@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 +8 -4
- package/package.json +1 -1
- package/src/index.mjs +4 -2
- package/src/prompts.mjs +16 -0
- package/src/scaffold.mjs +42 -5
- 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
|
@@ -26,8 +26,10 @@ npx @vatvaghool/create-ipl-dashboard [project-name] [options]
|
|
|
26
26
|
|
|
27
27
|
| Prompt | Description |
|
|
28
28
|
|--------|-------------|
|
|
29
|
-
| MongoDB URI | Your
|
|
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
|
|
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.
|
|
99
|
-
5.
|
|
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
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
|
}
|
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 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
|
+
});
|