@vatvaghool/create-ipl-dashboard 0.1.14 → 0.1.17

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 CHANGED
@@ -27,13 +27,16 @@ npx @vatvaghool/create-ipl-dashboard [project-name] [options]
27
27
  | Prompt | Description |
28
28
  |--------|-------------|
29
29
  | Project name | Directory to scaffold into |
30
- | MongoDB URI | Your MongoDB connection string (press Enter for a default) |
30
+ | Storage backend | `mongodb` (default) or `google_sheets` |
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) |
31
35
  | League URL | The fantasy.iplt20.com league page URL |
32
- | Collection name | MongoDB collection to store this league's data (e.g. `ipl_2025_office_league`) |
33
- | League name | Display name for your league |
34
- | Teams | Team names (and optional owners) in your league
36
+ | Collection/sheet name | MongoDB collection or Google sheet for this league's data |
37
+ | League name | Used as collection/table name in storage |
35
38
 
36
- If `--scrape` is provided, the CLI attempts to extract team names from the league page HTML. If that fails, it falls back to manual entry.
39
+ Set team names later via `app/data/teams.ts` or use `--scrape` to auto-detect from the league URL during scaffolding.
37
40
 
38
41
  ## Screenshots
39
42
 
@@ -81,16 +84,31 @@ cd my-league
81
84
 
82
85
  # Start the dev server
83
86
  npm run dev:simple
84
-
85
- # Capture auth state for scrapers (one-time setup)
86
- npm run capture:ipl-auth
87
-
88
- # Scrape live leaderboard data
89
- npm run sync:ipl
90
87
  ```
91
88
 
92
89
  Open http://localhost:3000 to see your dashboard.
93
90
 
91
+ ### Available commands
92
+
93
+ | Command | Description |
94
+ |---------|-------------|
95
+ | `npm run dev` | Dev server with welcome splash |
96
+ | `npm run dev:simple` | Dev server (simple, no splash) |
97
+ | `npm run build` | Production build |
98
+ | `npm start` | Production server |
99
+ | `npm run capture:ipl-auth` | Capture Playwright login state (one-time) |
100
+ | `npm run sync:ipl` | Scrape live leaderboard snapshot |
101
+ | `npm run sync:ipl:watch` | Scrape leaderboard in watch mode (polls every 2 min) |
102
+ | `npm run sync:ipl:transfers-daily` | Scrape transfer/booster data |
103
+ | `npm run sync:cloud` | Run both leaderboard + transfer sync (for cloud jobs) |
104
+ | `npm run seed:league` | Seed league metadata into storage |
105
+ | `npm run seed:mongodb` | Seed initial raw user data from `data.ts` |
106
+ | `npm run seed:mongodb:reset` | Reset and re-seed MongoDB data |
107
+ | `npm run verify:production` | Verify production setup |
108
+ | `npm run monitor:ops` | Check ops health status |
109
+ | `npm run test` | Run test suite |
110
+ | `npm run lint` | Run linter |
111
+
94
112
  ## How it works
95
113
 
96
114
  The CLI:
@@ -117,11 +135,13 @@ The template includes all dashboard components, API endpoints, scrapers, and tes
117
135
  npx @vatvaghool/create-ipl-dashboard another-league
118
136
  ```
119
137
 
120
- Provide a different collection name (e.g. `ipl_2025_friends_league`) and the new league will be stored in its own collection — data stays fully isolated.
138
+ Provide a different league name and the new league will be stored in its own collection/sheet — data stays fully isolated.
121
139
 
122
140
  ### Viewing seeded data
123
141
 
124
- Connect to the MongoDB instance with any MongoDB client. Each league appears as a separate collection containing a document with `type: "league"` and all the metadata (name, URL, teams, timestamps).
142
+ **MongoDB:** Connect with any MongoDB client. Each league appears as a separate collection with `type: "league"`.
143
+
144
+ **Google Sheets:** Open your spreadsheet in a browser. Each league has its own sheet (tab) with league metadata in row 2.
125
145
 
126
146
  ### Production deployment
127
147
 
@@ -131,4 +151,23 @@ npm run build
131
151
  npx vercel --prod
