@vatvaghool/create-ipl-dashboard 0.1.6 → 0.1.13

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
@@ -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, and team names — then get a ready-to-run Next.js dashboard with standings charts, performance trackers, AI roasts, and more.
9
+ Follow the prompts to enter your fantasy league URL, collection name, and team names — the database is pre-configured, so you don't need to provide a MongoDB URI. 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
 
@@ -26,10 +26,14 @@ npx @vatvaghool/create-ipl-dashboard [project-name] [options]
26
26
 
27
27
  | Prompt | Description |
28
28
  |--------|-------------|
29
- | MongoDB URI | Your MongoDB connection string (or leave blank for file-based fallback) |
29
+ | Project name | Directory to scaffold into |
30
30
  | League URL | The fantasy.iplt20.com league page URL |
31
+ | Collection name | MongoDB collection to store this league's data (e.g. `ipl_2025_office_league`) |
32
+ | League name | Display name for your league |
31
33
  | Teams | Team names (and optional owners) in your league |
32
34
 
35
+ The database connection (`MONGODB_URI`) is hardcoded — you only need to specify which collection each league should use.
36
+
33
37
  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.
34
38
 
35
39
  ## Screenshots
@@ -69,7 +73,7 @@ A full Next.js 16 project with:
69
73
  - **AI roasting** — generated commentary in multiple languages
70
74
  - **Stock ticker** — fantasy stocks with sparklines
71
75
  - **Live updates** — bookmarklet or Playwright scraper for live sync
72
- - **MongoDB persistence** — optional, with file-based fallback for dev
76
+ - **MongoDB persistence** — auto-configured database connection
73
77
 
74
78
  ## Quick start after scaffold
75
79
 
@@ -93,9 +97,39 @@ Open http://localhost:3000 to see your dashboard.
93
97
  The CLI:
94
98
 
95
99
  1. Copies a pre-built Next.js app template
96
- 2. Writes your `.env` with MongoDB URI and league URL
100
+ 2. Writes your `.env` with the pre-configured `MONGODB_URI`, `COLLECTION_NAME`, league URL, and league name
97
101
  3. Generates `app/data/teams.ts` with your team roster
98
- 4. Creates a placeholder `app/data/match-points.ts` (auto-populates as you sync)
99
- 5. Installs dependencies
102
+ 4. Generates `app/data/league.ts` with league metadata
103
+ 5. Creates a placeholder `app/data/match-points.ts` (auto-populates as you sync)
104
+ 6. Installs dependencies
105
+ 7. Runs `seed:league` to create a **document in your specified collection** in the pre-configured database, storing league metadata (name, URL, teams, timestamps)
106
+
107
+ Each league gets its own collection — run `create-ipl-dashboard` again with a different collection name to add another league.
100
108
 
101
109
  The template includes all dashboard components, API endpoints, scrapers, and tests from the [ipl-dashboard](https://github.com/anomalyco/ipl-dashboard) project.
110
+
111
+ ---
112
+
113
+ ## Next Steps
114
+
115
+ ### Adding more leagues
116
+
117
+ ```bash
118
+ npx @vatvaghool/create-ipl-dashboard another-league
119
+ ```
120
+
121
+ 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.
122
+
123
+ ### Viewing seeded data
124
+
125
+ 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).
126
+
127
+ ### Production deployment
128
+
129
+ ```bash
130
+ cd my-league
131
+ npm run build
132
+ npx vercel --prod
133
+ ```
134
+
135
+ Set `IPL_POST_SECRET` in your Vercel dashboard. The `MONGODB_URI` and `COLLECTION_NAME` are already configured in the scaffolded `.env`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vatvaghool/create-ipl-dashboard",
3
- "version": "0.1.6",
3
+ "version": "0.1.13",
4
4
  "description": "Scaffold an IPL fantasy cricket dashboard project",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.mjs CHANGED
@@ -3,10 +3,12 @@
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, getTeams, close } from "./prompts.mjs";
6
+ import { getProjectName, getLeagueUrl, getCollectionName, getLeagueName, getTeams, close } from "./prompts.mjs";
7
7
  import { scaffoldProject } from "./scaffold.mjs";
8
8
  import { scrapeTeamsFromUrl } from "./scraper.mjs";
9
9
 
