@vatvaghool/create-ipl-dashboard 0.1.0

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.
Files changed (108) hide show
  1. package/README.md +75 -0
  2. package/package.json +27 -0
  3. package/src/generate-template.mjs +73 -0
  4. package/src/index.mjs +98 -0
  5. package/src/prompts.mjs +78 -0
  6. package/src/scaffold.mjs +129 -0
  7. package/src/scraper.mjs +79 -0
  8. package/template/.dockerignore +13 -0
  9. package/template/AGENTS.md +5 -0
  10. package/template/Dockerfile.sync +14 -0
  11. package/template/README.md +160 -0
  12. package/template/app/api/ipl/data.ts +24 -0
  13. package/template/app/api/ipl/route.ts +505 -0
  14. package/template/app/api/ipl/transfers/route.ts +261 -0
  15. package/template/app/api/ipl/transfers/transform.ts +156 -0
  16. package/template/app/api/ipl/transform.ts +20 -0
  17. package/template/app/api/ipl/upcoming-matches/route.ts +18 -0
  18. package/template/app/api/ops/status/route.ts +225 -0
  19. package/template/app/components/AIRoasting.tsx +278 -0
  20. package/template/app/components/ColorWave.tsx +193 -0
  21. package/template/app/components/CrownBattle.tsx +207 -0
  22. package/template/app/components/DashboardContent.tsx +377 -0
  23. package/template/app/components/FantasyStockTicker.tsx +192 -0
  24. package/template/app/components/FireworksBurst.tsx +225 -0
  25. package/template/app/components/LiveMatchTicker.tsx +117 -0
  26. package/template/app/components/MatchRecapScroll.tsx +135 -0
  27. package/template/app/components/MatchStoryScrubber.tsx +274 -0
  28. package/template/app/components/PerformanceTracker.tsx +132 -0
  29. package/template/app/components/ProgressGlowRings.tsx +157 -0
  30. package/template/app/components/TeamDNAScanner.tsx +238 -0
  31. package/template/app/components/ThemeToggle.tsx +74 -0
  32. package/template/app/components/dashboard/CaptainBoard.tsx +138 -0
  33. package/template/app/components/dashboard/ChartBoard.tsx +162 -0
  34. package/template/app/components/dashboard/LatestBadge.tsx +23 -0
  35. package/template/app/components/dashboard/LedgerTable.tsx +385 -0
  36. package/template/app/components/dashboard/SectionCard.tsx +59 -0
  37. package/template/app/components/dashboard/StickyMini.tsx +20 -0
  38. package/template/app/components/dashboard/index.ts +6 -0
  39. package/template/app/components/ui/DashboardChartFrame.tsx +74 -0
  40. package/template/app/components/ui/DoodleSpinner.tsx +15 -0
  41. package/template/app/components/ui/TeamPills.tsx +41 -0
  42. package/template/app/data/match-points.ts +3 -0
  43. package/template/app/data/teams.ts +32 -0
  44. package/template/app/globals.css +1267 -0
  45. package/template/app/hooks/dashboard/index.ts +1 -0
  46. package/template/app/hooks/dashboard/useDashboardModel.ts +25 -0
  47. package/template/app/hooks/dashboardCache.ts +53 -0
  48. package/template/app/hooks/dashboardPolling.ts +53 -0
  49. package/template/app/hooks/snapshotCache.ts +47 -0
  50. package/template/app/hooks/useDashboardData.ts +28 -0
  51. package/template/app/layout.tsx +75 -0
  52. package/template/app/lib/aiAgent.ts +444 -0
  53. package/template/app/lib/config.ts +29 -0
  54. package/template/app/lib/dashboard/index.ts +1 -0
  55. package/template/app/lib/dashboard/model.ts +257 -0
  56. package/template/app/lib/dashboardData.ts +50 -0
  57. package/template/app/lib/dashboardView.ts +22 -0
  58. package/template/app/lib/detailedData.ts +112 -0
  59. package/template/app/lib/matchStatus.ts +28 -0
  60. package/template/app/lib/matches.ts +131 -0
  61. package/template/app/lib/teamBadges.ts +223 -0
  62. package/template/app/lib/upcomingMatches.ts +154 -0
  63. package/template/app/lib/useDb.ts +29 -0
  64. package/template/app/lib/utils/diff.ts +24 -0
  65. package/template/app/lib/utils/getChartColor.ts +17 -0
  66. package/template/app/lib/utils/getStdDeviation.ts +6 -0
  67. package/template/app/lib/utils/time.ts +40 -0
  68. package/template/app/lib/utils.ts +70 -0
  69. package/template/app/page.tsx +15 -0
  70. package/template/app/store/dashboardStore.ts +85 -0
  71. package/template/app/types/dashboard.ts +75 -0
  72. package/template/app/types.ts +130 -0
  73. package/template/app/utils/dashboard/index.ts +72 -0
  74. package/template/eslint.config.mjs +18 -0
  75. package/template/infra/cloud-run/README.md +68 -0
  76. package/template/infra/cloud-run/sync-job.yaml +32 -0
  77. package/template/infra/cutover/README.md +84 -0
  78. package/template/infra/vercel/README.md +57 -0
  79. package/template/next.config.ts +7 -0
  80. package/template/package-lock.json +7330 -0
  81. package/template/package.json +47 -0
  82. package/template/packages/ipl-dashboard-utils/README.md +316 -0
  83. package/template/packages/ipl-dashboard-utils/package.json +34 -0
  84. package/template/packages/ipl-dashboard-utils/src/index.ts +22 -0
  85. package/template/packages/ipl-dashboard-utils/src/transform.ts +687 -0
  86. package/template/packages/ipl-dashboard-utils/src/types.ts +88 -0
  87. package/template/packages/ipl-dashboard-utils/tsconfig.build.json +17 -0
  88. package/template/postcss.config.mjs +7 -0
  89. package/template/scripts/capture-ipl-auth.mjs +54 -0
  90. package/template/scripts/deploy-cloud-run-sync.sh +48 -0
  91. package/template/scripts/deploy-cloud-scheduler.sh +42 -0
  92. package/template/scripts/dev-simple.js +31 -0
  93. package/template/scripts/dev-welcome.mjs +38 -0
  94. package/template/scripts/monitor-ops-status.sh +50 -0
  95. package/template/scripts/seed-mongodb.ts +115 -0
  96. package/template/scripts/sync-cloud.mjs +50 -0
  97. package/template/scripts/sync-ipl.mjs +238 -0
  98. package/template/scripts/sync-transfers-daily.mjs +175 -0
  99. package/template/scripts/verify-production.mjs +108 -0
  100. package/template/tests/coverage-gaps.test.ts +290 -0
  101. package/template/tests/dashboard-polling.test.ts +96 -0
  102. package/template/tests/detailed-data.test.ts +60 -0
  103. package/template/tests/ipl-transform.test.ts +590 -0
  104. package/template/tests/transfers-route.test.ts +109 -0
  105. package/template/tests/upcoming-matches.test.ts +34 -0
  106. package/template/tests/utils-and-cache.test.ts +267 -0
  107. package/template/tsconfig.json +35 -0
  108. package/template/vercel.json +7 -0