132
152
  ```
133
153
 
134
- Set `IPL_POST_SECRET` in your Vercel dashboard. The `MONGODB_URI` and `COLLECTION_NAME` are already populated in the scaffolded `.env`.
154
+ Set `IPL_POST_SECRET` in your Vercel dashboard. Storage credentials are already populated in the scaffolded `.env`.
155
+
156
+ ### Data sync workflow
157
+
158
+ ```bash
159
+ # 1. Capture browser login (one-time)
160
+ npm run capture:ipl-auth
161
+
162
+ # 2. Scrape leaderboard (manual or watch mode)
163
+ npm run sync:ipl
164
+ npm run sync:ipl:watch # auto-poll every 2 min
165
+
166
+ # 3. Scrape transfer/booster data
167
+ npm run sync:ipl:transfers-daily
168
+
169
+ # 4. Run all scrapers (for cloud jobs)
170
+ npm run sync:cloud
171
+ ```
172
+
173
+ See the [Available commands](#quick-start-after-scaffold) table above for all options.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vatvaghool/create-ipl-dashboard",
3
- "version": "0.1.14",
3
+ "version": "0.1.17",
4
4
  "description": "Scaffold an IPL fantasy cricket dashboard project",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.mjs CHANGED
@@ -3,12 +3,10 @@
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, getMongoUri, getLeagueUrl, getCollectionName, getLeagueName, getTeams, close } from "./prompts.mjs";
6
+ import { getProjectName, getConnectionUrl, getLeagueUrl, getLeagueName, close } from "./prompts.mjs";
7
7
  import { scaffoldProject } from "./scaffold.mjs";
8
8
  import { scrapeTeamsFromUrl } from "./scraper.mjs";
9
9
 
10
- const DEFAULT_MONGODB_URI = "mongodb+srv://admin:admin@cricket.ptjjrub.mongodb.net/?appName=cricket";
11
-
12
10
  function printHelp(exitCode) {
13
11
  console.log(`
14
12
  create-ipl-dashboard [project-name] [options]
@@ -61,9 +59,8 @@ async function main() {
61
59
 
62
60
  await mkdir(projectPath, { recursive: true });
63
61
 
64
- const mongoUri = await getMongoUri(DEFAULT_MONGODB_URI);
62
+ const conn = await getConnectionUrl();
65
63
  const leagueUrl = await getLeagueUrl();
66
- const collectionName = await getCollectionName();
67
64
  const leagueName = await getLeagueName();
68
65
  let teams;
69
66
 
@@ -72,17 +69,12 @@ async function main() {
72
69
  teams = await scrapeTeamsFromUrl(leagueUrl);
73
70
  if (teams && teams.length > 0) {
74
71
  console.log(` Found ${teams.length} team(s): ${teams.map((t) => t.name).join(", ")}`);
75
- } else {
76
- console.log(" Could not auto-detect teams. Enter them manually:");
77
- teams = await getTeams();
78
72
  }
79
- } else {
80
- teams = await getTeams();
81
73
  }
82
74
 
83
75
  close();
84
76
 
85
- await scaffoldProject(projectPath, { mongoUri, leagueUrl, collectionName, leagueName, teams, skipInstall: flags.skipInstall });
77
+ await scaffoldProject(projectPath, { conn, leagueUrl, leagueName, teams, skipInstall: flags.skipInstall });
86
78
 
87
79
  console.log("");
88
80
  console.log(" Next steps:");
package/src/prompts.mjs CHANGED
@@ -1,5 +1,6 @@
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";
3
4
 
4
5
  const isTTY = stdin.isTTY;
5
6
 
@@ -24,6 +25,86 @@ function prompt(query) {
24
25
  });
25
26
  }
