@vatvaghool/create-ipl-dashboard 0.1.6 → 0.1.12

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
@@ -26,8 +26,10 @@ 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
+ | MongoDB URI | Your main app database connection string (or leave blank for file-based fallback) |
30
30
  | League URL | The fantasy.iplt20.com league page URL |
31
+ | League DB URI | Separate database for league registry (optional — creates a collection per league) |
32
+ | League name | Display name for your league (used as the collection name in the league DB) |
31
33
  | Teams | Team names (and optional owners) in your league |
32
34
 
33
35
  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.
@@ -93,9 +95,11 @@ Open http://localhost:3000 to see your dashboard.
93
95
  The CLI:
94
96
 
95
97
  1. Copies a pre-built Next.js app template
96
- 2. Writes your `.env` with MongoDB URI and league URL
98
+ 2. Writes your `.env` with MongoDB URI, league DB URI, league URL, and league name
97
99
  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
100
+ 4. Generates `app/data/league.ts` with league metadata
101
+ 5. Creates a placeholder `app/data/match-points.ts` (auto-populates as you sync)
102
+ 6. Installs dependencies
103
+ 7. If a league DB URI was provided, runs `seed:league` to create a **collection named after your league** in that database, storing league metadata (name, URL, teams, timestamps)
100
104
 
