@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 +40 -6
- package/package.json +1 -1
- package/src/index.mjs +6 -3
- package/src/prompts.mjs +13 -5
- package/src/scaffold.mjs +75 -10
- package/template/README.md +2 -2
- package/template/app/components/dashboard/CaptainBoard.tsx +118 -99
- package/template/app/globals.css +3 -104
- package/template/app/hooks/useDashboardData.ts +3 -1
- package/template/app/store/dashboardStore.ts +41 -12
- package/template/scripts/seed-league.mjs +91 -0
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
|
|
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
|
-
|
|
|
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** —
|
|
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
|
|
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.
|
|
99
|
-
5.
|
|
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
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,
|
|
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
|
|
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("
|
|
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
|
|
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("
|
|
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(
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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, {
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|
package/template/README.md
CHANGED
|
@@ -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` |
|
|
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
|
-
|
|
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:
|
|
98
|
+
initial={{ opacity: 0, y: 12 }}
|
|
28
99
|
animate={{ opacity: 1, y: 0 }}
|
|
29
|
-
transition={{ delay: index * 0.
|
|
30
|
-
className="relative"
|
|
100
|
+
transition={{ delay: index * 0.05 }}
|
|
31
101
|
>
|
|
32
102
|
<article
|
|
33
|
-
className=
|
|
103
|
+
className="relative overflow-hidden rounded-xl p-3"
|
|
34
104
|
style={{
|
|
35
|
-
|
|
36
|
-
background: `color-mix(in srgb, ${color}
|
|
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
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
73
|
-
<div className="
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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="
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
150
|
+
)}
|
|
132
151
|
</article>
|
|
133
152
|
</motion.div>
|
|
134
153
|
);
|
package/template/app/globals.css
CHANGED
|
@@ -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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
]);
|
|
52
|
+
const response = await fetch(`/api/ipl?t=${Date.now()}`, {
|
|
53
|
+
cache: "no-store",
|
|
54
|
+
});
|
|
53
55
|
|
|
54
|
-
if (!
|
|
55
|
-
throw new Error(`Dashboard fetch failed (${
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error(`Dashboard fetch failed (${response.status})`);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
const json = (await
|
|
59
|
-
const
|
|
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
|
+
});
|