10
+ const MONGODB_URI = "mongodb+srv://admin:admin@cricket.ptjjrub.mongodb.net/?appName=cricket";
11
+
10
12
  function printHelp(exitCode) {
11
13
  console.log(`
12
14
  create-ipl-dashboard [project-name] [options]
@@ -59,8 +61,9 @@ async function main() {
59
61
 
60
62
  await mkdir(projectPath, { recursive: true });
61
63
 
62
- const mongoUri = await getMongoUri();
63
64
  const leagueUrl = await getLeagueUrl();
65
+ const collectionName = await getCollectionName();
66
+ const leagueName = await getLeagueName();
64
67
  let teams;
65
68
 
66
69
  if (flags.scrape && leagueUrl) {
@@ -78,7 +81,7 @@ async function main() {
78
81
 
79
82
  close();
80
83
 
81
- await scaffoldProject(projectPath, { mongoUri, leagueUrl, teams, skipInstall: flags.skipInstall });
84
+ await scaffoldProject(projectPath, { mongoUri: MONGODB_URI, leagueUrl, collectionName, leagueName, teams, skipInstall: flags.skipInstall });
82
85
 
83
86
  console.log("");
84
87
  console.log(" Next steps:");
package/src/prompts.mjs CHANGED
@@ -33,20 +33,28 @@ export async function getProjectName(args) {
33
33
  return (await prompt("Project name: ")).trim() || "ipl-dashboard";
34
34
  }
35
35
 
36
- export async function getMongoUri() {
36
+ export async function getLeagueUrl() {
37
37
  if (!isTTY) {
38
38
  await consumeAllLines();
39
39
  return (pipedLines[promptIndex++] || "").trim();
40
40
  }
41
- return (await prompt("MongoDB URI (or press Enter to skip, can be set later): ")).trim();
41
+ return (await prompt("IPL fantasy league URL (or press Enter to skip, can be set later): ")).trim();
42
42
  }
43
43
 
44
- export async function getLeagueUrl() {
44
+ export async function getCollectionName() {
45
45
  if (!isTTY) {
46
46
  await consumeAllLines();
47
- return (pipedLines[promptIndex++] || "").trim();
47
+ return (pipedLines[promptIndex++] || "league_data").trim();
48
48
  }
49
- return (await prompt("IPL fantasy league URL (or press Enter to skip, can be set later): ")).trim();
49
+ return (await prompt("MongoDB collection name for league data (e.g. ipl_2025_office_league): ")).trim() || "league_data";
50
+ }
51
+
52
+ export async function getLeagueName() {
53
+ if (!isTTY) {
54
+ await consumeAllLines();
55
+ return (pipedLines[promptIndex++] || "My IPL League").trim();
56
+ }
57
+ return (await prompt("League name (e.g. Office Fantasy League): ")).trim() || "My IPL League";
50
58
  }
51
59
 
52
60
  export async function getTeams() {
package/src/scaffold.mjs CHANGED
@@ -8,7 +8,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const PACKAGE_ROOT = join(__dirname, "..");
9
9
  const TEMPLATE_DIR = join(PACKAGE_ROOT, "template");
10
10
 
11
- export async function scaffoldProject(projectPath, { mongoUri, leagueUrl, teams, skipInstall = false }) {
11
+ export async function scaffoldProject(
12
+ projectPath,
13
+ { mongoUri, leagueUrl, collectionName, leagueName, teams, skipInstall = false },
14
+ ) {
12
15
  console.log(`\nCreating project at ${projectPath}...`);
13
16
 
14
17
  await mkdir(projectPath, { recursive: true });
@@ -19,36 +22,71 @@ export async function scaffoldProject(projectPath, { mongoUri, leagueUrl, teams,
19
22
  console.log(" (no template directory found, generating from scratch)");
20
23
  }
21
24
 
22
- // Remove monorepo-specific files not needed in scaffolded project
23
- for (const f of ["AGENTS.md", "tsconfig.tsbuildinfo", "tsconfig.buildinfo", "package-lock.json"]) {
24
- try { await rm(join(projectPath, f)); } catch {}
25
+ for (const f of [
26
+ "AGENTS.md",
27
+ "tsconfig.tsbuildinfo",
28
+ "tsconfig.buildinfo",
29
+ "package-lock.json",
30
+ ]) {
31
+ try {
32
+ await rm(join(projectPath, f));
33
+ } catch {}
25
34
  }
26
35
 
27
- await writeEnvFile(projectPath, { mongoUri, leagueUrl });
36
+ await writeEnvFile(projectPath, {
37
+ mongoUri,
38
+ leagueUrl,
39
+ collectionName,
40
+ leagueName,
41
+ });
28
42
  await writeTeamData(projectPath, teams);
43
+ await writeLeagueData(projectPath, { leagueName, leagueUrl, teams });
29
44
  await writeMatchPointsPlaceholder(projectPath);
30
45
  await updatePackageJson(projectPath);
31
46
 
32
- // Remove monorepo's live snapshots (they're specific to original league)
33
- for (const f of ["app/api/ipl/live-snapshot.json", "app/api/ipl/transfers/live-snapshot.json"]) {
34
- try { await rm(join(projectPath, f)); } catch {}
47
+ for (const f of [
48
+ "app/api/ipl/live-snapshot.json",
49
+ "app/api/ipl/transfers/live-snapshot.json",
50
+ ]) {
51
+ try {
52
+ await rm(join(projectPath, f));
53
+ } catch {}
35
54
  }
36
55
 
37
56
  if (!skipInstall) {
38
57
  console.log(" Running npm install...");
39
58
  execSync("npm install", { cwd: projectPath, stdio: "inherit" });
59
+
60
+ if (mongoUri && leagueName) {
61
+ console.log(" Seeding league metadata into MongoDB...");
62
+ try {
63
+ execSync(`npm run seed:league -- "${leagueName}" "${collectionName}"`, {
64
+ cwd: projectPath,
65
+ stdio: "inherit",
66
+ });
67
+ } catch {
68
+ console.log(" League seed skipped (MongoDB may not be reachable yet)");
69
+ }
70
+ }
40
71
  } else {
41
- console.log(" Skipping npm install (run manually: cd <project> && npm install)");
72
+ console.log(
73
+ " Skipping npm install (run manually: cd <project> && npm install)",
74
+ );
42
75
  }
43
76
 
44
77
  console.log("\n Done! Your project is ready at:", projectPath);
45
78
  }
46
79
 
47
- async function writeEnvFile(projectPath, { mongoUri, leagueUrl }) {
80
+ async function writeEnvFile(
81
+ projectPath,
82
+ { mongoUri, leagueUrl, collectionName, leagueName },
83
+ ) {
48
84
  const envPath = join(projectPath, ".env");
49
85
  const lines = [
50
86
  `MONGODB_URI=${mongoUri || ""}`,
87
+ `COLLECTION_NAME=${collectionName || "league_data"}`,
51
88
  `IPL_LEAGUE_URL=${leagueUrl || ""}`,
89
+ `IPL_LEAGUE_NAME=${leagueName || ""}`,
52
90
  `IPL_POST_SECRET=`,
53
91
  ``,
54
92
  `IPL_API_BASE_URL=http://localhost:3000`,
@@ -100,6 +138,32 @@ for (const team of TEAMS) {
100
138
  await writeFile(join(dir, "teams.ts"), content);
101
139
  }
102
140
 
141
+ async function writeLeagueData(projectPath, { leagueName, leagueUrl, teams }) {
142
+ const dir = join(projectPath, "app/data");
143
+ await mkdir(dir, { recursive: true });
144
+
145
+ const teamEntries = teams
146
+ .map((t) => ` { id: ${t.id}, name: "${t.name}", owner: "${t.owner}" }`)
147
+ .join(",\n");
148
+
149
+ const content = `export type LeagueInfo = {
150
+ name: string;
151
+ leagueUrl: string;
152
+ teams: Array<{ id: number; name: string; owner: string }>;
153
+ };
154
+
155
+ export const leagueInfo: LeagueInfo = {
156
+ name: "${leagueName || ""}",
157
+ leagueUrl: "${leagueUrl || ""}",
158
+ teams: [
159
+ ${teamEntries},
160
+ ],
161
+ };
162
+ `;
163
+
164
+ await writeFile(join(dir, "league.ts"), content);
165
+ }
166
+
103
167
  async function writeMatchPointsPlaceholder(projectPath) {
104
168
  const dir = join(projectPath, "app/data");
105
169
  await mkdir(dir, { recursive: true });
@@ -124,6 +188,7 @@ async function updatePackageJson(projectPath) {
124
188
  pkg.name = "ipl-dashboard";
125
189
  pkg.private = false;
126
190
  delete pkg.scripts?.["generate:template"];
191
+ pkg.scripts["seed:league"] = "node scripts/seed-league.mjs";
127
192
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
128
193
  } catch {}
129
194
  }
@@ -67,7 +67,7 @@ Open http://localhost:3000
67
67
  | **StickyMini** | `components/dashboard/StickyMini.tsx` | Compact stat card with title, value, note, and color-coded icon |
68
68
  | **SectionCard** | `components/dashboard/SectionCard.tsx` | Animated motion-section wrapper with title, note, accent color, and staggered entrance |
69
69
  | **ChartBoard** | `components/dashboard/ChartBoard.tsx` | Recharts bar chart for team data with pill tags and tooltips |
70
- | **CaptainBoard** | `components/dashboard/CaptainBoard.tsx` | Displays captain/vice-captain picks per team with shield icons |
70
+ | **CaptainBoard** | `components/dashboard/CaptainBoard.tsx` | All playing players with points as TeamPills badges, C/VC highlighted |
71
71
  | **LedgerTable** | `components/dashboard/LedgerTable.tsx` | Full standings table with colors, boosters, rank arrows, and formatted points |
72
72
  | **LatestBadge** | `components/dashboard/LatestBadge.tsx` | Color-coded pill badge for points (pink <200, orange 200-400, green >400) |
73
73
 
@@ -105,7 +105,7 @@ Open http://localhost:3000
105
105
  | `/api/ipl` | GET | Full dashboard payload used by the UI |
106
106
  | `/api/ipl?format=snapshot` | GET | Raw normalized snapshot only |
107
107
  | `/api/ipl` | POST | Ingest a leaderboard snapshot |
108
- | `/api/ipl/transfers` | GET | Transfer/booster snapshot for all teams |
108
+ | `/api/ipl/transfers` | GET | Transfer/booster snapshot for all teams (fetched once on mount, not on poll) |
109
109
  | `/api/ipl/transfers` | POST | Ingest a single team transfer record |
110
110
  | `/api/ipl/upcoming-matches` | GET | Upcoming match schedule |
111
111
  | `/api/ops/status` | GET | Health/monitoring endpoint |
@@ -1,5 +1,4 @@
1
1
  import { motion } from "framer-motion";
2
- import { FaUserShield } from "react-icons/fa6";
3
2
  import type { OverallChartItem } from "../../types";
4
3
  import { getTeamColor, formatLedgerNumber } from "../../utils/dashboard";
5
4
 
@@ -7,12 +6,81 @@ interface CaptainBoardProps {
7
6
  teams: OverallChartItem[];
8
7
  }
9
8
 
9
+ function PlayerPill({
10
+ player,
11
+ isCaptain,
12
+ isViceCaptain,
13
+ color,
14
+ index,
15
+ }: {
16
+ player: { name: string; points?: number };
17
+ isCaptain: boolean;
18
+ isViceCaptain: boolean;
19
+ color: string;
20
+ index: number;
21
+ }) {
22
+ return (
23
+ <span
24
+ className="inline-flex items-center gap-1.5 border px-2.5 py-1 text-[11px] font-bold leading-tight"
25
+ style={{
26
+ color: "var(--ink)",
27
+ border: "1.5px solid",
28
+ borderRadius: `999px ${920 + (index % 3) * 28}px ${980 - (index % 3) * 24}px 999px`,
29
+ borderColor: isCaptain
30
+ ? `color-mix(in srgb, #ec4899 50%, var(--line))`
31
+ : isViceCaptain
32
+ ? `color-mix(in srgb, #3b82f6 50%, var(--line))`
33
+ : `color-mix(in srgb, ${color} 35%, var(--line))`,
34
+ background: isCaptain
35
+ ? `color-mix(in srgb, #ec4899 18%, var(--panel-strong))`
36
+ : isViceCaptain
37
+ ? `color-mix(in srgb, #3b82f6 18%, var(--panel-strong))`
38
+ : `color-mix(in srgb, ${color} 14%, var(--panel-strong))`,
39
+ boxShadow: `1.5px 2px 0 color-mix(in srgb, ${color} 22%, var(--shadow))`,
40
+ transform: `rotate(${index % 2 === 0 ? -0.4 : 0.3}deg)`,
41
+ fontFamily: "var(--font-note), cursive",
42
+ }}
43
+ >
44
+ {typeof player.points === "number" && (
45
+ <span
46
+ className="font-mono text-[10px] font-black tabular-nums"
47
+ style={{ color: "var(--ink-soft)" }}
48
+ >
49
+ {formatLedgerNumber(player.points)}
50
+ </span>
51
+ )}
52
+ <span className="max-w-[80px] truncate">{player.name}</span>
53
+ {isCaptain && (
54
+ <span
55
+ className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full text-[8px] font-black leading-none"
56
+ style={{
57
+ background: `color-mix(in srgb, #ec4899 50%, var(--panel-strong))`,
58
+ color: "var(--ink)",
59
+ }}
60
+ >
61
+ C
62
+ </span>
63
+ )}
64
+ {isViceCaptain && !isCaptain && (
65
+ <span
66
+ className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full text-[8px] font-black leading-none"
67
+ style={{
68
+ background: `color-mix(in srgb, #3b82f6 50%, var(--panel-strong))`,
69
+ color: "var(--ink)",
70
+ }}
71
+ >
72
+ VC
73
+ </span>
74
+ )}
75
+ </span>
76
+ );
77
+ }
78
+
10
79
  export function CaptainBoard({ teams }: CaptainBoardProps) {
11
80
  if (!teams.length) {
12
81
  return (
13
82
  <p className="ledger-note py-6">
14
- Captain and vice-captain picks will appear when squad snapshots are
15
- available.
83
+ Squad snapshots will appear when match data is available.
16
84
  </p>
17
85
  );
18
86
  }
@@ -21,114 +89,65 @@ export function CaptainBoard({ teams }: CaptainBoardProps) {
21
89
  <div className="captain-grid">
22
90
  {teams.map((team, index) => {
23
91
  const color = getTeamColor(index, teams.length);
92
+ const players = (team.players ?? []).filter((p) => typeof p.points === "number");
93
+ const captainName = team.captain?.name;
94
+ const viceCaptainName = team.viceCaptain?.name;
24
95
  return (
25
96
  <motion.div
26
97
  key={team.name}
27
- initial={{ opacity: 0, y: 16 }}
98
+ initial={{ opacity: 0, y: 12 }}
28
99
  animate={{ opacity: 1, y: 0 }}
29
- transition={{ delay: index * 0.06 }}
30
- className="relative"
100
+ transition={{ delay: index * 0.05 }}
31
101
  >
32
102
  <article
33
- className={`captain-scrap captain-scrap-${index % 3} relative overflow-hidden`}
103
+ className="relative overflow-hidden rounded-xl p-3"
34
104
  style={{
35
- borderColor: `color-mix(in srgb, ${color} 44%, var(--line))`,
36
- background: `color-mix(in srgb, ${color} 8%, var(--panel) 92%)`,
37
- boxShadow: `0 0 18px color-mix(in srgb, ${color} 20%, transparent)`,
105
+ border: `1.5px solid color-mix(in srgb, ${color} 40%, var(--line))`,
106
+ background: `color-mix(in srgb, ${color} 7%, var(--panel) 93%)`,
38
107
  }}
39
108
  >
40
- <motion.div
41
- className="absolute inset-0 rounded-xl pointer-events-none"
42
- style={{
43
- background: `radial-gradient(circle at 50% 0%, color-mix(in srgb, ${color} 20%, transparent) 0%, transparent 70%)`,
44
- }}
45
- animate={{ opacity: [0.3, 0.7, 0.3] }}
46
- transition={{
47
- repeat: Infinity,
48
- duration: 2.5 + (index % 3),
49
- ease: "easeInOut",
50
- delay: index * 0.2,
51
- }}
52
- />
53
-
54
- <motion.div
55
- className="absolute -inset-0.5 rounded-xl pointer-events-none"
56
- style={{
57
- border: `2px solid ${color}`,
58
- opacity: 0.5,
59
- }}
60
- animate={{
61
- opacity: [0.2, 0.6, 0.2],
62
- filter: ["blur(3px)", "blur(7px)", "blur(3px)"],
63
- }}
64
- transition={{
65
- repeat: Infinity,
66
- duration: 2,
67
- ease: "easeInOut",
68
- delay: index * 0.15,
69
- }}
70
- />
109
+ <div className="flex items-center justify-between mb-2.5">
110
+ <p
111
+ className="text-[13px] font-black leading-none"
112
+ style={{ color: "var(--ink)" }}
113
+ >
114
+ #{team.rank} {team.name}
115
+ </p>
116
+ <span
117
+ className="text-[10px] font-black tabular-nums"
118
+ style={{ color: "var(--ink-soft)" }}
119
+ >
120
+ {players.length} plyrs
121
+ </span>
122
+ </div>
71
123
 
72
- <div className="relative z-10">
73
- <div className="captain-top">
74
- <div>
75
- <p
76
- className="captain-team compact"
77
- style={{ color: "var(--ink)" }}
78
- >
79
- #{team.rank} {team.name}
80
- </p>
81
- </div>
82
- <div className="captain-icon">
83
- <FaUserShield className="text-base" style={{ color }} />
84
- </div>
124
+ {players.length > 0 ? (
125
+ <div className="flex flex-wrap gap-1.5">
126
+ {players.map((player, pi) => (
127
+ <PlayerPill
128
+ key={player.name}
129
+ player={player}
130
+ isCaptain={player.name === captainName}
131
+ isViceCaptain={player.name === viceCaptainName}
132
+ color={color}
133
+ index={pi}
134
+ />
135
+ ))}
85
136
  </div>
86
-
87
- <div className="captain-players compact-grid">
88
- <div className="captain-role">
89
- <span
90
- className="captain-badge captain-badge-pink"
91
- style={{
92
- boxShadow: `0 0 8px color-mix(in srgb, #ec4899 30%, transparent)`,
93
- }}
94
- >
95
- C
96
- </span>
97
- <div>
98
- <p className="captain-player">
99
- {team.captain?.name ?? "TBD"}
100
- </p>
101
- {typeof team.captain?.points === "number" && (
102
- <p className="captain-player-sub">
103
- {formatLedgerNumber(team.captain.points)}
104
- <span className="captain-player-sub-pts"> pts</span>
105
- </p>
106
- )}
107
- </div>
108
- </div>
109
- <div className="captain-role">
110
- <span
111
- className="captain-badge captain-badge-blue"
112
- style={{
113
- boxShadow: `0 0 8px color-mix(in srgb, #3b82f6 30%, transparent)`,
114
- }}
115
- >
116
- VC
117
- </span>
118
- <div>
119
- <p className="captain-player">
120
- {team.viceCaptain?.name ?? "TBD"}
121
- </p>
122
- {typeof team.viceCaptain?.points === "number" && (
123
- <p className="captain-player-sub">
124
- {formatLedgerNumber(team.viceCaptain.points)}
125
- <span className="captain-player-sub-pts"> pts</span>
126
- </p>
127
- )}
128
- </div>
129
- </div>
137
+ ) : (
138
+ <div className="flex flex-wrap gap-1.5">
139
+ <span
140
+ className="inline-flex items-center gap-1.5 border px-2.5 py-1 text-[11px] font-bold"
141
+ style={{
142
+ color: "var(--ink-soft)",
143
+ border: "1.5px dashed var(--line)",
144
+ borderRadius: 9999,
145
+ }}
146
+ >
147
+ No players with points yet
148
+ </span>
130
149
  </div>
131
- </div>
150
+ )}
132
151
  </article>
133
152
  </motion.div>
134
153
  );
@@ -564,17 +564,13 @@ select {
564
564
  }
565
565
 
566
566
  .leader-list,
567
- .insight-grid,
568
- .captain-grid {
567
+ .insight-grid {
569
568
  display: grid;
570
569
  gap: 0.8rem;
571
570
  }
572
571
  .leader-list {
573
572
  grid-template-columns: repeat(2, minmax(0, 1fr));
574
573
  }
575
- .captain-grid {
576
- grid-template-columns: repeat(2, minmax(0, 1fr));
577
- }
578
574
  .hover-jitter {
579
575
  transition:
580
576
  transform 180ms ease,
@@ -883,15 +879,9 @@ select {
883
879
  }
884
880
 
885
881
  .captain-grid {
882
+ display: grid;
886
883
  grid-template-columns: repeat(2, minmax(0, 1fr));
887
- }
888
-
889
- .captain-scrap {
890
- border: 1px solid var(--line);
891
- padding: 0.85rem;
892
- min-height: 6.2rem;
893
- border-radius: 18px;
894
- background: color-mix(in srgb, var(--panel) 96%, white 4%);
884
+ gap: 0.75rem;
895
885
  }
896
886
 
897
887
  @media (min-width: 1280px) {
@@ -900,97 +890,6 @@ select {
900
890
  }
901
891
  }
902
892
 
903
- .captain-scrap-0,
904
- .captain-scrap-1,
905
- .captain-scrap-2 {
906
- transform: none;
907
- }
908
-
909
- .captain-top {
910
- display: flex;
911
- justify-content: space-between;
912
- align-items: flex-start;
913
- gap: 0.75rem;
914
- }
915
-
916
- .captain-team.compact {
917
- margin: 0;
918
- font-size: 0.95rem;
919
- font-weight: 700;
920
- }
921
-
922
- .captain-icon {
923
- display: inline-flex;
924
- align-items: center;
925
- justify-content: center;
926
- width: 2.4rem;
927
- height: 2.4rem;
928
- border-radius: 999px;
929
- border: 1px solid var(--line);
930
- background: color-mix(in srgb, white 92%, var(--panel) 8%);
931
- }
932
-
933
- .captain-players.compact-grid {
934
- display: grid;
935
- grid-template-columns: 1fr 1fr;
936
- gap: 0.6rem;
937
- margin-top: 0.85rem;
938
- }
939
-
940
- .captain-role {
941
- display: flex;
942
- align-items: center;
943
- gap: 0.5rem;
944
- }
945
-
946
- .captain-badge {
947
- display: inline-flex;
948
- align-items: center;
949
- justify-content: center;
950
- width: 2rem;
951
- height: 2rem;
952
- border-radius: 999px;
953
- font-family: var(--font-note), cursive;
954
- font-size: 0.8rem;
955
- font-weight: 800;
956
- flex-shrink: 0;
957
- }
958
-
959
- .captain-badge-pink {
960
- background: color-mix(in srgb, var(--marker-pink) 44%, var(--panel-strong));
961
- border: 1.5px solid color-mix(in srgb, var(--marker-pink) 60%, var(--line));
962
- color: var(--ink);
963
- }
964
-
965
- .captain-badge-blue {
966
- background: color-mix(in srgb, var(--marker-blue) 44%, var(--panel-strong));
967
- border: 1.5px solid color-mix(in srgb, var(--marker-blue) 60%, var(--line));
968
- color: var(--ink);
969
- }
970
-
971
- .captain-player {
972
- margin: 0;
973
- font-size: 0.88rem;
974
- font-weight: 700;
975
- line-height: 1.25;
976
- color: var(--ink);
977
- }
978
-
979
- .captain-player-sub {
980
- margin: 0;
981
- font-size: 0.95rem;
982
- font-weight: 800;
983
- color: var(--ink);
984
- letter-spacing: 0.02em;
985
- }
986
-
987
- .captain-player-sub-pts {
988
- font-size: 0.82rem;
989
- font-weight: 600;
990
- color: var(--ink-soft);
991
- letter-spacing: 0.06em;
992
- }
993
-
994
893
  .ledger-shell {
995
894
  overflow-x: auto;
996
895
  border: 1.5px dashed var(--line);
@@ -9,6 +9,7 @@ import {
9
9
  export function useDashboardData() {
10
10
  const hydrateFromCache = useDashboardStore((state) => state.hydrateFromCache);
11
11
  const fetchDashboard = useDashboardStore((state) => state.fetchDashboard);
12
+ const fetchTransfers = useDashboardStore((state) => state.fetchTransfers);
12
13
  const data = useDashboardStore((state) => state.data);
13
14
  const loading = useDashboardStore((state) => state.loading);
14
15
  const error = useDashboardStore((state) => state.error);
@@ -16,13 +17,14 @@ export function useDashboardData() {
16
17
  useEffect(() => {
17
18
  hydrateFromCache();
18
19
  void fetchDashboard();
20
+ void fetchTransfers();
19
21
 
20
22
  const interval = window.setInterval(() => {
21
23
  void fetchDashboard();
22
24
  }, DASHBOARD_SYNC_INTERVAL_MS);
23
25
 
24
26
  return () => window.clearInterval(interval);
25
- }, [fetchDashboard, hydrateFromCache]);
27
+ }, [fetchDashboard, fetchTransfers, hydrateFromCache]);
26
28
 
27
29
  return { data, loading, error };
28
30
  }
@@ -13,8 +13,10 @@ type State = {
13
13
  loading: boolean;
14
14
  error: string | null;
15
15
  lastSyncedAt: string | null;
16
+ hasFetchedTransfers: boolean;
16
17
  hydrateFromCache: () => void;
17
18
  fetchDashboard: () => Promise<void>;
19
+ fetchTransfers: () => Promise<void>;
18
20
  };
19
21
 
20
22
  export const useDashboardStore = create<State>((set) => ({
@@ -22,6 +24,7 @@ export const useDashboardStore = create<State>((set) => ({
22
24
  loading: true,
23
25
  error: null,
24
26
  lastSyncedAt: null,
27
+ hasFetchedTransfers: false,
25
28
 
26
29
  hydrateFromCache: () => {
27
30
  const cached = readDashboardCache();
@@ -46,24 +49,21 @@ export const useDashboardStore = create<State>((set) => ({
46
49
  }
47
50
 
48
51
  try {
49
- const [dashboardResponse, transfersResponse] = await Promise.all([
50
- fetch(`/api/ipl?t=${Date.now()}`, { cache: "no-store" }),
51
- fetch(`/api/ipl/transfers?t=${Date.now()}`, { cache: "no-store" }),
52
- ]);
52
+ const response = await fetch(`/api/ipl?t=${Date.now()}`, {
53
+ cache: "no-store",
54
+ });
53
55
 
54
- if (!dashboardResponse.ok) {
55
- throw new Error(`Dashboard fetch failed (${dashboardResponse.status})`);
56
+ if (!response.ok) {
57
+ throw new Error(`Dashboard fetch failed (${response.status})`);
56
58
  }
57
59
 
58
- const json = (await dashboardResponse.json()) as DashboardData;
59
- const transfers = transfersResponse.ok
60
- ? ((await transfersResponse.json()) as DashboardData["transfers"])
61
- : undefined;
60
+ const json = (await response.json()) as DashboardData;
61
+ const currentData = useDashboardStore.getState().data;
62
62
 
63
63
  set({
64
64
  data: {
65
65
  ...json,
66
- transfers,
66
+ transfers: currentData?.transfers,
67
67
  },
68
68
  loading: false,
69
69
  error: null,
@@ -71,7 +71,7 @@ export const useDashboardStore = create<State>((set) => ({
71
71
  });
72
72
  writeDashboardCache({
73
73
  ...json,
74
- transfers,
74
+ transfers: currentData?.transfers,
75
75
  });
76
76
  } catch (err) {
77
77
  const message = err instanceof Error ? err.message : "Fetch failed";
@@ -82,4 +82,33 @@ export const useDashboardStore = create<State>((set) => ({
82
82
  });
83
83
  }
84
84
  },
85
+
86
+ fetchTransfers: async () => {
87
+ const { hasFetchedTransfers } = useDashboardStore.getState();
88
+ if (hasFetchedTransfers) return;
89
+
90
+ try {
91
+ const response = await fetch(`/api/ipl/transfers?t=${Date.now()}`, {
92
+ cache: "no-store",
93
+ });
94
+
95
+ if (!response.ok) return;
96
+
97
+ const transfers =
98
+ (await response.json()) as DashboardData["transfers"];
99
+ const currentData = useDashboardStore.getState().data;
100
+
101
+ if (currentData) {
102
+ set({
103
+ data: { ...currentData, transfers },
104
+ hasFetchedTransfers: true,
105
+ });
106
+ writeDashboardCache({ ...currentData, transfers });
107
+ } else {
108
+ set({ hasFetchedTransfers: true });
109
+ }
110
+ } catch {
111
+ set({ hasFetchedTransfers: true });
112
+ }
113
+ },
85
114
  }));
@@ -0,0 +1,91 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const ROOT = join(__dirname, "..");
7
+
8
+ try {
9
+ process.loadEnvFile(join(ROOT, ".env"));
10
+ } catch {
11
+ // env vars may already be set
12
+ }
13
+
14
+ function sanitizeCollectionName(name) {
15
+ return name
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9_]/g, "_")
18
+ .replace(/_+/g, "_")
19
+ .replace(/^_|_$/g, "")
20
+ .slice(0, 100) || "league";
21
+ }
22
+
23
+ async function main() {
24
+ const leagueName = process.argv[2] || process.env.IPL_LEAGUE_NAME?.trim();
25
+ if (!leagueName) {
26
+ console.log("No league name provided. Pass as argument or set IPL_LEAGUE_NAME.");
27
+ process.exit(1);
28
+ }
29
+
30
+ const collectionName = process.argv[3] || process.env.COLLECTION_NAME?.trim() || sanitizeCollectionName(leagueName);
31
+
32
+ const uri = process.env.MONGODB_URI;
33
+ if (!uri) {
34
+ console.log("MONGODB_URI not set. Skipping league seed.");
35
+ return;
36
+ }
37
+
38
+ const leagueUrl = process.env.IPL_LEAGUE_URL?.trim();
39
+
40
+ let teams = [];
41
+ const leagueDataPath = join(ROOT, "app/data/league.ts");
42
+ if (existsSync(leagueDataPath)) {
43
+ try {
44
+ const content = readFileSync(leagueDataPath, "utf8");
45
+ const match = content.match(/teams:\s*\[([\s\S]*?)\],/);
46
+ if (match) {
47
+ const entries = [...match[1].matchAll(/\{\s*id:\s*(\d+),\s*name:\s*"([^"]+)",\s*owner:\s*"([^"]+)"\s*\}/g)];
48
+ teams = entries.map(([, id, name, owner]) => ({
49
+ id: Number(id),
50
+ name,
51
+ owner,
52
+ }));
53
+ }
54
+ } catch {
55
+ // fallback
56
+ }
57
+ }
58
+
59
+ const { MongoClient } = await import("mongodb");
60
+ const client = new MongoClient(uri);
61
+
62
+ try {
63
+ await client.connect();
64
+ const db = client.db();
65
+ const collection = db.collection(collectionName);
66
+
67
+ const doc = {
68
+ type: "league",
69
+ name: leagueName,
70
+ leagueUrl: leagueUrl || "",
71
+ teams,
72
+ createdAt: new Date().toISOString(),
73
+ updatedAt: new Date().toISOString(),
74
+ };
75
+
76
+ await collection.updateOne(
77
+ { type: "league" },
78
+ { $set: doc },
79
+ { upsert: true },
80
+ );
81
+
82
+ console.log(`League "${leagueName}" seeded in collection "${collectionName}" with ${teams.length} team(s).`);
83
+ } finally {
84
+ await client.close();
85
+ }
86
+ }
87
+
88
+ main().catch((err) => {
89
+ console.error("Failed to seed league:", err.message);
90
+ process.exitCode = 1;
91
+ });