@@ -0,0 +1,261 @@
1
+ import { NextResponse } from "next/server";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import getMongoDb from "../../../lib/useDb";
4
+ import { config } from "../../../lib/config.ts";
5
+ import type {
6
+ ScrapedTransferItem,
7
+ ScrapedTransferSnapshot,
8
+ } from "../../../types";
9
+ import {
10
+ mergeTransferCollections,
11
+ mergeTransferItem,
12
+ normalizeTransferItem,
13
+ normalizeTransferSnapshot,
14
+ normalizeTransferPayload,
15
+ summarizeTransferSnapshot,
16
+ } from "./transform";
17
+
18
+ export const dynamic = "force-dynamic";
19
+ export const revalidate = 0;
20
+ export const runtime = "nodejs";
21
+
22
+ const DASHBOARD_COLLECTION = config.mongodb.collectionName;
23
+ const TRANSFER_DOCUMENT_TYPE = config.docTypes.transferStats;
24
+ const isProduction = process.env.NODE_ENV === "production";
25
+ const allowLocalSnapshotFallback = !isProduction;
26
+ const SNAPSHOT_FILE_PATH = config.paths.transferSnapshotFile;
27
+
28
+ const corsHeaders = {
29
+ "Access-Control-Allow-Origin": "*",
30
+ "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
31
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
32
+ "Cache-Control": "no-store, no-cache, must-revalidate",
33
+ Pragma: "no-cache",
34
+ Expires: "0",
35
+ };
36
+
37
+ const LOG_TAG = "[ipl-transfers-api]";
38
+ const shouldLog =
39
+ process.env.IPL_API_LOG === "1" || process.env.NODE_ENV !== "production";
40
+
41
+ const log = (message: string, details?: unknown) => {
42
+ if (!shouldLog) return;
43
+
44
+ if (details === undefined) {
45
+ console.log(LOG_TAG, message);
46
+ return;
47
+ }
48
+
49
+ console.log(LOG_TAG, message, details);
50
+ };
51
+
52
+ const getDb = async () => {
53
+ try {
54
+ return await getMongoDb();
55
+ } catch (error) {
56
+ console.error("MongoDB connection failed:", error);
57
+ return null;
58
+ }
59
+ };
60
+
61
+ const readMongoTransfers = async () => {
62
+ const db = await getDb();
63
+
64
+ if (!db) {
65
+ return null;
66
+ }
67
+
68
+ try {
69
+ const documents = await db
70
+ .collection(DASHBOARD_COLLECTION)
71
+ .find({ type: TRANSFER_DOCUMENT_TYPE })
72
+ .toArray();
73
+
74
+ const teams = documents
75
+ .map(normalizeTransferItem)
76
+ .filter(Boolean) as ScrapedTransferItem[];
77
+
78
+ return teams.length > 0 ? teams : null;
79
+ } catch (error) {
80
+ console.error("Failed to read IPL transfer stats from MongoDB:", error);
81
+ return null;
82
+ }
83
+ };
84
+
85
+ const readSnapshotFile = async () => {
86
+ if (!allowLocalSnapshotFallback) {
87
+ return null;
88
+ }
89
+
90
+ try {
91
+ const content = await readFile(SNAPSHOT_FILE_PATH, "utf8");
92
+ const snapshot = normalizeTransferSnapshot(JSON.parse(content));
93
+
94
+ return snapshot?.teams ?? null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ };
99
+
100
+ const writeMongoTransfer = async (record: ScrapedTransferItem) => {
101
+ const db = await getDb();
102
+
103
+ if (!db) {
104
+ return {
105
+ configured: false,
106
+ stored: false,
107
+ };
108
+ }
109
+
110
+ try {
111
+ await db.collection(DASHBOARD_COLLECTION).updateOne(
112
+ {
113
+ type: TRANSFER_DOCUMENT_TYPE,
114
+ team: record.team,
115
+ },
116
+ {
117
+ $set: {
118
+ type: TRANSFER_DOCUMENT_TYPE,
119
+ team: record.team,
120
+ matchesPlayed: record.matchesPlayed,
121
+ boostersUsed: record.boostersUsed,
122
+ transfersLeft: record.transfersLeft,
123
+ updatedAt: record.updatedAt,
124
+ },
125
+ },
126
+ { upsert: true },
127
+ );
128
+
129
+ return {
130
+ configured: true,
131
+ stored: true,
132
+ };
133
+ } catch (error) {
134
+ console.error("Failed to write IPL transfer stats to MongoDB:", error);
135
+
136
+ return {
137
+ configured: true,
138
+ stored: false,
139
+ };
140
+ }
141
+ };
142
+
143
+ const writeSnapshotFile = async (snapshot: ScrapedTransferSnapshot) => {
144
+ if (!allowLocalSnapshotFallback) {
145
+ return false;
146
+ }
147
+
148
+ try {
149
+ await writeFile(
150
+ SNAPSHOT_FILE_PATH,
151
+ `${JSON.stringify(snapshot, null, 2)}\n`,
152
+ "utf8",
153
+ );
154
+
155
+ return true;
156
+ } catch (error) {
157
+ console.error("Failed to write IPL transfer snapshot file:", error);
158
+ return false;
159
+ }
160
+ };
161
+
162
+ const snapshotSummary = (teams: ScrapedTransferItem[] | null) =>
163
+ teams && teams.length > 0 ? summarizeTransferSnapshot(teams) : null;
164
+
165
+ export async function OPTIONS() {
166
+ return new Response(null, { status: 204, headers: corsHeaders });
167
+ }
168
+
169
+ export async function GET() {
170
+ const [mongoTransfers, fileTransfers] = await Promise.all([
171
+ readMongoTransfers(),
172
+ readSnapshotFile(),
173
+ ]);
174
+
175
+ const teams = mergeTransferCollections(mongoTransfers ?? [], fileTransfers ?? []);
176
+
177
+ log("GET /api/ipl/transfers", {
178
+ hasMongoTransfers: Boolean(mongoTransfers),
179
+ hasFileTransfers: Boolean(fileTransfers),
180
+ allowLocalSnapshotFallback,
181
+ snapshot: snapshotSummary(teams),
182
+ });
183
+
184
+ if (teams.length === 0) {
185
+ return NextResponse.json(
186
+ { error: "No IPL transfer stats found yet. POST a transfer payload first." },
187
+ { status: 503, headers: corsHeaders },
188
+ );
189
+ }
190
+
191
+ return NextResponse.json(summarizeTransferSnapshot(teams), {
192
+ headers: corsHeaders,
193
+ });
194
+ }
195
+
196
+ export async function POST(request: Request) {
197
+ const mongoConfigured = Boolean(process.env.MONGODB_URI);
198
+ const items = normalizeTransferPayload(await request.json().catch(() => null));
199
+
200
+ if (!items) {
201
+ log("POST /api/ipl/transfers -> 400 (invalid payload)");
202
+ return NextResponse.json(
203
+ {
204
+ error:
205
+ "Invalid payload. Expected { team, matchesPlayed, boostersUsed, transfersLeft, updatedAt } or { teams: [...] }.",
206
+ },
207
+ { status: 400, headers: corsHeaders },
208
+ );
209
+ }
210
+
211
+ if (isProduction && !mongoConfigured) {
212
+ log("POST /api/ipl/transfers -> 503 (MongoDB required in production)");
213
+ return NextResponse.json(
214
+ {
215
+ error:
216
+ "MongoDB storage is required in production. Configure MONGODB_URI for cloud ingestion.",
217
+ },
218
+ { status: 503, headers: corsHeaders },
219
+ );
220
+ }
221
+
222
+ log("POST /api/ipl/transfers received payload", { count: items.length });
223
+
224
+ const [mongoTransfers, fileTransfers] = await Promise.all([
225
+ readMongoTransfers(),
226
+ readSnapshotFile(),
227
+ ]);
228
+
229
+ const baselineTransfers = mergeTransferCollections(
230
+ mongoTransfers ?? [],
231
+ fileTransfers ?? [],
232
+ );
233
+ let nextTransfers = baselineTransfers;
234
+ for (const item of items) {
235
+ nextTransfers = mergeTransferItem(nextTransfers, item);
236
+ }
237
+ const snapshot = summarizeTransferSnapshot(nextTransfers);
238
+
239
+ const mongoResults = await Promise.all(items.map(writeMongoTransfer));
240
+ const fileStorage = await writeSnapshotFile(snapshot);
241
+
242
+ log("POST /api/ipl/transfers write results", {
243
+ mongoResults,
244
+ fileStorage,
245
+ snapshot: snapshotSummary(nextTransfers),
246
+ });
247
+
248
+ return NextResponse.json(
249
+ {
250
+ ok: true,
251
+ count: nextTransfers.length,
252
+ storage: {
253
+ mongodbTransferStats: mongoResults.some((r) => r.stored),
254
+ localSnapshotFile: fileStorage,
255
+ mongodbConfigured: mongoResults.some((r) => r.configured),
256
+ },
257
+ snapshot,
258
+ },
259
+ { headers: corsHeaders },
260
+ );
261
+ }
@@ -0,0 +1,156 @@
1
+ import type {
2
+ ScrapedTransferItem,
3
+ ScrapedTransferSnapshot,
4
+ } from "../../../types";
5
+
6
+ const toNumber = (value: unknown) => {
7
+ const numeric = Number(String(value ?? "").replace(/,/g, "").trim());
8
+ return Number.isFinite(numeric) ? numeric : undefined;
9
+ };
10
+
11
+ const toTrimmedString = (value: unknown) => {
12
+ const text = String(value ?? "").trim();
13
+ return text.length > 0 ? text : undefined;
14
+ };
15
+
16
+ export const normalizeTransferPayload = (
17
+ value: unknown,
18
+ ): ScrapedTransferItem[] | null => {
19
+ if (!value || typeof value !== "object") return null;
20
+
21
+ const candidate = value as Record<string, unknown>;
22
+
23
+ if (Array.isArray(value)) {
24
+ const items = value.map(normalizeTransferItem).filter(Boolean) as ScrapedTransferItem[];
25
+ return items.length > 0 ? items : null;
26
+ }
27
+
28
+ if (Array.isArray(candidate.teams)) {
29
+ const items = candidate.teams
30
+ .map(normalizeTransferItem)
31
+ .filter(Boolean) as ScrapedTransferItem[];
32
+ return items.length > 0 ? items : null;
33
+ }
34
+
35
+ const single = normalizeTransferItem(candidate);
36
+ return single ? [single] : null;
37
+ };
38
+
39
+ export const normalizeTransferItem = (
40
+ value: unknown,
41
+ ): ScrapedTransferItem | null => {
42
+ if (!value || typeof value !== "object") {
43
+ return null;
44
+ }
45
+
46
+ const candidate = value as Record<string, unknown>;
47
+ const team = toTrimmedString(candidate.team);
48
+ const matchesPlayed = toNumber(candidate.matchesPlayed);
49
+ const boostersUsed = toNumber(candidate.boostersUsed) ?? toNumber(candidate.boosters);
50
+ const transfersLeftRaw = candidate.transfersLeft;
51
+ const transfersLeft =
52
+ typeof transfersLeftRaw === "number" && Number.isFinite(transfersLeftRaw)
53
+ ? String(transfersLeftRaw)
54
+ : toTrimmedString(transfersLeftRaw);
55
+ const updatedAt = toTrimmedString(candidate.updatedAt) ?? new Date().toISOString();
56
+
57
+ if (
58
+ !team ||
59
+ matchesPlayed === undefined ||
60
+ boostersUsed === undefined ||
61
+ !transfersLeft
62
+ ) {
63
+ return null;
64
+ }
65
+
66
+ return {
67
+ team,
68
+ matchesPlayed,
69
+ boostersUsed,
70
+ transfersLeft,
71
+ updatedAt,
72
+ };
73
+ };
74
+
75
+ export const normalizeTransferSnapshot = (
76
+ value: unknown,
77
+ ): ScrapedTransferSnapshot | null => {
78
+ if (!value) {
79
+ return null;
80
+ }
81
+
82
+ if (Array.isArray(value)) {
83
+ const teams = value.map(normalizeTransferItem).filter(Boolean) as ScrapedTransferItem[];
84
+
85
+ return teams.length > 0 ? { teams } : null;
86
+ }
87
+
88
+ if (typeof value !== "object") {
89
+ return null;
90
+ }
91
+
92
+ const candidate = value as Record<string, unknown>;
93
+
94
+ if (Array.isArray(candidate.teams)) {
95
+ const teams = candidate.teams
96
+ .map(normalizeTransferItem)
97
+ .filter(Boolean) as ScrapedTransferItem[];
98
+
99
+ return teams.length > 0
100
+ ? {
101
+ updatedAt: toTrimmedString(candidate.updatedAt),
102
+ teams,
103
+ }
104
+ : null;
105
+ }
106
+
107
+ const singleItem = normalizeTransferItem(candidate);
108
+
109
+ return singleItem ? { updatedAt: singleItem.updatedAt, teams: [singleItem] } : null;
110
+ };
111
+
112
+ export const mergeTransferItem = (
113
+ existing: ScrapedTransferItem[],
114
+ incoming: ScrapedTransferItem,
115
+ ) => {
116
+ const items = existing.filter(
117
+ (item) => item.team.toLowerCase() !== incoming.team.toLowerCase(),
118
+ );
119
+
120
+ items.push(incoming);
121
+
122
+ return items.sort((a, b) => a.team.localeCompare(b.team));
123
+ };
124
+
125
+ export const mergeTransferCollections = (
126
+ primary: ScrapedTransferItem[],
127
+ secondary: ScrapedTransferItem[],
128
+ ) => {
129
+ const byTeam = new Map<string, ScrapedTransferItem>();
130
+
131
+ for (const item of [...primary, ...secondary]) {
132
+ const key = item.team.toLowerCase();
133
+ const current = byTeam.get(key);
134
+
135
+ if (!current || current.updatedAt <= item.updatedAt) {
136
+ byTeam.set(key, item);
137
+ }
138
+ }
139
+
140
+ return [...byTeam.values()].sort((a, b) => a.team.localeCompare(b.team));
141
+ };
142
+
143
+ export const summarizeTransferSnapshot = (
144
+ teams: ScrapedTransferItem[],
145
+ ): ScrapedTransferSnapshot => {
146
+ const updatedAt =
147
+ [...teams]
148
+ .map((item) => item.updatedAt)
149
+ .sort()
150
+ .at(-1) ?? new Date().toISOString();
151
+
152
+ return {
153
+ updatedAt,
154
+ teams: [...teams].sort((a, b) => a.team.localeCompare(b.team)),
155
+ };
156
+ };
@@ -0,0 +1,20 @@
1
+ export {
2
+ addLeaderboardMetrics,
3
+ buildDashboardFromSnapshot,
4
+ buildManualDashboard,
5
+ normalizePayload,
6
+ normalizeRawApiUsers,
7
+ serializeRawApiUsersModule,
8
+ syncRawUsersWithSnapshot,
9
+ toFiniteNumber,
10
+ } from "../../../packages/ipl-dashboard-utils/src/transform.ts";
11
+
12
+ import { TEAM_ALIAS_MAP, resolveTeamId } from "../../data/teams.ts";
13
+ import { config } from "../../lib/config.ts";
14
+
15
+ export { resolveTeamId, TEAM_ALIAS_MAP };
16
+
17
+ export const getTransformOptions = () => ({
18
+ teamAliases: TEAM_ALIAS_MAP,
19
+ totalTransfers: config.league.totalTransfers,
20
+ });
@@ -0,0 +1,18 @@
1
+ import { NextResponse } from "next/server";
2
+ import { fetchUpcomingMatches } from "../../../lib/upcomingMatches";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const revalidate = 0;
6
+ export const runtime = "nodejs";
7
+
8
+ export async function GET() {
9
+ const payload = await fetchUpcomingMatches();
10
+
11
+ return NextResponse.json(payload, {
12
+ headers: {
13
+ "Cache-Control": "no-store, no-cache, must-revalidate",
14
+ Pragma: "no-cache",
15
+ Expires: "0",
16
+ },
17
+ });
18
+ }
@@ -0,0 +1,225 @@
1
+ import { NextResponse } from "next/server";
2
+ import getMongoDb from "../../../lib/useDb";
3
+ import { config } from "../../../lib/config.ts";
4
+ import { normalizePayload } from "../../ipl/transform";
5
+ import { summarizeTransferSnapshot } from "../../ipl/transfers/transform";
6
+
7
+ export const dynamic = "force-dynamic";
8
+ export const revalidate = 0;
9
+ export const runtime = "nodejs";
10
+
11
+ const DASHBOARD_COLLECTION = config.mongodb.collectionName;
12
+ const DASHBOARD_DOCUMENT_TYPE = config.docTypes.dashboard;
13
+ const RAW_USERS_DOCUMENT_TYPE = config.docTypes.rawUsers;
14
+ const TRANSFER_DOCUMENT_TYPE = config.docTypes.transferStats;
15
+ const isProduction = process.env.NODE_ENV === "production";
16
+ const DEFAULT_DASHBOARD_STALE_MINUTES = isProduction ? 20 : 180;
17
+ const DEFAULT_TRANSFERS_STALE_MINUTES = isProduction ? 720 : 1440;
18
+
19
+ type AlertSeverity = "info" | "warning" | "critical";
20
+
21
+ const readThresholdMinutes = (name: string, fallback: number) => {
22
+ const raw = Number(process.env[name] ?? "");
23
+ return Number.isFinite(raw) && raw > 0 ? raw : fallback;
24
+ };
25
+
26
+ const dashboardStaleMinutes = readThresholdMinutes(
27
+ "IPL_DASHBOARD_STALE_MINUTES",
28
+ DEFAULT_DASHBOARD_STALE_MINUTES,
29
+ );
30
+ const transfersStaleMinutes = readThresholdMinutes(
31
+ "IPL_TRANSFERS_STALE_MINUTES",
32
+ DEFAULT_TRANSFERS_STALE_MINUTES,
33
+ );
34
+
35
+ const toAgeMs = (value: string | undefined) => {
36
+ if (!value) return null;
37
+ const timestamp = Date.parse(value);
38
+ return Number.isFinite(timestamp) ? Date.now() - timestamp : null;
39
+ };
40
+
41
+ const getFreshness = (
42
+ updatedAt: string | undefined,
43
+ thresholdMinutes: number,
44
+ ) => {
45
+ const ageMs = toAgeMs(updatedAt);
46
+ const thresholdMs = thresholdMinutes * 60 * 1000;
47
+
48
+ if (!updatedAt || ageMs === null) {
49
+ return {
50
+ updatedAt: updatedAt ?? null,
51
+ ageMs,
52
+ thresholdMinutes,
53
+ isStale: true,
54
+ status: "missing" as const,
55
+ };
56
+ }
57
+
58
+ return {
59
+ updatedAt,
60
+ ageMs,
61
+ thresholdMinutes,
62
+ isStale: ageMs > thresholdMs,
63
+ status: ageMs > thresholdMs ? ("stale" as const) : ("fresh" as const),
64
+ };
65
+ };
66
+
67
+ const getDb = async () => {
68
+ try {
69
+ return await getMongoDb();
70
+ } catch (error) {
71
+ console.error("MongoDB connection failed:", error);
72
+ return null;
73
+ }
74
+ };
75
+
76
+ export async function GET() {
77
+ const mongoConfigured = Boolean(process.env.MONGODB_URI);
78
+ const db = await getDb();
79
+
80
+ if (!db) {
81
+ return NextResponse.json(
82
+ {
83
+ ok: !isProduction,
84
+ mode: isProduction ? "cloud" : "local",
85
+ mongoConfigured,
86
+ mongoConnected: false,
87
+ reason: mongoConfigured
88
+ ? "MongoDB connection failed."
89
+ : "MONGODB_URI is not configured.",
90
+ },
91
+ { status: mongoConfigured ? 503 : 200 },
92
+ );
93
+ }
94
+
95
+ const [dashboardDocument, rawUsersDocument, transferDocuments] = await Promise.all([
96
+ db.collection(DASHBOARD_COLLECTION).findOne({ type: DASHBOARD_DOCUMENT_TYPE }),
97
+ db.collection(DASHBOARD_COLLECTION).findOne({ type: RAW_USERS_DOCUMENT_TYPE }),
98
+ db
99
+ .collection(DASHBOARD_COLLECTION)
100
+ .find({ type: TRANSFER_DOCUMENT_TYPE })
101
+ .toArray(),
102
+ ]);
103
+ const snapshot = normalizePayload(dashboardDocument);
104
+ const transfers = summarizeTransferSnapshot(
105
+ transferDocuments
106
+ .map((document) => ({
107
+ team:
108
+ typeof document.team === "string" ? document.team : "",
109
+ matchesPlayed:
110
+ typeof document.matchesPlayed === "number"
111
+ ? document.matchesPlayed
112
+ : Number.NaN,
113
+ boostersUsed:
114
+ typeof document.boostersUsed === "number"
115
+ ? document.boostersUsed
116
+ : Number.NaN,
117
+ transfersLeft:
118
+ typeof document.transfersLeft === "string"
119
+ ? document.transfersLeft
120
+ : String(document.transfersLeft ?? "").trim(),
121
+ updatedAt:
122
+ typeof document.updatedAt === "string"
123
+ ? document.updatedAt
124
+ : new Date(0).toISOString(),
125
+ }))
126
+ .filter(
127
+ (item) =>
128
+ item.team &&
129
+ Number.isFinite(item.matchesPlayed) &&
130
+ Number.isFinite(item.boostersUsed) &&
131
+ item.transfersLeft,
132
+ ),
133
+ );
134
+ const transfersUpdatedAt =
135
+ transfers.teams.length > 0 ? transfers.updatedAt : undefined;
136
+ const dashboardFreshness = getFreshness(
137
+ snapshot?.updatedAt,
138
+ dashboardStaleMinutes,
139
+ );
140
+ const transfersFreshness = getFreshness(
141
+ transfersUpdatedAt,
142
+ transfersStaleMinutes,
143
+ );
144
+ const alerts: Array<{
145
+ code: string;
146
+ severity: AlertSeverity;
147
+ message: string;
148
+ }> = [];
149
+
150
+ if (!snapshot) {
151
+ alerts.push({
152
+ code: "dashboard_missing",
153
+ severity: isProduction ? "critical" : "warning",
154
+ message: "Dashboard snapshot is missing from MongoDB.",
155
+ });
156
+ } else if (dashboardFreshness.isStale) {
157
+ alerts.push({
158
+ code: "dashboard_stale",
159
+ severity: isProduction ? "critical" : "warning",
160
+ message: `Dashboard snapshot is stale. Latest update is older than ${dashboardStaleMinutes} minutes.`,
161
+ });
162
+ }
163
+
164
+ if (transfers.teams.length === 0) {
165
+ alerts.push({
166
+ code: "transfers_missing",
167
+ severity: "warning",
168
+ message: "Transfer snapshot data is missing.",
169
+ });
170
+ } else if (transfersFreshness.isStale) {
171
+ alerts.push({
172
+ code: "transfers_stale",
173
+ severity: "warning",
174
+ message: `Transfer snapshot is stale. Latest update is older than ${transfersStaleMinutes} minutes.`,
175
+ });
176
+ }
177
+
178
+ const hasCriticalAlert = alerts.some((alert) => alert.severity === "critical");
179
+ const hasWarningAlert = alerts.some((alert) => alert.severity === "warning");
180
+ const status = hasCriticalAlert
181
+ ? "critical"
182
+ : hasWarningAlert
183
+ ? "degraded"
184
+ : "ok";
185
+ const responseStatus = status === "critical" ? 503 : 200;
186
+
187
+ return NextResponse.json(
188
+ {
189
+ ok: status !== "critical",
190
+ status,
191
+ mode: isProduction ? "cloud" : "local",
192
+ checkedAt: new Date().toISOString(),
193
+ mongoConfigured,
194
+ mongoConnected: true,
195
+ localSnapshotFallbackEnabled: !isProduction,
196
+ thresholds: {
197
+ dashboardStaleMinutes,
198
+ transfersStaleMinutes,
199
+ },
200
+ dashboard: snapshot
201
+ ? {
202
+ updatedAt: snapshot.updatedAt,
203
+ dailyTransferUpdatedAt: snapshot.dailyTransferUpdatedAt,
204
+ completedMatches: snapshot.completedMatches,
205
+ leadersCount: snapshot.leaders.length,
206
+ freshness: dashboardFreshness,
207
+ }
208
+ : null,
209
+ rawUsers: {
210
+ count: Array.isArray(rawUsersDocument?.users) ? rawUsersDocument.users.length : 0,
211
+ updatedAt:
212
+ typeof rawUsersDocument?.updatedAt === "string"
213
+ ? rawUsersDocument.updatedAt
214
+ : null,
215
+ },
216
+ transfers: {
217
+ updatedAt: transfersUpdatedAt ?? null,
218
+ count: transfers.teams.length,
219
+ freshness: transfersFreshness,
220
+ },
221
+ alerts,
222
+ },
223
+ { status: responseStatus },
224
+ );
225
+ }