101
105
  The template includes all dashboard components, API endpoints, scrapers, and tests from the [ipl-dashboard](https://github.com/anomalyco/ipl-dashboard) project.
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.12",
4
4
  "description": "Scaffold an IPL fantasy cricket dashboard project",
5
5
  "type": "module",
6
6
  "bin": {
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, getMongoUri, getLeagueUrl, getTeams, close } from "./prompts.mjs";
6
+ import { getProjectName, getMongoUri, getLeagueUrl, getLeagueDbUri, getLeagueName, getTeams, close } from "./prompts.mjs";
7
7
  import { scaffoldProject } from "./scaffold.mjs";
8
8
  import { scrapeTeamsFromUrl } from "./scraper.mjs";
9
9
 
@@ -61,6 +61,8 @@ async function main() {
61
61
 
62
62
  const mongoUri = await getMongoUri();
63
63
  const leagueUrl = await getLeagueUrl();
64
+ const leagueDbUri = await getLeagueDbUri();
65
+ const leagueName = await getLeagueName();
64
66
  let teams;
65
67
 
66
68
  if (flags.scrape && leagueUrl) {
@@ -78,7 +80,7 @@ async function main() {
78
80
 
79
81
  close();
80
82
 
81
- await scaffoldProject(projectPath, { mongoUri, leagueUrl, teams, skipInstall: flags.skipInstall });
83
+ await scaffoldProject(projectPath, { mongoUri, leagueUrl, leagueDbUri, leagueName, teams, skipInstall: flags.skipInstall });
82
84
 
83
85
  console.log("");
84
86
  console.log(" Next steps:");
package/src/prompts.mjs CHANGED
@@ -49,6 +49,22 @@ export async function getLeagueUrl() {
49
49
  return (await prompt("IPL fantasy league URL (or press Enter to skip, can be set later): ")).trim();
50
50
  }
51
51
 
52
+ export async function getLeagueDbUri() {
53
+ if (!isTTY) {
54
+ await consumeAllLines();
55
+ return (pipedLines[promptIndex++] || "").trim();
56
+ }
57
+ return (await prompt("League DB URI (separate database for league registry, press Enter to skip): ")).trim();
58
+ }
59
+
60
+ export async function getLeagueName() {
61
+ if (!isTTY) {
62
+ await consumeAllLines();
63
+ return (pipedLines[promptIndex++] || "My IPL League").trim();
64
+ }
65
+ return (await prompt("League name (e.g. Office Fantasy League): ")).trim() || "My IPL League";
66
+ }
67
+
52
68
  export async function getTeams() {
53
69
  if (!isTTY) {
54
70
  await consumeAllLines();
package/src/scaffold.mjs CHANGED
@@ -8,7 +8,7 @@ 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(projectPath, { mongoUri, leagueUrl, leagueDbUri, leagueName, teams, skipInstall = false }) {
12
12
  console.log(`\nCreating project at ${projectPath}...`);
13
13
 
14
14
  await mkdir(projectPath, { recursive: true });
@@ -19,17 +19,16 @@ export async function scaffoldProject(projectPath, { mongoUri, leagueUrl, teams,
19
19
  console.log(" (no template directory found, generating from scratch)");
20
20
  }
21
21
 
22
- // Remove monorepo-specific files not needed in scaffolded project
23
22
  for (const f of ["AGENTS.md", "tsconfig.tsbuildinfo", "tsconfig.buildinfo", "package-lock.json"]) {
24
23
  try { await rm(join(projectPath, f)); } catch {}
25
24
  }
26
25
 
27
- await writeEnvFile(projectPath, { mongoUri, leagueUrl });
26
+ await writeEnvFile(projectPath, { mongoUri, leagueUrl, leagueDbUri, leagueName });
28
27
  await writeTeamData(projectPath, teams);
28
+ await writeLeagueData(projectPath, { leagueName, leagueUrl, teams });
29
29
  await writeMatchPointsPlaceholder(projectPath);
30
30
  await updatePackageJson(projectPath);
31
31
 
32
- // Remove monorepo's live snapshots (they're specific to original league)
33
32
  for (const f of ["app/api/ipl/live-snapshot.json", "app/api/ipl/transfers/live-snapshot.json"]) {
34
33
  try { await rm(join(projectPath, f)); } catch {}
35
34
  }
@@ -37,6 +36,15 @@ export async function scaffoldProject(projectPath, { mongoUri, leagueUrl, teams,
37
36
  if (!skipInstall) {
38
37
  console.log(" Running npm install...");
39
38
  execSync("npm install", { cwd: projectPath, stdio: "inherit" });
39
+
40
+ if (leagueDbUri && leagueName) {
41
+ console.log(" Seeding league metadata into MongoDB...");
42
+ try {
43
+ execSync(`npm run seed:league -- "${leagueName}"`, { cwd: projectPath, stdio: "inherit" });
44
+ } catch {
45
+ console.log(" League seed skipped (MongoDB may not be reachable yet)");
46
+ }
47
+ }
40
48
  } else {
41
49
  console.log(" Skipping npm install (run manually: cd <project> && npm install)");
42
50
  }
@@ -44,11 +52,13 @@ export async function scaffoldProject(projectPath, { mongoUri, leagueUrl, teams,
44
52
  console.log("\n Done! Your project is ready at:", projectPath);
45
53
  }
46
54
 
47
- async function writeEnvFile(projectPath, { mongoUri, leagueUrl }) {
55
+ async function writeEnvFile(projectPath, { mongoUri, leagueUrl, leagueDbUri, leagueName }) {
48
56
  const envPath = join(projectPath, ".env");
49
57
  const lines = [
50
58
  `MONGODB_URI=${mongoUri || ""}`,
59
+ `LEAGUE_DB_URI=${leagueDbUri || ""}`,
51
60
  `IPL_LEAGUE_URL=${leagueUrl || ""}`,
61
+ `IPL_LEAGUE_NAME=${leagueName || ""}`,
52
62
  `IPL_POST_SECRET=`,
53
63
  ``,
54
64
  `IPL_API_BASE_URL=http://localhost:3000`,
@@ -100,6 +110,32 @@ for (const team of TEAMS) {
100
110
  await writeFile(join(dir, "teams.ts"), content);
101
111
  }
102
112
 
113
+ async function writeLeagueData(projectPath, { leagueName, leagueUrl, teams }) {
114
+ const dir = join(projectPath, "app/data");
115
+ await mkdir(dir, { recursive: true });
116
+
117
+ const teamEntries = teams
118
+ .map((t, i) => ` { id: ${t.id}, name: "${t.name}", owner: "${t.owner}" }`)
119
+ .join(",\n");
120
+
121
+ const content = `export type LeagueInfo = {
122
+ name: string;
123
+ leagueUrl: string;
124
+ teams: Array<{ id: number; name: string; owner: string }>;
125
+ };
126
+
127
+ export const leagueInfo: LeagueInfo = {
128
+ name: "${leagueName || ""}",
129
+ leagueUrl: "${leagueUrl || ""}",
130
+ teams: [
131
+ ${teamEntries},
132
+ ],
133
+ };
134
+ `;
135
+
136
+ await writeFile(join(dir, "league.ts"), content);
137
+ }
138
+
103
139
  async function writeMatchPointsPlaceholder(projectPath) {
104
140
  const dir = join(projectPath, "app/data");
105
141
  await mkdir(dir, { recursive: true });
@@ -124,6 +160,7 @@ async function updatePackageJson(projectPath) {
124
160
  pkg.name = "ipl-dashboard";
125
161
  pkg.private = false;
126
162
  delete pkg.scripts?.["generate:template"];
163
+ pkg.scripts["seed:league"] = "node scripts/seed-league.mjs";
127
164
  await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
128
165
  } catch {}
129
166
  }
@@ -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 uri = process.env.LEAGUE_DB_URI;
31
+ if (!uri) {
32
+ console.log("LEAGUE_DB_URI not set. Skipping league seed.");
33
+ return;
34
+ }
35
+
36
+ const leagueUrl = process.env.IPL_LEAGUE_URL?.trim();
37
+
38
+ let teams = [];
39
+ const leagueDataPath = join(ROOT, "app/data/league.ts");
40
+ if (existsSync(leagueDataPath)) {
41
+ try {
42
+ const content = readFileSync(leagueDataPath, "utf8");
43
+ const match = content.match(/teams:\s*\[([\s\S]*?)\],/);
44
+ if (match) {
45
+ const entries = [...match[1].matchAll(/\{\s*id:\s*(\d+),\s*name:\s*"([^"]+)",\s*owner:\s*"([^"]+)"\s*\}/g)];
46
+ teams = entries.map(([, id, name, owner]) => ({
47
+ id: Number(id),
48
+ name,
49
+ owner,
50
+ }));
51
+ }
52
+ } catch {
53
+ // fallback
54
+ }
55
+ }
56
+
57
+ const collectionName = sanitizeCollectionName(leagueName);
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
+ });