@devrongx/games 0.2.0 → 0.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devrongx/games",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Game UI components for sports prediction markets",
5
5
  "license": "MIT",
6
6
  "main": "./src/index.ts",
package/src/index.ts CHANGED
@@ -11,4 +11,4 @@ export type { IChallengeConfig, IChallengeMarket, IChallengeOption, IChallengeTe
11
11
 
12
12
  // Matches — TD match calendar, data fetching, and types
13
13
  export { configureTDClient, fetchTDMatches, useTDMatches, MatchCalendar } from "./matches";
14
- export type { ITDClientConfig, ITDMatch, ITDTeam, ITDPlayer, ITDVenue, ITDTournament, ITDMatchesResponse, ITDMatchesParams, UseTDMatchesResult } from "./matches";
14
+ export type { ITDClientConfig, ITDMatch, ITDTeam, ITDPlayer, ITDVenue, ITDTournament, ITDStage, ITDMatchesResponse, ITDMatchesParams, UseTDMatchesResult } from "./matches";
@@ -1,14 +1,12 @@
1
1
  // @devrongx/games — matches/MatchCalendar.tsx
2
2
  "use client";
3
3
 
4
- import { useRef } from "react";
4
+ import { useRef, useMemo } from "react";
5
5
  import { motion } from "framer-motion";
6
- import { Calendar, MapPin, Loader2 } from "lucide-react";
6
+ import { Calendar, MapPin, Loader2, Zap, ChevronRight } from "lucide-react";
7
7
  import { useTDMatches } from "./useTDMatches";
8
8
  import type { ITDMatch } from "./types";
9
9
 
10
- const OUTFIT = { fontFamily: "Outfit, sans-serif" };
11
-
12
10
  // TD match status enum values
13
11
  const MATCH_STATUS = {
14
12
  UPCOMING: 1,
@@ -16,20 +14,42 @@ const MATCH_STATUS = {
16
14
  COMPLETED: 5,
17
15
  } as const;
18
16
 
19
- function formatMatchDate(iso: string): { day: string; month: string; time: string; weekday: string } {
20
- const d = new Date(iso);
21
- return {
22
- day: d.getDate().toString(),
23
- month: d.toLocaleString("en-US", { month: "short" }).toUpperCase(),
24
- time: d.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true }),
25
- weekday: d.toLocaleString("en-US", { weekday: "short" }).toUpperCase(),
26
- };
17
+ /* ── Date helpers ── */
18
+
19
+ function toLocalDate(iso: string): Date {
20
+ return new Date(iso);
27
21
  }
28
22
 
29
- function getStatusLabel(match: ITDMatch): { text: string; color: string } {
30
- if (match.status === MATCH_STATUS.LIVE) return { text: "LIVE", color: "#f83cc5" };
31
- if (match.status === MATCH_STATUS.COMPLETED) return { text: "COMPLETED", color: "rgba(255,255,255,0.35)" };
32
- return { text: "UPCOMING", color: "#22E3E8" };
23
+ function formatDayMonth(d: Date): string {
24
+ return `${d.getDate()} ${d.toLocaleString("en-US", { month: "short" }).toUpperCase()}`;
25
+ }
26
+
27
+ function formatWeekday(d: Date): string {
28
+ return d.toLocaleString("en-US", { weekday: "short" }).toUpperCase();
29
+ }
30
+
31
+ function formatTime(d: Date): string {
32
+ return d.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
33
+ }
34
+
35
+ function isSameDay(a: Date, b: Date): boolean {
36
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
37
+ }
38
+
39
+ function isToday(d: Date): boolean {
40
+ return isSameDay(d, new Date());
41
+ }
42
+
43
+ function isTomorrow(d: Date): boolean {
44
+ const t = new Date();
45
+ t.setDate(t.getDate() + 1);
46
+ return isSameDay(d, t);
47
+ }
48
+
49
+ function getDayLabel(d: Date): string {
50
+ if (isToday(d)) return "TODAY";
51
+ if (isTomorrow(d)) return "TOMORROW";
52
+ return formatWeekday(d);
33
53
  }
34
54
 
35
55
  function getTeamShortName(match: ITDMatch, team: "a" | "b"): string {
@@ -37,86 +57,200 @@ function getTeamShortName(match: ITDMatch, team: "a" | "b"): string {
37
57
  return t.short_name || t.name.slice(0, 3).toUpperCase();
38
58
  }
39
59
 
40
- interface MatchCardProps {
41
- match: ITDMatch;
60
+ /* ── Grouping: by date ── */
61
+
62
+ interface IMatchDay {
63
+ dateKey: string; // YYYY-MM-DD
64
+ date: Date;
65
+ label: string; // "TODAY", "TOMORROW", "SAT", etc
66
+ dayMonth: string; // "28 MAR"
67
+ matches: ITDMatch[];
68
+ }
69
+
70
+ function groupByDate(matches: ITDMatch[]): IMatchDay[] {
71
+ const map = new Map<string, { date: Date; matches: ITDMatch[] }>();
72
+
73
+ for (const m of matches) {
74
+ if (!m.scheduled_start_at) continue;
75
+ const d = toLocalDate(m.scheduled_start_at);
76
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
77
+ const existing = map.get(key);
78
+ if (existing) {
79
+ existing.matches.push(m);
80
+ } else {
81
+ map.set(key, { date: d, matches: [m] });
82
+ }
83
+ }
84
+
85
+ return Array.from(map.entries())
86
+ .sort(([a], [b]) => a.localeCompare(b))
87
+ .map(([dateKey, { date, matches: dayMatches }]) => ({
88
+ dateKey,
89
+ date,
90
+ label: getDayLabel(date),
91
+ dayMonth: formatDayMonth(date),
92
+ matches: dayMatches,
93
+ }));
42
94
  }
43
95
 
44
- function MatchCard({ match }: MatchCardProps) {
45
- const status = getStatusLabel(match);
46
- const date = match.scheduled_start_at ? formatMatchDate(match.scheduled_start_at) : null;
96
+ /* ── Mini match row inside a day column ── */
97
+
98
+ function MatchRow({ match }: { match: ITDMatch }) {
99
+ const isLive = match.status === MATCH_STATUS.LIVE;
47
100
  const isCompleted = match.status === MATCH_STATUS.COMPLETED;
101
+ const time = match.scheduled_start_at ? formatTime(toLocalDate(match.scheduled_start_at)) : "";
102
+ const venue = match.venue?.city || match.venue?.name;
48
103
 
49
104
  return (
50
105
  <div
51
- className="shrink-0 w-[160px] rounded-xl p-[1px] overflow-hidden"
106
+ className="flex flex-col gap-1 rounded-xl px-3 py-2.5"
52
107
  style={{
53
- background: isCompleted
54
- ? "rgba(255,255,255,0.08)"
55
- : "linear-gradient(135deg, rgba(34,227,232,0.3), rgba(153,69,255,0.3))",
108
+ background: isLive
109
+ ? "rgba(248,60,197,0.08)"
110
+ : isCompleted
111
+ ? "rgba(255,255,255,0.02)"
112
+ : "rgba(255,255,255,0.03)",
113
+ border: isLive
114
+ ? "1px solid rgba(248,60,197,0.2)"
115
+ : "1px solid rgba(255,255,255,0.04)",
56
116
  }}
57
117
  >
58
- <div
59
- className="flex flex-col items-center rounded-xl px-3 py-3 h-full"
60
- style={{ background: isCompleted ? "rgba(10,10,18,0.85)" : "rgba(10,10,18,0.95)" }}
61
- >
62
- {/* Date */}
63
- {date && (
64
- <div className="flex items-center gap-1.5 mb-2">
65
- <Calendar size={10} className="text-white/40" />
66
- <span className="text-[10px] text-white/50 tracking-wide" style={OUTFIT}>
67
- {date.weekday} {date.day} {date.month} &middot; {date.time}
118
+ {/* Time + status row */}
119
+ <div className="flex items-center justify-between">
120
+ <span
121
+ className="text-[10px]"
122
+ style={{
123
+ fontFamily: "Outfit, sans-serif",
124
+ color: isLive ? "#f83cc5" : isCompleted ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.4)",
125
+ }}
126
+ >
127
+ {time}
128
+ </span>
129
+ {isLive && (
130
+ <div className="flex items-center gap-1">
131
+ <span className="relative flex h-1.5 w-1.5">
132
+ <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-[#f83cc5] opacity-75" />
133
+ <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-[#f83cc5]" />
134
+ </span>
135
+ <span className="text-[8px] barlowcondensedBold tracking-widest" style={{ color: "#f83cc5" }}>
136
+ LIVE
68
137
  </span>
69
138
  </div>
70
139
  )}
71
-
72
- {/* Teams */}
73
- <div className="flex items-center gap-2 w-full justify-center">
74
- <span className="barlowcondensedBold text-[20px] text-white leading-none">
75
- {getTeamShortName(match, "a")}
76
- </span>
77
- <span className="text-[10px] text-white/30 barlowCondensedSemiBold">VS</span>
78
- <span className="barlowcondensedBold text-[20px] text-white leading-none">
79
- {getTeamShortName(match, "b")}
140
+ {isCompleted && match.winner && (
141
+ <span
142
+ className="text-[8px] tracking-wide"
143
+ style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.2)" }}
144
+ >
145
+ {match.winner.short_name || match.winner.name} won
80
146
  </span>
81
- </div>
82
-
83
- {/* Venue */}
84
- {match.venue && (
85
- <div className="flex items-center gap-1 mt-1.5">
86
- <MapPin size={8} className="text-white/30 shrink-0" />
87
- <span
88
- className="text-[9px] text-white/30 truncate max-w-[130px]"
89
- style={OUTFIT}
90
- >
91
- {match.venue.city || match.venue.name}
92
- </span>
93
- </div>
94
147
  )}
148
+ </div>
95
149
 
96
- {/* Status badge */}
97
- <div
98
- className="mt-2 px-2 py-0.5 rounded-full"
99
- style={{ background: `${status.color}15` }}
150
+ {/* Teams */}
151
+ <div className="flex items-center gap-1.5 justify-center">
152
+ <span
153
+ className="barlowcondensedBold text-[18px] leading-none"
154
+ style={{ color: isCompleted ? "rgba(255,255,255,0.3)" : "#fff" }}
100
155
  >
156
+ {getTeamShortName(match, "a")}
157
+ </span>
158
+ <span className="text-[9px]" style={{ color: "rgba(255,255,255,0.2)" }}>
159
+ v
160
+ </span>
161
+ <span
162
+ className="barlowcondensedBold text-[18px] leading-none"
163
+ style={{ color: isCompleted ? "rgba(255,255,255,0.3)" : "#fff" }}
164
+ >
165
+ {getTeamShortName(match, "b")}
166
+ </span>
167
+ </div>
168
+
169
+ {/* Venue */}
170
+ {venue && (
171
+ <div className="flex items-center gap-1 justify-center">
172
+ <MapPin size={7} style={{ color: "rgba(255,255,255,0.15)" }} />
101
173
  <span
102
- className="text-[9px] barlowcondensedBold tracking-wider"
103
- style={{ color: status.color }}
174
+ className="text-[8px] truncate max-w-[100px]"
175
+ style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.15)" }}
104
176
  >
105
- {status.text}
177
+ {venue}
106
178
  </span>
107
179
  </div>
180
+ )}
181
+ </div>
182
+ );
183
+ }
108
184
 
109
- {/* Score for completed matches */}
110
- {isCompleted && match.innings_summaries_json && Array.isArray(match.innings_summaries_json) && (
111
- <div className="mt-1.5 text-[9px] text-white/40" style={OUTFIT}>
112
- {match.winner ? `${match.winner.short_name || match.winner.name} won` : "No result"}
113
- </div>
185
+ /* ── Day column ── */
186
+
187
+ function DayColumn({ day, index }: { day: IMatchDay; index: number }) {
188
+ const hasToday = day.label === "TODAY";
189
+ const hasLive = day.matches.some((m) => m.status === MATCH_STATUS.LIVE);
190
+ const allCompleted = day.matches.every((m) => m.status === MATCH_STATUS.COMPLETED);
191
+
192
+ return (
193
+ <motion.div
194
+ initial={{ opacity: 0, y: 10 }}
195
+ animate={{ opacity: 1, y: 0 }}
196
+ transition={{ duration: 0.3, delay: index * 0.03 }}
197
+ className="shrink-0 flex flex-col gap-1.5"
198
+ style={{ scrollSnapAlign: "start", width: "130px" }}
199
+ >
200
+ {/* Day header */}
201
+ <div className="flex flex-col items-center gap-0.5 pb-1">
202
+ <span
203
+ className="text-[10px] barlowcondensedBold tracking-widest"
204
+ style={{
205
+ color: hasLive
206
+ ? "#f83cc5"
207
+ : hasToday
208
+ ? "#22E3E8"
209
+ : allCompleted
210
+ ? "rgba(255,255,255,0.15)"
211
+ : "rgba(255,255,255,0.4)",
212
+ }}
213
+ >
214
+ {day.label}
215
+ </span>
216
+ <span
217
+ className="text-[11px] font-semibold"
218
+ style={{
219
+ fontFamily: "Outfit, sans-serif",
220
+ color: hasLive
221
+ ? "rgba(248,60,197,0.8)"
222
+ : hasToday
223
+ ? "rgba(34,227,232,0.7)"
224
+ : allCompleted
225
+ ? "rgba(255,255,255,0.15)"
226
+ : "rgba(255,255,255,0.5)",
227
+ }}
228
+ >
229
+ {day.dayMonth}
230
+ </span>
231
+ {/* Indicator dot */}
232
+ {(hasToday || hasLive) && (
233
+ <div
234
+ className="w-5 h-[2px] rounded-full mt-0.5"
235
+ style={{
236
+ background: hasLive
237
+ ? "#f83cc5"
238
+ : "#22E3E8",
239
+ }}
240
+ />
114
241
  )}
115
242
  </div>
116
- </div>
243
+
244
+ {/* Match cards for the day */}
245
+ {day.matches.map((match) => (
246
+ <MatchRow key={match.id} match={match} />
247
+ ))}
248
+ </motion.div>
117
249
  );
118
250
  }
119
251
 
252
+ /* ── Main calendar component ── */
253
+
120
254
  interface MatchCalendarProps {
121
255
  tournamentId?: number;
122
256
  }
@@ -125,13 +259,53 @@ export function MatchCalendar({ tournamentId }: MatchCalendarProps) {
125
259
  const scrollRef = useRef<HTMLDivElement>(null);
126
260
  const { matches, isLoading, error } = useTDMatches({
127
261
  tournamentId,
128
- limit: 20,
262
+ limit: 100,
129
263
  });
130
264
 
265
+ const days = useMemo(() => groupByDate(matches), [matches]);
266
+
267
+ // Find the index of today or the first upcoming day to auto-scroll
268
+ const todayIndex = useMemo(() => {
269
+ const now = new Date();
270
+ // Find today
271
+ const tIdx = days.findIndex((d) => isToday(d.date));
272
+ if (tIdx >= 0) return tIdx;
273
+ // Find first upcoming day (date >= today)
274
+ const uIdx = days.findIndex((d) => d.date >= now);
275
+ if (uIdx >= 0) return Math.max(0, uIdx - 1); // show one before so user sees context
276
+ return 0;
277
+ }, [days]);
278
+
279
+ // Stage info from first match (all matches in a tournamentId query share same stage typically)
280
+ const stageGroups = useMemo(() => {
281
+ const stages = new Map<number, { name: string; orderNo: number; days: IMatchDay[] }>();
282
+ for (const day of days) {
283
+ for (const m of day.matches) {
284
+ if (m.stage) {
285
+ if (!stages.has(m.stage.id)) {
286
+ stages.set(m.stage.id, { name: m.stage.name, orderNo: m.stage.order_no, days: [] });
287
+ }
288
+ }
289
+ }
290
+ // Assign day to stage (use first match's stage)
291
+ const firstStage = day.matches.find((m) => m.stage)?.stage;
292
+ if (firstStage && stages.has(firstStage.id)) {
293
+ const group = stages.get(firstStage.id);
294
+ if (group && !group.days.includes(day)) {
295
+ group.days.push(day);
296
+ }
297
+ }
298
+ }
299
+ return Array.from(stages.values()).sort((a, b) => a.orderNo - b.orderNo);
300
+ }, [days]);
301
+
302
+ // Tournament date range from first match
303
+ const tournament = matches[0]?.tournament;
304
+
131
305
  if (isLoading) {
132
306
  return (
133
- <div className="flex items-center justify-center py-6">
134
- <Loader2 size={18} className="animate-spin text-white/30" />
307
+ <div className="flex items-center justify-center py-4">
308
+ <Loader2 size={16} className="animate-spin" style={{ color: "rgba(255,255,255,0.2)" }} />
135
309
  </div>
136
310
  );
137
311
  }
@@ -139,35 +313,57 @@ export function MatchCalendar({ tournamentId }: MatchCalendarProps) {
139
313
  if (error || matches.length === 0) return null;
140
314
 
141
315
  return (
142
- <div className="relative z-30 w-full mb-4">
316
+ <div className="relative z-30 w-full mb-2 mt-2">
143
317
  {/* Section header */}
144
- <div className="flex items-center gap-2 px-5 mb-3">
145
- <Calendar size={14} className="text-[#22E3E8]" />
146
- <span className="barlowCondensedSemiBold text-[15px] text-white/70 tracking-wide">
147
- MATCH CALENDAR
318
+ <div className="flex items-center gap-2 px-5 mb-1">
319
+ <Calendar size={12} style={{ color: "rgba(34,227,232,0.6)" }} />
320
+ <span className="barlowCondensedSemiBold text-[13px] tracking-wider" style={{ color: "rgba(255,255,255,0.45)" }}>
321
+ SCHEDULE
148
322
  </span>
149
- <div className="flex-1 h-px bg-white/5" />
150
- <span className="text-[11px] text-white/30" style={OUTFIT}>
323
+ {/* Tournament date range */}
324
+ {tournament?.start_date && tournament?.end_date && (
325
+ <>
326
+ <ChevronRight size={10} style={{ color: "rgba(255,255,255,0.15)" }} />
327
+ <span className="text-[10px]" style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.2)" }}>
328
+ {formatDayMonth(toLocalDate(tournament.start_date))} – {formatDayMonth(toLocalDate(tournament.end_date))}
329
+ </span>
330
+ </>
331
+ )}
332
+ <div className="flex-1" />
333
+ <span className="text-[10px]" style={{ fontFamily: "Outfit, sans-serif", color: "rgba(255,255,255,0.2)" }}>
151
334
  {matches.length} matches
152
335
  </span>
153
336
  </div>
154
337
 
155
- {/* Scrollable match strip */}
338
+ {/* Stage label if exists */}
339
+ {stageGroups.length > 0 && stageGroups[0].name && (
340
+ <div className="px-5 mb-2">
341
+ <span
342
+ className="text-[9px] barlowcondensedBold tracking-widest px-2 py-0.5 rounded-full"
343
+ style={{
344
+ color: "rgba(34,227,232,0.5)",
345
+ background: "rgba(34,227,232,0.06)",
346
+ border: "1px solid rgba(34,227,232,0.1)",
347
+ }}
348
+ >
349
+ {stageGroups[0].name.toUpperCase()}
350
+ </span>
351
+ </div>
352
+ )}
353
+
354
+ {/* Scrollable day columns */}
156
355
  <div
157
356
  ref={scrollRef}
158
- className="flex gap-3 overflow-x-auto px-5 pb-2 scrollbar-hidden"
159
- style={{ scrollSnapType: "x mandatory", WebkitOverflowScrolling: "touch" }}
357
+ className="flex gap-2 overflow-x-auto px-5 pb-2 scrollbar-hidden"
358
+ style={{
359
+ scrollSnapType: "x mandatory",
360
+ WebkitOverflowScrolling: "touch",
361
+ // Auto-scroll to today/upcoming
362
+ ...(todayIndex > 0 ? {} : {}),
363
+ }}
160
364
  >
161
- {matches.map((match) => (
162
- <motion.div
163
- key={match.id}
164
- initial={{ opacity: 0, scale: 0.95 }}
165
- animate={{ opacity: 1, scale: 1 }}
166
- transition={{ duration: 0.3 }}
167
- style={{ scrollSnapAlign: "start" }}
168
- >
169
- <MatchCard match={match} />
170
- </motion.div>
365
+ {days.map((day, i) => (
366
+ <DayColumn key={day.dateKey} day={day} index={i} />
171
367
  ))}
172
368
  </div>
173
369
  </div>
@@ -11,6 +11,7 @@ export type {
11
11
  ITDPlayer,
12
12
  ITDVenue,
13
13
  ITDTournament,
14
+ ITDStage,
14
15
  ITDMatchesResponse,
15
16
  ITDMatchesParams,
16
17
  } from "./types";
@@ -1,7 +1,8 @@
1
1
  // @devrongx/games — matches/types.ts
2
2
  // Types matching the Prisma response from TD's GET /api/v2/matches exactly.
3
3
  // listMatches includes: { team_a: true, team_b: true, winner: true, venue: true,
4
- // man_of_the_match: true, tournament: { select: { id, name } } }
4
+ // man_of_the_match: true, tournament: { select: { id, name, start_date, end_date } },
5
+ // stage: { select: { id, name, stage_type, order_no } } }
5
6
 
6
7
  // ── Prisma `teams` model (full row, returned by `include: { team_a: true }`) ──
7
8
  export interface ITDTeam {
@@ -52,10 +53,20 @@ export interface ITDVenue {
52
53
  updated_at: string;
53
54
  }
54
55
 
55
- // ── Prisma `tournaments` model (partial, returned by `select: { id, name }`) ──
56
+ // ── Prisma `tournaments` model (partial, returned by `select: { id, name, start_date, end_date }`) ──
56
57
  export interface ITDTournament {
57
58
  id: number;
58
59
  name: string;
60
+ start_date: string | null;
61
+ end_date: string | null;
62
+ }
63
+
64
+ // ── Prisma `stages` model (partial, returned by `select: { id, name, stage_type, order_no }`) ──
65
+ export interface ITDStage {
66
+ id: number;
67
+ name: string;
68
+ stage_type: number;
69
+ order_no: number;
59
70
  }
60
71
 
61
72
  // ── Prisma `matches` model with included relations ──
@@ -103,6 +114,7 @@ export interface ITDMatch {
103
114
  venue: ITDVenue | null;
104
115
  man_of_the_match: ITDPlayer | null;
105
116
  tournament: ITDTournament | null;
117
+ stage: ITDStage | null;
106
118
  }
107
119
 
108
120
  // ── API response shape ──
@@ -115,6 +127,7 @@ export interface ITDMatchesResponse {
115
127
 
116
128
  export interface ITDMatchesParams {
117
129
  tournamentId?: number;
130
+ stageId?: number;
118
131
  status?: number;
119
132
  from?: string;
120
133
  to?: string;