@vatvaghool/create-ipl-dashboard 0.1.18 → 0.1.23
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 +38 -129
- package/package.json +2 -3
- package/src/index.mjs +18 -22
- package/src/prompts.mjs +20 -112
- package/src/scaffold.mjs +23 -35
- package/template/README.md +23 -200
- package/template/app/api/ops/seed/route.ts +76 -0
- package/template/app/api/ops/status/route.ts +0 -2
- package/template/app/lib/config.ts +0 -4
- package/template/app/lib/matchStatus.ts +0 -12
- package/template/app/lib/storage/index.ts +1 -10
- package/template/package.json +1 -6
- package/template/scripts/dev-simple.js +1 -1
- package/template/scripts/dev-welcome.mjs +39 -18
- package/template/scripts/seed-league.mjs +0 -55
- package/screenshots/ai-roasting.png +0 -0
- package/screenshots/captain-board.png +0 -0
- package/screenshots/dashboard-overview.png +0 -0
- package/screenshots/ledger-table.png +0 -0
- package/screenshots/match-scrubber.png +0 -0
- package/screenshots/performance-tracker.png +0 -0
- package/src/scraper.mjs +0 -79
- package/template/app/hooks/dashboardPolling.ts +0 -53
- package/template/app/hooks/snapshotCache.ts +0 -47
- package/template/app/lib/storage/google-sheets-storage.ts +0 -147
- package/template/app/lib/utils/diff.ts +0 -24
- package/template/app/lib/utils/time.ts +0 -40
- package/template/screenshots/ai-roasting.png +0 -0
- package/template/screenshots/captain-board.png +0 -0
- package/template/screenshots/dashboard-overview.png +0 -0
- package/template/screenshots/ledger-table.png +0 -0
- package/template/screenshots/match-scrubber.png +0 -0
- package/template/screenshots/performance-tracker.png +0 -0
- package/template/tests/coverage-gaps.test.ts +0 -290
- package/template/tests/dashboard-polling.test.ts +0 -96
- package/template/tests/utils-and-cache.test.ts +0 -267
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { DashboardData } from "../types";
|
|
2
|
-
|
|
3
|
-
export const DASHBOARD_POLL_INTERVAL_MS = 120000;
|
|
4
|
-
|
|
5
|
-
type DashboardFetcher = typeof fetch;
|
|
6
|
-
|
|
7
|
-
type DashboardPollerOptions = {
|
|
8
|
-
fetcher: DashboardFetcher;
|
|
9
|
-
onData: (data: DashboardData) => void;
|
|
10
|
-
onError?: (error: unknown) => void;
|
|
11
|
-
intervalMs?: number;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
export const createDashboardPoller = ({
|
|
15
|
-
fetcher,
|
|
16
|
-
onData,
|
|
17
|
-
onError = console.error,
|
|
18
|
-
intervalMs = DASHBOARD_POLL_INTERVAL_MS,
|
|
19
|
-
}: DashboardPollerOptions) => {
|
|
20
|
-
let active = true;
|
|
21
|
-
let previousSerialized = "";
|
|
22
|
-
|
|
23
|
-
const fetchData = async () => {
|
|
24
|
-
try {
|
|
25
|
-
const res = await fetcher(`/api/ipl?t=${Date.now()}`, {
|
|
26
|
-
cache: "no-store",
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
if (!res.ok) {
|
|
30
|
-
throw new Error(`Dashboard API failed with ${res.status}`);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const data = (await res.json()) as DashboardData;
|
|
34
|
-
const serialized = JSON.stringify(data);
|
|
35
|
-
|
|
36
|
-
if (active && serialized !== previousSerialized) {
|
|
37
|
-
previousSerialized = serialized;
|
|
38
|
-
onData(data);
|
|
39
|
-
}
|
|
40
|
-
} catch (error) {
|
|
41
|
-
onError(error);
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const interval = setInterval(fetchData, intervalMs);
|
|
46
|
-
|
|
47
|
-
void fetchData();
|
|
48
|
-
|
|
49
|
-
return () => {
|
|
50
|
-
active = false;
|
|
51
|
-
clearInterval(interval);
|
|
52
|
-
};
|
|
53
|
-
};
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import type { ScrapedDashboardPayload } from "../types";
|
|
4
|
-
|
|
5
|
-
const CACHE_KEY = "ipl:snapshot-cache:v1";
|
|
6
|
-
|
|
7
|
-
type CachePayload = {
|
|
8
|
-
cachedAt: string;
|
|
9
|
-
snapshot: ScrapedDashboardPayload;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export const readSnapshotCache = (): CachePayload | null => {
|
|
13
|
-
if (typeof window === "undefined") return null;
|
|
14
|
-
|
|
15
|
-
try {
|
|
16
|
-
const raw = window.localStorage.getItem(CACHE_KEY);
|
|
17
|
-
if (!raw) return null;
|
|
18
|
-
|
|
19
|
-
const parsed = JSON.parse(raw) as Partial<CachePayload> | null;
|
|
20
|
-
if (!parsed || typeof parsed !== "object") return null;
|
|
21
|
-
if (!parsed.snapshot || typeof parsed.snapshot !== "object") return null;
|
|
22
|
-
|
|
23
|
-
const cachedAt =
|
|
24
|
-
typeof parsed.cachedAt === "string" ? parsed.cachedAt : undefined;
|
|
25
|
-
|
|
26
|
-
return {
|
|
27
|
-
cachedAt: cachedAt ?? new Date().toISOString(),
|
|
28
|
-
snapshot: parsed.snapshot as ScrapedDashboardPayload,
|
|
29
|
-
};
|
|
30
|
-
} catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
export const writeSnapshotCache = (snapshot: ScrapedDashboardPayload) => {
|
|
36
|
-
if (typeof window === "undefined") return;
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const payload: CachePayload = {
|
|
40
|
-
cachedAt: new Date().toISOString(),
|
|
41
|
-
snapshot,
|
|
42
|
-
};
|
|
43
|
-
window.localStorage.setItem(CACHE_KEY, JSON.stringify(payload));
|
|
44
|
-
} catch {
|
|
45
|
-
// ignore cache write failures (private mode, quota, etc.)
|
|
46
|
-
}
|
|
47
|
-
};
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import type { IStorage, StorageResult } from "./types";
|
|
2
|
-
|
|
3
|
-
const SHEET_ID = () => process.env.GOOGLE_SHEET_ID || "";
|
|
4
|
-
const SHEET_NAMES: Record<string, string> = {
|
|
5
|
-
dashboard: "dashboard",
|
|
6
|
-
"raw-users": "raw_users",
|
|
7
|
-
"transfer-stats": "transfers",
|
|
8
|
-
league: "league",
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
function sheetNameFor(type: string): string {
|
|
12
|
-
return SHEET_NAMES[type] || type.replace(/[^a-z0-9_]/g, "_");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
let sheetsClient: any = null;
|
|
16
|
-
|
|
17
|
-
async function getSheetsClient() {
|
|
18
|
-
if (sheetsClient) return sheetsClient;
|
|
19
|
-
try {
|
|
20
|
-
const { google } = await import("googleapis");
|
|
21
|
-
const auth = new google.auth.GoogleAuth({
|
|
22
|
-
credentials: {
|
|
23
|
-
client_email: process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
|
|
24
|
-
private_key: process.env.GOOGLE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
|
|
25
|
-
},
|
|
26
|
-
scopes: ["https://www.googleapis.com/auth/spreadsheets"],
|
|
27
|
-
});
|
|
28
|
-
sheetsClient = google.sheets({ version: "v4", auth });
|
|
29
|
-
return sheetsClient;
|
|
30
|
-
} catch (error) {
|
|
31
|
-
console.error("Failed to create Google Sheets client:", error);
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export class GoogleSheetsStorage implements IStorage {
|
|
37
|
-
isConfigured(): boolean {
|
|
38
|
-
return Boolean(process.env.GOOGLE_SHEET_ID && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL && process.env.GOOGLE_PRIVATE_KEY);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async isConnected(): Promise<boolean> {
|
|
42
|
-
const client = await getSheetsClient();
|
|
43
|
-
return client !== null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private async ensureSheet(sheetName: string): Promise<void> {
|
|
47
|
-
const client = await getSheetsClient();
|
|
48
|
-
if (!client) return;
|
|
49
|
-
try {
|
|
50
|
-
const res = await client.spreadsheets.get({ spreadsheetId: SHEET_ID() });
|
|
51
|
-
const sheets: any[] = res.data.sheets || [];
|
|
52
|
-
if (sheets.some((s: any) => s.properties?.title === sheetName)) return;
|
|
53
|
-
await client.spreadsheets.batchUpdate({
|
|
54
|
-
spreadsheetId: SHEET_ID(),
|
|
55
|
-
requestBody: {
|
|
56
|
-
requests: [{ addSheet: { properties: { title: sheetName } } }],
|
|
57
|
-
},
|
|
58
|
-
});
|
|
59
|
-
} catch {
|
|
60
|
-
// Sheet already exists or can't be created
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async readDocument(type: string): Promise<Record<string, unknown> | null> {
|
|
65
|
-
const client = await getSheetsClient();
|
|
66
|
-
if (!client) return null;
|
|
67
|
-
const sheetName = sheetNameFor(type);
|
|
68
|
-
try {
|
|
69
|
-
const res = await client.spreadsheets.values.get({
|
|
70
|
-
spreadsheetId: SHEET_ID(),
|
|
71
|
-
range: `${sheetName}!A:C`,
|
|
72
|
-
});
|
|
73
|
-
const rows: string[][] = res.data.values || [];
|
|
74
|
-
const dataRow = rows.find((r) => r[0] === type);
|
|
75
|
-
if (dataRow?.[1]) {
|
|
76
|
-
return { type, ...JSON.parse(dataRow[1]), updatedAt: dataRow[2] || new Date().toISOString() };
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
79
|
-
} catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async upsertDocument(
|
|
85
|
-
type: string,
|
|
86
|
-
data: Record<string, unknown>,
|
|
87
|
-
_filter?: Record<string, unknown>,
|
|
88
|
-
): Promise<StorageResult> {
|
|
89
|
-
const client = await getSheetsClient();
|
|
90
|
-
if (!client) return { configured: true, stored: false, status: "no_connection" };
|
|
91
|
-
const sheetName = sheetNameFor(type);
|
|
92
|
-
await this.ensureSheet(sheetName);
|
|
93
|
-
try {
|
|
94
|
-
const res = await client.spreadsheets.values.get({
|
|
95
|
-
spreadsheetId: SHEET_ID(),
|
|
96
|
-
range: `${sheetName}!A:C`,
|
|
97
|
-
});
|
|
98
|
-
const rows: string[][] = res.data.values || [];
|
|
99
|
-
const rowIndex = rows.findIndex((r) => r[0] === type);
|
|
100
|
-
const jsonData = JSON.stringify(data);
|
|
101
|
-
const updatedAt = new Date().toISOString();
|
|
102
|
-
if (rowIndex >= 0) {
|
|
103
|
-
await client.spreadsheets.values.update({
|
|
104
|
-
spreadsheetId: SHEET_ID(),
|
|
105
|
-
range: `${sheetName}!A${rowIndex + 1}:C${rowIndex + 1}`,
|
|
106
|
-
valueInputOption: "RAW",
|
|
107
|
-
requestBody: { values: [[type, jsonData, updatedAt]] },
|
|
108
|
-
});
|
|
109
|
-
} else {
|
|
110
|
-
await client.spreadsheets.values.append({
|
|
111
|
-
spreadsheetId: SHEET_ID(),
|
|
112
|
-
range: `${sheetName}!A:C`,
|
|
113
|
-
valueInputOption: "RAW",
|
|
114
|
-
requestBody: { values: [[type, jsonData, updatedAt]] },
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
return { configured: true, stored: true, status: "updated" };
|
|
118
|
-
} catch (error) {
|
|
119
|
-
console.error(`Failed to write document type "${type}" to Google Sheets:`, error);
|
|
120
|
-
return { configured: true, stored: false, status: "failed" };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async readDocuments(type: string): Promise<Record<string, unknown>[]> {
|
|
125
|
-
const client = await getSheetsClient();
|
|
126
|
-
if (!client) return [];
|
|
127
|
-
const sheetName = sheetNameFor(type);
|
|
128
|
-
try {
|
|
129
|
-
const res = await client.spreadsheets.values.get({
|
|
130
|
-
spreadsheetId: SHEET_ID(),
|
|
131
|
-
range: `${sheetName}!A:C`,
|
|
132
|
-
});
|
|
133
|
-
const rows: string[][] = res.data.values || [];
|
|
134
|
-
return rows
|
|
135
|
-
.filter((r) => r[0] && r[0] !== type)
|
|
136
|
-
.map((r) => {
|
|
137
|
-
try {
|
|
138
|
-
return { team: r[0], ...JSON.parse(r[1] || "{}"), updatedAt: r[2] || new Date().toISOString() };
|
|
139
|
-
} catch {
|
|
140
|
-
return { team: r[0], updatedAt: r[2] || new Date().toISOString() };
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
} catch {
|
|
144
|
-
return [];
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// utils/diff.ts
|
|
2
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3
|
-
export function getDiff(prev: any, next: any, path = ""): Record<string, any> {
|
|
4
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
-
const diff: Record<string, any> = {};
|
|
6
|
-
|
|
7
|
-
for (const key in next) {
|
|
8
|
-
const newPath = path ? `${path}.${key}` : key;
|
|
9
|
-
|
|
10
|
-
if (
|
|
11
|
-
typeof next[key] === "object" &&
|
|
12
|
-
next[key] !== null &&
|
|
13
|
-
typeof prev?.[key] === "object"
|
|
14
|
-
) {
|
|
15
|
-
Object.assign(diff, getDiff(prev[key], next[key], newPath));
|
|
16
|
-
} else {
|
|
17
|
-
if (prev?.[key] !== next[key]) {
|
|
18
|
-
diff[newPath] = next[key];
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return diff;
|
|
24
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
// utils/time.ts
|
|
2
|
-
|
|
3
|
-
export function formatMatchTime(date: string, timeZone = "Asia/Kolkata") {
|
|
4
|
-
return new Date(date).toLocaleString("en-IN", {
|
|
5
|
-
timeZone,
|
|
6
|
-
hour: "2-digit",
|
|
7
|
-
minute: "2-digit",
|
|
8
|
-
hour12: true,
|
|
9
|
-
day: "2-digit",
|
|
10
|
-
month: "short",
|
|
11
|
-
});
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function getMatchStatus(date: string) {
|
|
15
|
-
const now = new Date().getTime();
|
|
16
|
-
const matchTime = new Date(date).getTime();
|
|
17
|
-
|
|
18
|
-
const diff = matchTime - now;
|
|
19
|
-
|
|
20
|
-
if (diff < -4 * 60 * 60 * 1000) return "ENDED"; // 4h after start
|
|
21
|
-
if (diff <= 0) return "LIVE";
|
|
22
|
-
if (diff <= 30 * 60 * 1000) return "STARTING SOON";
|
|
23
|
-
|
|
24
|
-
return "UPCOMING";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function getTimeLeft(date: string) {
|
|
28
|
-
const now = new Date().getTime();
|
|
29
|
-
const matchTime = new Date(date).getTime();
|
|
30
|
-
|
|
31
|
-
const diff = matchTime - now;
|
|
32
|
-
|
|
33
|
-
if (diff <= 0) return "Started";
|
|
34
|
-
|
|
35
|
-
const mins = Math.floor(diff / 60000);
|
|
36
|
-
const hrs = Math.floor(mins / 60);
|
|
37
|
-
|
|
38
|
-
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
|
|
39
|
-
return `${mins}m`;
|
|
40
|
-
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { afterEach, describe, it, mock } from "node:test";
|
|
3
|
-
|
|
4
|
-
import type { DashboardData, RawApiUser } from "../app/types.ts";
|
|
5
|
-
import {
|
|
6
|
-
addLeaderboardMetrics,
|
|
7
|
-
buildManualDashboard,
|
|
8
|
-
normalizePayload,
|
|
9
|
-
normalizeRawApiUsers,
|
|
10
|
-
syncRawUsersWithSnapshot,
|
|
11
|
-
} from "../app/api/ipl/transform.ts";
|
|
12
|
-
import { createDashboardPoller } from "../app/hooks/dashboardPolling.ts";
|
|
13
|
-
|
|
14
|
-
const flushPromises = () => new Promise<void>((resolve) => queueMicrotask(resolve));
|
|
15
|
-
|
|
16
|
-
describe("coverage gap guards", () => {
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
mock.timers.reset();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("normalizes date instances and filters malformed squad/player records", () => {
|
|
22
|
-
const updatedAt = new Date("2026-04-28T09:15:00.000Z");
|
|
23
|
-
const payload = normalizePayload({
|
|
24
|
-
updatedAt,
|
|
25
|
-
leaders: [
|
|
26
|
-
{
|
|
27
|
-
rank: 1,
|
|
28
|
-
name: "Alpha",
|
|
29
|
-
points: 110,
|
|
30
|
-
captain: { playerName: "Captain A", id: 7, playerPoints: 44 },
|
|
31
|
-
viceCaptain: { fullName: "Vice A", playerId: "11", pts: 22 },
|
|
32
|
-
players: [
|
|
33
|
-
{ name: "Valid Player", number: "08", points: 20 },
|
|
34
|
-
{ playerName: "Bench Player", id: 19, playerPoints: 5 },
|
|
35
|
-
{ number: "99", points: 3 },
|
|
36
|
-
"",
|
|
37
|
-
null,
|
|
38
|
-
],
|
|
39
|
-
},
|
|
40
|
-
],
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
assert.ok(payload);
|
|
44
|
-
assert.equal(payload.updatedAt, updatedAt.toISOString());
|
|
45
|
-
assert.equal(payload.leaders[0].captain?.name, "Captain A");
|
|
46
|
-
assert.equal(payload.leaders[0].captain?.number, "7");
|
|
47
|
-
assert.equal(payload.leaders[0].captain?.points, 44);
|
|
48
|
-
assert.equal(payload.leaders[0].viceCaptain?.name, "Vice A");
|
|
49
|
-
assert.equal(payload.leaders[0].viceCaptain?.number, "11");
|
|
50
|
-
assert.deepEqual(payload.leaders[0].players, [
|
|
51
|
-
{ name: "Valid Player", number: "08", points: 20 },
|
|
52
|
-
{ name: "Bench Player", number: "19", points: 5 },
|
|
53
|
-
]);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("keeps completed match count undefined when a valid payload reports zero", () => {
|
|
57
|
-
const payload = normalizePayload({
|
|
58
|
-
completedMatches: 0,
|
|
59
|
-
Meta: {
|
|
60
|
-
Timestamp: {
|
|
61
|
-
ISTTime: "2026-04-28T09:15:00.000Z",
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
leaders: [{ rank: 1, name: "Alpha", points: 110 }],
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
assert.ok(payload);
|
|
68
|
-
assert.equal(payload.completedMatches, undefined);
|
|
69
|
-
assert.equal(payload.updatedAt, "2026-04-28T09:15:00.000Z");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("parses completed matches from feed text and handles single-row leaderboard metrics", () => {
|
|
73
|
-
const payload = normalizePayload({
|
|
74
|
-
Data: {
|
|
75
|
-
Value: [{ rank: 1, usrname: "Solo", points: 77 }],
|
|
76
|
-
FeedTime: {
|
|
77
|
-
UTCTime: "Match 12",
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
assert.ok(payload);
|
|
83
|
-
assert.equal(payload.completedMatches, 12);
|
|
84
|
-
|
|
85
|
-
const rows = addLeaderboardMetrics([
|
|
86
|
-
{ rank: 1, name: "Solo", points: 77, transfersLeft: 210 },
|
|
87
|
-
]);
|
|
88
|
-
|
|
89
|
-
assert.deepEqual(rows, [
|
|
90
|
-
{
|
|
91
|
-
name: "Solo",
|
|
92
|
-
points: 77,
|
|
93
|
-
rank: 1,
|
|
94
|
-
previousRank: 1,
|
|
95
|
-
previousPoints: 77,
|
|
96
|
-
lastMatchPoints: undefined,
|
|
97
|
-
gapToNext: 0,
|
|
98
|
-
gapPercent: 0,
|
|
99
|
-
movement: "same",
|
|
100
|
-
transfersLeft: 210,
|
|
101
|
-
transfersUsed: undefined,
|
|
102
|
-
totalTransfers: undefined,
|
|
103
|
-
boostersUsed: undefined,
|
|
104
|
-
efficiency: undefined,
|
|
105
|
-
isLastMatchLeader: false,
|
|
106
|
-
captain: undefined,
|
|
107
|
-
viceCaptain: undefined,
|
|
108
|
-
players: undefined,
|
|
109
|
-
},
|
|
110
|
-
]);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("rejects invalid raw user payload shapes and invalid match rows", () => {
|
|
114
|
-
assert.equal(normalizeRawApiUsers({ nope: true }), null);
|
|
115
|
-
|
|
116
|
-
const normalized = normalizeRawApiUsers([
|
|
117
|
-
null,
|
|
118
|
-
{
|
|
119
|
-
rno: 1,
|
|
120
|
-
temname: "Alpha",
|
|
121
|
-
matches: [
|
|
122
|
-
null,
|
|
123
|
-
{ matchId: "bad", points: 10 },
|
|
124
|
-
{ matchId: 1, points: "bad" },
|
|
125
|
-
{ matchId: 2, points: 25 },
|
|
126
|
-
],
|
|
127
|
-
},
|
|
128
|
-
{
|
|
129
|
-
rno: 2,
|
|
130
|
-
temname: "No Matches Array",
|
|
131
|
-
},
|
|
132
|
-
]);
|
|
133
|
-
|
|
134
|
-
assert.deepEqual(normalized, [
|
|
135
|
-
{
|
|
136
|
-
rno: 1,
|
|
137
|
-
temname: "Alpha",
|
|
138
|
-
points: 25,
|
|
139
|
-
matches: [{ matchId: 2, points: 25 }],
|
|
140
|
-
},
|
|
141
|
-
]);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("builds an empty manual dashboard and appends future matches without live points", () => {
|
|
145
|
-
assert.deepEqual(buildManualDashboard([]), {
|
|
146
|
-
overall: [],
|
|
147
|
-
daily: [],
|
|
148
|
-
completedMatches: undefined,
|
|
149
|
-
source: "database",
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
const users: RawApiUser[] = [
|
|
153
|
-
{
|
|
154
|
-
rno: 1,
|
|
155
|
-
temname: "Alpha",
|
|
156
|
-
points: 30,
|
|
157
|
-
matches: [{ matchId: 1, points: 30 }],
|
|
158
|
-
},
|
|
159
|
-
];
|
|
160
|
-
|
|
161
|
-
const result = syncRawUsersWithSnapshot(users, {
|
|
162
|
-
updatedAt: "2026-04-28T09:15:00.000Z",
|
|
163
|
-
completedMatches: 3,
|
|
164
|
-
leaders: [{ rank: 1, name: "Alpha", points: 45 }],
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
assert.equal(result.status, "updated");
|
|
168
|
-
assert.equal(result.mode, "append");
|
|
169
|
-
assert.deepEqual(result.users[0].matches, [
|
|
170
|
-
{ matchId: 1, points: 30 },
|
|
171
|
-
{ matchId: 2, points: 15 },
|
|
172
|
-
]);
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("returns skipped when snapshot leaders do not match raw users", () => {
|
|
176
|
-
const users: RawApiUser[] = [
|
|
177
|
-
{
|
|
178
|
-
rno: 1,
|
|
179
|
-
temname: "Alpha",
|
|
180
|
-
points: 100,
|
|
181
|
-
matches: [{ matchId: 1, points: 100 }],
|
|
182
|
-
},
|
|
183
|
-
];
|
|
184
|
-
|
|
185
|
-
const result = syncRawUsersWithSnapshot(users, {
|
|
186
|
-
updatedAt: "2026-04-28T09:15:00.000Z",
|
|
187
|
-
completedMatches: 2,
|
|
188
|
-
leaders: [{ rank: 1, name: "Ghost Team", points: 150 }],
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
assert.deepEqual(result, {
|
|
192
|
-
status: "skipped",
|
|
193
|
-
users,
|
|
194
|
-
unmatchedNames: ["Ghost Team"],
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("creates a latest match row when forcing update-latest without an existing match entry", () => {
|
|
199
|
-
const users: RawApiUser[] = [
|
|
200
|
-
{
|
|
201
|
-
rno: 1,
|
|
202
|
-
temname: "Alpha",
|
|
203
|
-
points: 10,
|
|
204
|
-
matches: [{ matchId: 1, points: 10 }],
|
|
205
|
-
},
|
|
206
|
-
{
|
|
207
|
-
rno: 2,
|
|
208
|
-
temname: "Beta",
|
|
209
|
-
points: 15,
|
|
210
|
-
matches: [{ matchId: 1, points: 15 }],
|
|
211
|
-
},
|
|
212
|
-
];
|
|
213
|
-
|
|
214
|
-
const result = syncRawUsersWithSnapshot(users, {
|
|
215
|
-
updatedAt: "2026-04-28T09:15:00.000Z",
|
|
216
|
-
completedMatches: 2,
|
|
217
|
-
leaders: [
|
|
218
|
-
{ rank: 1, name: "Beta", points: 15, lastMatchPoints: 0 },
|
|
219
|
-
{ rank: 2, name: "Alpha", points: 18, lastMatchPoints: 8 },
|
|
220
|
-
],
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
assert.equal(result.status, "updated");
|
|
224
|
-
assert.equal(result.mode, "append");
|
|
225
|
-
assert.equal(result.matchId, 2);
|
|
226
|
-
assert.deepEqual(result.users[0].matches, [
|
|
227
|
-
{ matchId: 1, points: 10 },
|
|
228
|
-
{ matchId: 2, points: 8 },
|
|
229
|
-
]);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("reports polling fetch failures and does not emit stale data after stop", async () => {
|
|
233
|
-
mock.timers.enable({ apis: ["Date", "setInterval"], now: 0 });
|
|
234
|
-
|
|
235
|
-
const errors: unknown[] = [];
|
|
236
|
-
const received: DashboardData[] = [];
|
|
237
|
-
let resolveFetch: ((value: Response) => void) | undefined;
|
|
238
|
-
|
|
239
|
-
const fetcher = (async (url: string) => {
|
|
240
|
-
if (url.includes("first")) {
|
|
241
|
-
return {
|
|
242
|
-
ok: false,
|
|
243
|
-
status: 503,
|
|
244
|
-
json: async () => ({}),
|
|
245
|
-
} as Response;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return await new Promise<Response>((resolve) => {
|
|
249
|
-
resolveFetch = resolve;
|
|
250
|
-
});
|
|
251
|
-
}) as typeof fetch;
|
|
252
|
-
|
|
253
|
-
let firstCall = true;
|
|
254
|
-
const wrappedFetcher = (async (url: string, options?: RequestInit) => {
|
|
255
|
-
const actualUrl = firstCall ? "/api/ipl?first=true" : url;
|
|
256
|
-
firstCall = false;
|
|
257
|
-
return fetcher(actualUrl, options);
|
|
258
|
-
}) as typeof fetch;
|
|
259
|
-
|
|
260
|
-
const stop = createDashboardPoller({
|
|
261
|
-
fetcher: wrappedFetcher,
|
|
262
|
-
onData: (data) => received.push(data),
|
|
263
|
-
onError: (error) => errors.push(error),
|
|
264
|
-
intervalMs: 50,
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
await flushPromises();
|
|
268
|
-
await flushPromises();
|
|
269
|
-
assert.equal(errors.length, 1);
|
|
270
|
-
assert.match(String(errors[0]), /503/);
|
|
271
|
-
|
|
272
|
-
mock.timers.tick(50);
|
|
273
|
-
stop();
|
|
274
|
-
|
|
275
|
-
resolveFetch?.({
|
|
276
|
-
ok: true,
|
|
277
|
-
status: 200,
|
|
278
|
-
json: async () =>
|
|
279
|
-
({
|
|
280
|
-
source: "database",
|
|
281
|
-
updatedAt: "2026-04-28T09:20:00.000Z",
|
|
282
|
-
overall: [{ rank: 1, name: "Alpha", points: 120 }],
|
|
283
|
-
daily: [{ day: "Match 1", Alpha: 120 }],
|
|
284
|
-
}) as DashboardData,
|
|
285
|
-
} as Response);
|
|
286
|
-
|
|
287
|
-
await flushPromises();
|
|
288
|
-
assert.equal(received.length, 0);
|
|
289
|
-
});
|
|
290
|
-
});
|