26
27
 
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
+
27
108
  export async function getProjectName(args) {
28
109
  if (args[0]) return args[0];
29
110
  if (!isTTY) {
@@ -33,63 +114,47 @@ export async function getProjectName(args) {
33
114
  return (await prompt("Project name: ")).trim() || "ipl-dashboard";
34
115
  }
35
116
 
36
- export async function getMongoUri(defaultUri) {
37
- if (!isTTY) {
38
- await consumeAllLines();
39
- return (pipedLines[promptIndex++] || defaultUri || "").trim();
40
- }
41
- const answer = (await prompt(`MongoDB URI (press Enter for default): `)).trim();
42
- return answer || defaultUri || "";
117
+ export async function getStorageBackend() {
118
+ return select("Storage backend:", ["MongoDB", "Google Sheets"], BACKEND_VALUES);
43
119
  }
44
120
 
45
- export async function getLeagueUrl() {
46
- if (!isTTY) {
47
- await consumeAllLines();
48
- return (pipedLines[promptIndex++] || "").trim();
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 };
49
133
  }
50
- return (await prompt("IPL fantasy league URL (or press Enter to skip, can be set later): ")).trim();
51
- }
52
134
 
53
- export async function getCollectionName() {
135
+ const DEFAULT_MONGODB_URI = "mongodb+srv://admin:admin@cricket.ptjjrub.mongodb.net/?appName=cricket";
54
136
  if (!isTTY) {
55
137
  await consumeAllLines();
56
- return (pipedLines[promptIndex++] || "league_data").trim();
138
+ return { backend, uri: (pipedLines[promptIndex++] || DEFAULT_MONGODB_URI || "").trim() };
57
139
  }
58
- return (await prompt("MongoDB collection name for league data (e.g. ipl_2025_office_league): ")).trim() || "league_data";
140
+ const answer = (await prompt("MongoDB URI (press Enter for default): ")).trim();
141
+ return { backend, uri: answer || DEFAULT_MONGODB_URI || "" };
59
142
  }
60
143
 
61
- export async function getLeagueName() {
144
+ export async function getLeagueUrl() {
62
145
  if (!isTTY) {
63
146
  await consumeAllLines();
64
- return (pipedLines[promptIndex++] || "My IPL League").trim();
147
+ return (pipedLines[promptIndex++] || "").trim();
65
148
  }
66
- return (await prompt("League name (e.g. Office Fantasy League): ")).trim() || "My IPL League";
149
+ return (await prompt("IPL fantasy league URL: ")).trim();
67
150
  }
68
151
 
69
- export async function getTeams() {
152
+ export async function getLeagueName() {
70
153
  if (!isTTY) {
71
154
  await consumeAllLines();
72
- const count = Math.max(1, parseInt(pipedLines[promptIndex++], 10) || 9);
73
- const teams = [];
74
- for (let i = 0; i < count; i++) {
75
- const line = pipedLines[promptIndex++] || "";
76
- const parts = line.split(",").map((s) => s.trim());
77
- teams.push({ id: i + 1, name: parts[0] || `Team ${i + 1}`, owner: parts[1] || parts[0] || `Team ${i + 1}` });
78
- }
79
- return teams;
80
- }
81
-
82
- const countAnswer = await prompt("How many teams in your league? (default: 9): ");
83
- const count = Math.max(1, parseInt(countAnswer, 10) || 9);
84
-
85
- console.log(`\nEnter ${count} team(s) — at minimum a name, optionally an owner:\n`);
86
- const teams = [];
87
- for (let i = 0; i < count; i++) {
88
- const line = await prompt(` Team ${i + 1} (Name or "Name,Owner"): `);
89
- const parts = line.split(",").map((s) => s.trim());
90
- teams.push({ id: i + 1, name: parts[0] || `Team ${i + 1}`, owner: parts[1] || parts[0] || `Team ${i + 1}` });
155
+ return (pipedLines[promptIndex++] || "my_league").trim();
91
156
  }
92
- return teams;
157
+ return (await prompt("League name (used as collection/table name, e.g. my_office_league): ")).trim() || "my_league";
93
158
  }
94
159
 
95
160
  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
- { mongoUri, leagueUrl, collectionName, leagueName, teams, skipInstall = false },
13
+ { conn, leagueUrl, leagueName, teams, skipInstall = false },
14
14
  ) {
15
15
  console.log(`\nCreating project at ${projectPath}...`);
16
16
 
@@ -34,15 +34,14 @@ export async function scaffoldProject(
34
34
  }
35
35
 
36
36
  await writeEnvFile(projectPath, {
37
- mongoUri,
37
+ conn,
38
38
  leagueUrl,
39
- collectionName,
40
39
  leagueName,
41
40
  });
42
- await writeTeamData(projectPath, teams);
43
- await writeLeagueData(projectPath, { leagueName, leagueUrl, teams });
41
+ await writeTeamData(projectPath, teams || []);
42
+ await writeLeagueData(projectPath, { leagueName, leagueUrl, teams: teams || [] });
44
43
  await writeMatchPointsPlaceholder(projectPath);
45
- await updatePackageJson(projectPath);
44
+ await updatePackageJson(projectPath, conn.backend);
46
45
 
47
46
  for (const f of [
48
47
  "app/api/ipl/live-snapshot.json",
@@ -57,15 +56,15 @@ export async function scaffoldProject(
57
56
  console.log(" Running npm install...");
58
57
  execSync("npm install", { cwd: projectPath, stdio: "inherit" });
59
58
 
60
- if (mongoUri && leagueName) {
61
- console.log(" Seeding league metadata into MongoDB...");
59
+ if (leagueName) {
60
+ console.log(" Seeding league metadata...");
62
61
  try {
63
- execSync(`npm run seed:league -- "${leagueName}" "${collectionName}"`, {
62
+ execSync(`npm run seed:league -- "${leagueName}" "${leagueName}"`, {
64
63
  cwd: projectPath,
65
64
  stdio: "inherit",
66
65
  });
67
66
  } catch {
68
- console.log(" League seed skipped (MongoDB may not be reachable yet)");
67
+ console.log(" League seed skipped (storage may not be reachable yet)");
69
68
  }
70
69
  }
71
70
  } else {
@@ -79,12 +78,15 @@ export async function scaffoldProject(
79
78
 
80
79
  async function writeEnvFile(
81
80
  projectPath,
82
- { mongoUri, leagueUrl, collectionName, leagueName },
81
+ { conn, leagueUrl, leagueName },
83
82
  ) {
84
83
  const envPath = join(projectPath, ".env");
85
84
  const lines = [
86
- `MONGODB_URI=${mongoUri || ""}`,
87
- `COLLECTION_NAME=${collectionName || "league_data"}`,
85
+ `STORAGE_BACKEND=${conn.backend || "mongodb"}`,
86
+ `COLLECTION_NAME=${leagueName || "my_league"}`,
87
+ conn.backend === "google_sheets" ? `GOOGLE_SHEET_ID=${conn.sheetId || ""}` : `MONGODB_URI=${conn.uri || ""}`,
88
+ conn.backend === "google_sheets" ? `GOOGLE_SERVICE_ACCOUNT_EMAIL=${conn.serviceAccountEmail || ""}` : "",
89
+ conn.backend === "google_sheets" ? `GOOGLE_PRIVATE_KEY=${conn.privateKey || ""}` : "",
88
90
  `IPL_LEAGUE_URL=${leagueUrl || ""}`,
89
91
  `IPL_LEAGUE_NAME=${leagueName || ""}`,
90
92
  `IPL_POST_SECRET=`,
@@ -94,7 +96,7 @@ async function writeEnvFile(
94
96
  `IPL_API_LOG_PAYLOAD=0`,
95
97
  `IPL_WRITE_SEED_DATA_FILE=1`,
96
98
  ``,
97
- ];
99
+ ].filter(Boolean);
98
100
  await writeFile(envPath, lines.join("\n"));
99
101
  }
100
102
 
@@ -180,7 +182,7 @@ async function writeMatchPointsPlaceholder(projectPath) {
180
182
  await writeFile(join(dir, "match-points.ts"), content);
181
183
  }
182
184
 
183
- async function updatePackageJson(projectPath) {
185
+ async function updatePackageJson(projectPath, storageBackend) {
184
186
  const pkgPath = join(projectPath, "package.json");
185
187
  try {
186
188
  const raw = await readFile(pkgPath, "utf8");
@@ -189,6 +191,10 @@ async function updatePackageJson(projectPath) {
189
191
  pkg.private = false;
190
192
  delete pkg.scripts?.["generate:template"];
191
193
  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
+ }
192
198
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
193
199
  } catch {}
194
200
  }
@@ -12,7 +12,7 @@ import {
12
12
  getTransformOptions,
13
13
  } from "./transform";
14
14
  import { rawApiUsers } from "./data";
15
- import getMongoDb from "../../lib/useDb";
15
+ import { getStorage } from "../../lib/storage";
16
16
  import { config } from "../../lib/config.ts";
17
17
 
18
18
  export const dynamic = "force-dynamic";
@@ -54,39 +54,24 @@ const log = (message: string, details?: unknown) => {
54
54
 
55
55
  const opts = getTransformOptions();
56
56
 
57
- const getDb = async () => {
58
- try {
59
- return await getMongoDb();
60
- } catch (error) {
61
- console.error("MongoDB connection failed:", error);
62
- return null;
63
- }
64
- };
57
+ const storage = getStorage();
65
58
 
66
59
  const readMongoSnapshot = async () => {
67
- const db = await getDb();
68
- if (!db) return null;
69
60
  try {
70
- const document = await db
71
- .collection(DASHBOARD_COLLECTION)
72
- .findOne({ type: DASHBOARD_DOCUMENT_TYPE });
61
+ const document = await storage.readDocument(DASHBOARD_DOCUMENT_TYPE);
73
62
  return normalizePayload(document);
74
63
  } catch (error) {
75
- console.error("Failed to read IPL snapshot from MongoDB:", error);
64
+ console.error("Failed to read IPL snapshot from storage:", error);
76
65
  return null;
77
66
  }
78
67
  };
79
68
 
80
69
  const readMongoRawUsers = async () => {
81
- const db = await getDb();
82
- if (!db) return null;
83
70
  try {
84
- const document = await db
85
- .collection(DASHBOARD_COLLECTION)
86
- .findOne({ type: RAW_USERS_DOCUMENT_TYPE });
87
- return normalizeRawApiUsers(document?.users);
71
+ const document = await storage.readDocument(RAW_USERS_DOCUMENT_TYPE);
72
+ return normalizeRawApiUsers((document?.users as any[]) ?? null);
88
73
  } catch (error) {
89
- console.error("Failed to read IPL raw users from MongoDB:", error);
74
+ console.error("Failed to read IPL raw users from storage:", error);
90
75
  return null;
91
76
  }
92
77
  };
@@ -192,60 +177,44 @@ const snapshotSummary = (payload: ScrapedDashboardPayload | null) => {
192
177
  };
193
178
 
194
179
  const writeMongoSnapshot = async (payload: ScrapedDashboardPayload) => {
195
- const db = await getDb();
196
- if (!db) return { configured: false, stored: false };
180
+ if (!storage.isConfigured()) return { configured: false, stored: false };
197
181
  try {
198
182
  const current = await readMongoSnapshot();
199
183
  if (snapshotsAreEqual(current, payload)) {
200
184
  return { configured: true, stored: true, status: "unchanged" as const };
201
185
  }
202
- await db.collection(DASHBOARD_COLLECTION).updateOne(
203
- { type: DASHBOARD_DOCUMENT_TYPE },
204
- {
205
- $set: {
206
- type: DASHBOARD_DOCUMENT_TYPE,
207
- updatedAt: payload.updatedAt,
208
- dailyTransferUpdatedAt: payload.dailyTransferUpdatedAt,
209
- completedMatches: payload.completedMatches,
210
- leaders: payload.leaders.map((l) => ({
211
- ...l,
212
- teamId: resolveTeamId(l.name),
213
- })),
214
- },
215
- },
216
- { upsert: true },
217
- );
218
- return { configured: true, stored: true, status: "updated" as const };
186
+ const result = await storage.upsertDocument(DASHBOARD_DOCUMENT_TYPE, {
187
+ updatedAt: payload.updatedAt,
188
+ dailyTransferUpdatedAt: payload.dailyTransferUpdatedAt,
189
+ completedMatches: payload.completedMatches,
190
+ leaders: payload.leaders.map((l) => ({
191
+ ...l,
192
+ teamId: resolveTeamId(l.name),
193
+ })),
194
+ });
195
+ return { configured: result.configured, stored: result.stored, status: result.status || "updated" };
219
196
  } catch (error) {
220
- console.error("Failed to write IPL snapshot to MongoDB:", error);
197
+ console.error("Failed to write IPL snapshot to storage:", error);
221
198
  return { configured: true, stored: false, status: "failed" as const };
222
199
  }
223
200
  };
224
201
 
225
202
  const writeMongoRawUsers = async (users: RawApiUser[]) => {
226
- const db = await getDb();
227
- if (!db) return { configured: false, stored: false };
203
+ if (!storage.isConfigured()) return { configured: false, stored: false };
228
204
  try {
229
- await db.collection(DASHBOARD_COLLECTION).updateOne(
230
- { type: RAW_USERS_DOCUMENT_TYPE },
231
- {
232
- $set: {
233
- type: RAW_USERS_DOCUMENT_TYPE,
234
- updatedAt: new Date().toISOString(),
235
- users: users.map((user) => ({
236
- rno: user.rno,
237
- temname: user.temname,
238
- points: user.points,
239
- teamId: resolveTeamId(user.temname),
240
- matches: [...user.matches].sort((a, b) => a.matchId - b.matchId),
241
- })),
242
- },
243
- },
244
- { upsert: true },
245
- );
246
- return { configured: true, stored: true };
205
+ const result = await storage.upsertDocument(RAW_USERS_DOCUMENT_TYPE, {
206
+ updatedAt: new Date().toISOString(),
207
+ users: users.map((user) => ({
208
+ rno: user.rno,
209
+ temname: user.temname,
210
+ points: user.points,
211
+ teamId: resolveTeamId(user.temname),
212
+ matches: [...user.matches].sort((a, b) => a.matchId - b.matchId),
213
+ })),
214
+ });
215
+ return { configured: result.configured, stored: result.stored };
247
216
  } catch (error) {
248
- console.error("Failed to write IPL raw users to MongoDB:", error);
217
+ console.error("Failed to write IPL raw users to storage:", error);
249
218
  return { configured: true, stored: false };
250
219
  }
251
220
  };
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from "next/server";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
- import getMongoDb from "../../../lib/useDb";
3
+ import { getStorage } from "../../../lib/storage";
4
4
  import { config } from "../../../lib/config.ts";
5
5
  import type {
6
6
  ScrapedTransferItem,
@@ -49,35 +49,17 @@ const log = (message: string, details?: unknown) => {
49
49
  console.log(LOG_TAG, message, details);
50
50
  };
51
51
 
52
- const getDb = async () => {
53
- try {
54
- return await getMongoDb();
55
- } catch (error) {
56
- console.error("MongoDB connection failed:", error);
57
- return null;
58
- }
59
- };
52
+ const storage = getStorage();
60
53
 
61
54
  const readMongoTransfers = async () => {
62
- const db = await getDb();
63
-
64
- if (!db) {
65
- return null;
66
- }
67
-
68
55
  try {
69
- const documents = await db
70
- .collection(DASHBOARD_COLLECTION)
71
- .find({ type: TRANSFER_DOCUMENT_TYPE })
72
- .toArray();
73
-
56
+ const documents = await storage.readDocuments(TRANSFER_DOCUMENT_TYPE);
74
57
  const teams = documents
75
58
  .map(normalizeTransferItem)
76
59
  .filter(Boolean) as ScrapedTransferItem[];
77
-
78
60
  return teams.length > 0 ? teams : null;
79
61
  } catch (error) {
80
- console.error("Failed to read IPL transfer stats from MongoDB:", error);
62
+ console.error("Failed to read IPL transfer stats from storage:", error);
81
63
  return null;
82
64
  }
83
65
  };
@@ -98,45 +80,23 @@ const readSnapshotFile = async () => {
98
80
  };
99
81
 
100
82
  const writeMongoTransfer = async (record: ScrapedTransferItem) => {
101
- const db = await getDb();
102
-
103
- if (!db) {
104
- return {
105
- configured: false,
106
- stored: false,
107
- };
108
- }
109
-
83
+ if (!storage.isConfigured()) return { configured: false, stored: false };
110
84
  try {
111
- await db.collection(DASHBOARD_COLLECTION).updateOne(
85
+ const result = await storage.upsertDocument(
86
+ TRANSFER_DOCUMENT_TYPE,
112
87
  {
113
- type: TRANSFER_DOCUMENT_TYPE,
114
88
  team: record.team,
89
+ matchesPlayed: record.matchesPlayed,
90
+ boostersUsed: record.boostersUsed,
91
+ transfersLeft: record.transfersLeft,
92
+ updatedAt: record.updatedAt,
115
93
  },
116
- {
117
- $set: {
118
- type: TRANSFER_DOCUMENT_TYPE,
119
- team: record.team,
120
- matchesPlayed: record.matchesPlayed,
121
- boostersUsed: record.boostersUsed,
122
- transfersLeft: record.transfersLeft,
123
- updatedAt: record.updatedAt,
124
- },
125
- },
126
- { upsert: true },
94
+ { team: record.team },
127
95
  );
128
-
129
- return {
130
- configured: true,
131
- stored: true,
132
- };
96
+ return { configured: result.configured, stored: result.stored };
133
97
  } catch (error) {
134
- console.error("Failed to write IPL transfer stats to MongoDB:", error);
135
-
136
- return {
137
- configured: true,
138
- stored: false,
139
- };
98
+ console.error("Failed to write IPL transfer stats to storage:", error);
99
+ return { configured: true, stored: false };
140
100
  }
141
101
  };
142
102
 
@@ -1,8 +1,8 @@
1
1
  import { NextResponse } from "next/server";
2
- import getMongoDb from "../../../lib/useDb";
2
+ import { getStorage } from "../../../lib/storage";
3
3
  import { config } from "../../../lib/config.ts";
4
4
  import { normalizePayload } from "../../ipl/transform";
5
- import { summarizeTransferSnapshot } from "../../ipl/transfers/transform";
5
+ import { normalizeTransferItem, summarizeTransferSnapshot } from "../../ipl/transfers/transform";
6
6
 
7
7
  export const dynamic = "force-dynamic";
8
8
  export const revalidate = 0;
@@ -64,73 +64,38 @@ const getFreshness = (
64
64
  };
65
65
  };
66
66
 
67
- const getDb = async () => {
68
- try {
69
- return await getMongoDb();
70
- } catch (error) {
71
- console.error("MongoDB connection failed:", error);
72
- return null;
73
- }
74
- };
67
+ const storage = getStorage();
75
68
 
76
69
  export async function GET() {
77
- const mongoConfigured = Boolean(process.env.MONGODB_URI);
78
- const db = await getDb();
70
+ const storageConfigured = storage.isConfigured();
71
+ const storageConnected = await storage.isConnected();
79
72
 
80
- if (!db) {
73
+ if (!storageConnected) {
81
74
  return NextResponse.json(
82
75
  {
83
76
  ok: !isProduction,
84
77
  mode: isProduction ? "cloud" : "local",
85
- mongoConfigured,
78
+ mongoConfigured: storageConfigured,
86
79
  mongoConnected: false,
87
- reason: mongoConfigured
88
- ? "MongoDB connection failed."
89
- : "MONGODB_URI is not configured.",
80
+ storageBackend: process.env.STORAGE_BACKEND || "mongodb",
81
+ reason: storageConfigured
82
+ ? "Storage connection failed."
83
+ : "Storage is not configured.",
90
84
  },
91
- { status: mongoConfigured ? 503 : 200 },
85
+ { status: storageConfigured ? 503 : 200 },
92
86
  );
93
87
  }
94
88
 
95
89
  const [dashboardDocument, rawUsersDocument, transferDocuments] = await Promise.all([
96
- db.collection(DASHBOARD_COLLECTION).findOne({ type: DASHBOARD_DOCUMENT_TYPE }),
97
- db.collection(DASHBOARD_COLLECTION).findOne({ type: RAW_USERS_DOCUMENT_TYPE }),
98
- db
99
- .collection(DASHBOARD_COLLECTION)
100
- .find({ type: TRANSFER_DOCUMENT_TYPE })
101
- .toArray(),
90
+ storage.readDocument(DASHBOARD_DOCUMENT_TYPE),
91
+ storage.readDocument(RAW_USERS_DOCUMENT_TYPE),
92
+ storage.readDocuments(TRANSFER_DOCUMENT_TYPE),
102
93
  ]);
103
94
  const snapshot = normalizePayload(dashboardDocument);
104
- const transfers = summarizeTransferSnapshot(
105
- transferDocuments
106
- .map((document) => ({
107
- team:
108
- typeof document.team === "string" ? document.team : "",
109
- matchesPlayed:
110
- typeof document.matchesPlayed === "number"
111
- ? document.matchesPlayed
112
- : Number.NaN,
113
- boostersUsed:
114
- typeof document.boostersUsed === "number"
115
- ? document.boostersUsed
116
- : Number.NaN,
117
- transfersLeft:
118
- typeof document.transfersLeft === "string"
119
- ? document.transfersLeft
120
- : String(document.transfersLeft ?? "").trim(),
121
- updatedAt:
122
- typeof document.updatedAt === "string"
123
- ? document.updatedAt
124
- : new Date(0).toISOString(),
125
- }))
126
- .filter(
127
- (item) =>
128
- item.team &&
129
- Number.isFinite(item.matchesPlayed) &&
130
- Number.isFinite(item.boostersUsed) &&
131
- item.transfersLeft,
132
- ),
133
- );
95
+ const transferItems = transferDocuments
96
+ .map((doc) => normalizeTransferItem(doc as any))
97
+ .filter(Boolean) as any[];
98
+ const transfers = summarizeTransferSnapshot(transferItems);
134
99
  const transfersUpdatedAt =
135
100
  transfers.teams.length > 0 ? transfers.updatedAt : undefined;
136
101
  const dashboardFreshness = getFreshness(
@@ -190,8 +155,9 @@ export async function GET() {
190
155
  status,
191
156
  mode: isProduction ? "cloud" : "local",
192
157
  checkedAt: new Date().toISOString(),
193
- mongoConfigured,
194
- mongoConnected: true,
158
+ mongoConfigured: storageConfigured,
159
+ mongoConnected: storageConnected,
160
+ storageBackend: process.env.STORAGE_BACKEND || "mongodb",
195
161
  localSnapshotFallbackEnabled: !isProduction,
196
162
  thresholds: {
197
163
  dashboardStaleMinutes,
@@ -207,7 +173,7 @@ export async function GET() {
207
173
  }
208
174
  : null,
209
175
  rawUsers: {
210
- count: Array.isArray(rawUsersDocument?.users) ? rawUsersDocument.users.length : 0,
176
+ count: Array.isArray((rawUsersDocument as any)?.users) ? (rawUsersDocument as any).users.length : 0,
211
177
  updatedAt:
212
178
  typeof rawUsersDocument?.updatedAt === "string"
213
179
  ? rawUsersDocument.updatedAt
@@ -1,6 +1,10 @@
1
1
  import path from "node:path";
2
2
 
3
3
  export const config = {
4
+ storage: {
5
+ backend: process.env.STORAGE_BACKEND || "mongodb",
6
+ },
7
+
4
8
  mongodb: {
5
9
  databaseName: process.env.IPL_DB_NAME || "ipl",
6
10
  collectionName: process.env.IPL_COLLECTION_NAME || "ipl",
@@ -0,0 +1,147 @@
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
+ }
@@ -0,0 +1,20 @@
1
+ import type { IStorage } from "./types";
2
+ import { MongoStorage } from "./mongo-storage";
3
+ import { GoogleSheetsStorage } from "./google-sheets-storage";
4
+
5
+ let _instance: IStorage | null = null;
6
+
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
+ export function getStorage(): IStorage {
14
+ if (_instance) return _instance;
15
+ const backend = detectBackend();
16
+ _instance = backend === "google_sheets" ? new GoogleSheetsStorage() : new MongoStorage();
17
+ return _instance;
18
+ }
19
+
20
+ export type { IStorage } from "./types";
@@ -0,0 +1,53 @@
1
+ import getMongoDb from "../useDb";
2
+ import { config } from "../config";
3
+ import type { IStorage, StorageResult } from "./types";
4
+
5
+ const COLLECTION = config.mongodb.collectionName;
6
+
7
+ export class MongoStorage implements IStorage {
8
+ isConfigured(): boolean {
9
+ return Boolean(process.env.MONGODB_URI);
10
+ }
11
+
12
+ async isConnected(): Promise<boolean> {
13
+ const db = await getMongoDb();
14
+ return db !== null;
15
+ }
16
+
17
+ async readDocument(type: string): Promise<Record<string, unknown> | null> {
18
+ const db = await getMongoDb();
19
+ if (!db) return null;
20
+ try {
21
+ return (await db.collection(COLLECTION).findOne({ type })) as Record<string, unknown> | null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ async upsertDocument(
28
+ type: string,
29
+ data: Record<string, unknown>,
30
+ filter?: Record<string, unknown>,
31
+ ): Promise<StorageResult> {
32
+ const db = await getMongoDb();
33
+ if (!db) return { configured: true, stored: false, status: "no_connection" };
34
+ try {
35
+ const query = filter ? { type, ...filter } : { type };
36
+ await db.collection(COLLECTION).updateOne(query, { $set: { type, ...data } }, { upsert: true });
37
+ return { configured: true, stored: true, status: "updated" };
38
+ } catch (error) {
39
+ console.error(`Failed to upsert document type "${type}" in MongoDB:`, error);
40
+ return { configured: true, stored: false, status: "failed" };
41
+ }
42
+ }
43
+
44
+ async readDocuments(type: string): Promise<Record<string, unknown>[]> {
45
+ const db = await getMongoDb();
46
+ if (!db) return [];
47
+ try {
48
+ return (await db.collection(COLLECTION).find({ type }).toArray()) as Record<string, unknown>[];
49
+ } catch {
50
+ return [];
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,13 @@
1
+ export interface StorageResult {
2
+ configured: boolean;
3
+ stored: boolean;
4
+ status?: string;
5
+ }
6
+
7
+ export interface IStorage {
8
+ readDocument(type: string): Promise<Record<string, unknown> | null>;
9
+ upsertDocument(type: string, data: Record<string, unknown>, filter?: Record<string, unknown>): Promise<StorageResult>;
10
+ readDocuments(type: string): Promise<Record<string, unknown>[]>;
11
+ isConfigured(): boolean;
12
+ isConnected(): Promise<boolean>;
13
+ }
@@ -29,6 +29,61 @@ 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
+
32
87
  const uri = process.env.MONGODB_URI;
33
88
  if (!uri) {
34
89
  console.log("MONGODB_URI not set. Skipping league seed.");