@claudinho/core 0.4.2 → 0.5.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/README.md CHANGED
@@ -18,13 +18,13 @@ npm i @claudinho/core
18
18
  ## What's inside
19
19
 
20
20
  - **Domain model** — `Match` (incl. `venue`/`city`/`country`), `Team`, `Stage`, `Status`, `PunditPick`, `LedgerRow`
21
- - **`ProviderAdapter`** — the swappable data-vendor interface; `EspnAdapter` included
21
+ - **`ProviderAdapter`** — the swappable data-vendor interface (`fetchByDate`, `fetchLive`, optional `fetchWindow`/`fetchStandings`); `EspnAdapter` included
22
22
  - **Static schedule** — all 104 fixtures (groups, venues, host cities, kickoffs) bundled; query with `allFixtures`, `fixturesByDate` (groups by your timezone), `fixturesByTeam`, `fixturesByGroup`, `nextFixtureForTeam`, `groups`
23
23
  - **Live overlay** — `makeAdapter`, `getMatchesForDate`, `getLiveMatches`, `mergeLive` (static base + live state, with graceful degradation)
24
- - **Standings** — `computeStandings` (points / GD / GF tiebreak)
24
+ - **Standings** — `getStandings` fetches authoritative, cumulative group tables from the provider (`GroupStandings`), failing closed to a roster-at-zero when none is available; `computeStandings` derives a table from a set of matches (points / GD / GF tiebreak)
25
25
  - **Helpers** — emoji flags (`nationToFlag`), TZ-aware time (`formatKickoff`, `formatDate`, `formatTime`, `countdown`, `localDate`), location strings (`matchLocation`), localized commentary flair (`matchFlavor` / `FlavorLevel`), validators (`isValidDate`, `isValidTimeZone`)
26
26
  - **Prediction-market signals (sidecar)** — read-only market signals kept *separate* from `Match`: the `MarketSignal` / `MarketProvider` model, the `PolymarketProvider` (public Gamma data only — no auth/trading/links; event slugs auto-derived per fixture, validation fails closed), a `FakeMarketProvider`, `makeMarketProvider`, `getMarketSignal` / `getMarketSignals`, the `isReliableMarketSignal` gate, and approved-copy formatters (`marketFavoriteText`, `marketProbabilityText`, `marketBlock`). Informational only — never betting advice.
27
- - **Shareable snippets** — `formatShareSnippet` builds pure, deterministic, plain-text match cards (composing `Match` + the market copy bank) for the CLI's `share` command and future MCP/site reuse. The non-affiliation disclaimer is non-optional; market lines come from the approved bank.
27
+ - **Shareable snippets** — `formatShareSnippet` builds pure, deterministic, plain-text match cards (composing `Match` + the market copy bank); `formatShareTable` does the same for a group's standings (facts + emoji flags only, no market line). For the CLI's `share` command and MCP/site reuse. The non-affiliation disclaimer is non-optional; market lines come from the approved bank.
28
28
 
29
29
  ## Example
30
30
 
package/dist/index.d.ts CHANGED
@@ -224,6 +224,21 @@ declare function nextFixtureForTeam(code: string, opts?: {
224
224
  from?: Date;
225
225
  fixtures?: Match[];
226
226
  }): Match | undefined;
227
+ /** A fixture is potentially live from kickoff until ~kickoff + 140 min. */
228
+ declare const LIVE_WINDOW_MS: number;
229
+ /** Fixtures whose live window contains `now` (cheap, static — no network). */
230
+ declare function fixturesInLiveWindow(now?: number, fixtures?: Match[]): Match[];
231
+ /**
232
+ * The team's in-play fixture when one is inside its live window, else the next
233
+ * upcoming fixture. This is "the match that matters for <team> right now":
234
+ * mid-match, a user (or an agent) asking about a team almost always means the
235
+ * one being played — `nextFixtureForTeam` alone would skip it the moment the
236
+ * kickoff is in the past and silently answer about next week.
237
+ */
238
+ declare function currentOrNextFixtureForTeam(code: string, opts?: {
239
+ from?: Date;
240
+ fixtures?: Match[];
241
+ }): Match | undefined;
227
242
  /** Sorted list of distinct group letters present in the schedule. */
228
243
  declare function groups(fixtures?: Match[]): string[];
229
244
 
@@ -239,6 +254,11 @@ interface StandingRow {
239
254
  goalDiff: number;
240
255
  points: number;
241
256
  }
257
+ /** A group's table: the group letter ("A".."L") and its rows in standings order. */
258
+ interface GroupStandings {
259
+ group: string;
260
+ rows: StandingRow[];
261
+ }
242
262
  /**
243
263
  * Compute a group table from a set of matches. Teams are seeded from every
244
264
  * match (so a pre-tournament table still lists all four teams at 0), and only
@@ -271,6 +291,13 @@ interface ProviderAdapter {
271
291
  fetchLive(): Promise<Match[]>;
272
292
  /** Optional inclusive date-range fetch (used for schedule generation). */
273
293
  fetchWindow?(startDate: string, endDate: string): Promise<Match[]>;
294
+ /**
295
+ * Optional authoritative group tables (cumulative across the group stage).
296
+ * Returned in standings order per group. Providers that can't supply a real
297
+ * table omit this; callers then fall back (degraded) to a roster at zero —
298
+ * never a wrong, partial table computed from a narrow live window.
299
+ */
300
+ fetchStandings?(): Promise<GroupStandings[]>;
274
301
  /** Optional push subscription (websocket/SSE providers). Returns an unsubscribe fn. */
275
302
  subscribe?(onBatch: (matches: Match[]) => void): () => void;
276
303
  }
@@ -360,6 +387,34 @@ interface EspnAdapterOptions {
360
387
  */
361
388
  enrichGroups?: boolean;
362
389
  }
390
+ interface EspnStandingsStat {
391
+ name?: string;
392
+ value?: number;
393
+ }
394
+ interface EspnStandingsEntry {
395
+ team?: EspnTeam;
396
+ stats?: EspnStandingsStat[];
397
+ }
398
+ interface EspnStandingsChild {
399
+ name?: string;
400
+ abbreviation?: string;
401
+ standings?: {
402
+ entries?: EspnStandingsEntry[];
403
+ };
404
+ }
405
+ interface EspnStandings {
406
+ children?: EspnStandingsChild[];
407
+ }
408
+ /**
409
+ * Parse the standings payload into group tables. Pure (exported for tests).
410
+ *
411
+ * Two non-obvious robustness points, both verified against the live response:
412
+ * - ESPN's `entries` array is NOT in rank order, so we sort by the `rank` stat
413
+ * (falling back to points → GD → GF → code when rank is absent).
414
+ * - Non-group `children` (knockout brackets) are skipped by the "Group X" name
415
+ * test, so this stays correct once the bracket phase begins.
416
+ */
417
+ declare function parseStandings(data: EspnStandings): GroupStandings[];
363
418
  declare class EspnAdapter implements ProviderAdapter {
364
419
  private readonly opts;
365
420
  readonly name = "espn";
@@ -370,9 +425,18 @@ declare class EspnAdapter implements ProviderAdapter {
370
425
  fetchByDate(dateISO: string): Promise<Match[]>;
371
426
  fetchWindow(startDate: string, endDate: string): Promise<Match[]>;
372
427
  fetchLive(): Promise<Match[]>;
428
+ /** Standings endpoint URL (lives under apis/v2, not site/v2; derived from base). */
429
+ private standingsUrl;
430
+ /**
431
+ * Authoritative, cumulative group tables from the standings endpoint. Throws
432
+ * on fetch/parse failure (the caller decides the fallback). Group-stage only:
433
+ * non-group `children` are filtered out by {@link parseStandings}.
434
+ */
435
+ fetchStandings(): Promise<GroupStandings[]>;
373
436
  /**
374
437
  * Build (and cache) a team-code -> group-letter map from the standings
375
- * endpoint. Best-effort: returns {} if standings are unavailable.
438
+ * endpoint. Best-effort: returns {} if standings are unavailable. Reuses the
439
+ * same parse as {@link fetchStandings}, so the two never drift.
376
440
  */
377
441
  fetchGroupMap(force?: boolean): Promise<Record<string, string>>;
378
442
  private fetchScoreboard;
@@ -412,6 +476,55 @@ declare function liveSourceLabel(source: string): string;
412
476
  * schedule on any provider/network error (graceful degradation).
413
477
  */
414
478
  declare function getMatchesForDate(adapter: ProviderAdapter, dateISO: string): Promise<LiveResult>;
479
+ interface StandingsResult {
480
+ /** Group tables in group-letter order; each table's rows in standings order. */
481
+ tables: GroupStandings[];
482
+ /** True when no authoritative table was available and rows are a static roster. */
483
+ degraded: boolean;
484
+ /** The provider that served a real table (absent when degraded). */
485
+ source?: string;
486
+ }
487
+ /**
488
+ * Authoritative group tables, preferring the provider's cumulative standings and
489
+ * FAILING CLOSED to a roster-at-zero (degraded) when none is available.
490
+ *
491
+ * Deliberately does NOT compute a table from a live-match window: that silently
492
+ * drops earlier matchdays and reports a wrong, partial table (e.g. all-zeros for
493
+ * a group not playing today) — the bug this replaced. A degraded roster is
494
+ * honestly empty; a confidently-wrong table is the failure mode we refuse.
495
+ *
496
+ * An empty `tables` with `degraded: false` means the fetch succeeded but the
497
+ * asked-for group isn't in it (caller renders "no such group").
498
+ */
499
+ declare function getStandings(adapter: ProviderAdapter, group?: string): Promise<StandingsResult>;
500
+ interface MatchByIdResult {
501
+ match?: Match;
502
+ degraded: boolean;
503
+ source?: string;
504
+ }
505
+ /**
506
+ * The fixture a team-scoped MARKET query should be about, live-confirmed.
507
+ * Static window math alone fails twice at the edges: a match in extra time
508
+ * (now > kickoff + 140min, still LIVE) would be skipped for next week's
509
+ * fixture, and a just-finished match (inside the window, already FT) would be
510
+ * selected over the next one. So: pick the in-window candidate using a widened
511
+ * window, overlay live state, and fall through to the next fixture when the
512
+ * overlay says the candidate is finished. Degraded fetches keep the static
513
+ * candidate (fail-closed: the market-relevance gate then errs toward showing
514
+ * nothing rather than something wrong).
515
+ */
516
+ declare function marketFixtureForTeam(adapter: ProviderAdapter, code: string, now?: Date): Promise<MatchByIdResult>;
517
+ /**
518
+ * A single match by id, with live overlay. The provider's scoreboard buckets
519
+ * days in its own zone (ESPN: US/Eastern), so a fixture's UTC date can differ
520
+ * from the scoreboard day it's filed under — a 02:00Z kickoff belongs to the
521
+ * previous ET evening, and fetching only the UTC date silently misses its live
522
+ * state (the match then renders from the static schedule as if scheduled).
523
+ * Fetch the ±1-day window around the fixture's UTC date instead — the same
524
+ * trick `getMatchesForDate` uses — and fall back to the static fixture on any
525
+ * provider error.
526
+ */
527
+ declare function getMatchById(adapter: ProviderAdapter, id: string): Promise<MatchByIdResult>;
415
528
  /** Currently-live matches; empty + degraded on error. */
416
529
  declare function getLiveMatches(adapter: ProviderAdapter): Promise<LiveResult>;
417
530
 
@@ -520,14 +633,16 @@ interface MarketProvider {
520
633
  findSignals(matches: Match[], options?: MarketSignalOptions): Promise<MarketSignalsResult>;
521
634
  }
522
635
 
523
- /**
524
- * Probability normalization + the reliability gate. `isReliableMarketSignal` is
525
- * the single primitive every default-on surface keys off: "show only when
526
- * reliable, otherwise omit silently."
527
- */
528
-
529
636
  /** Default freshness window: a signal older than this is stale (15 minutes). */
530
637
  declare const DEFAULT_MAX_AGE_MS: number;
638
+ /**
639
+ * Market signals are pre-match and in-play reads. Once a match has finished —
640
+ * status `FT`, or (for static fixtures that never get a live overlay) once its
641
+ * live window has long passed — a "favorite" is resolved history: showing
642
+ * "markets favor X 100%" after full time reads as a bug, not information.
643
+ * An unparseable kickoff fails open (the reliability gate still applies).
644
+ */
645
+ declare function marketRelevant(match: Match, now?: Date): boolean;
531
646
  /**
532
647
  * Re-scale outcome probabilities so the positive ones sum to 1. This removes
533
648
  * market "vig" (raw prices sum to >1) and is scale-agnostic: inputs may be
@@ -762,5 +877,32 @@ interface ShareSnippetInput {
762
877
  * line; lines within a block by a single newline.
763
878
  */
764
879
  declare function formatShareSnippet(input: ShareSnippetInput, options?: ShareSnippetOptions): string;
880
+ interface ShareTableInput {
881
+ /** Group tables to render (1..n); each in standings order. */
882
+ tables: {
883
+ group: string;
884
+ rows: StandingRow[];
885
+ }[];
886
+ /** Live-data provider name for attribution; omit when degraded/static. */
887
+ source?: string;
888
+ /** Exact run cue, e.g. "npx @claudinho/cli table A". */
889
+ installLine?: string;
890
+ /** Body line when there are no tables (e.g. "No group Z."). */
891
+ emptyNote?: string;
892
+ /**
893
+ * True when the rows are a static roster (no live results), not an
894
+ * authoritative table. A shared card is pasted into public/social, so this
895
+ * MUST be surfaced — otherwise a roster-at-zero reads as a real "nobody has
896
+ * played yet" table. The card then carries an explicit not-live notice.
897
+ */
898
+ degraded?: boolean;
899
+ }
900
+ /**
901
+ * Render a shareable group-standings card. Pure, plain-text, deterministic. Like
902
+ * {@link formatShareSnippet} but for tables: facts + emoji flags only, no market
903
+ * lines (standings carry no market read), disclaimer non-optional, hashtag and
904
+ * run cue toggleable via {@link ShareSnippetOptions}.
905
+ */
906
+ declare function formatShareTable(input: ShareTableInput, options?: ShareSnippetOptions): string;
765
907
 
766
- export { type BuildSignalInput, DEFAULT_COMPETITION, DEFAULT_FLAVOR, DEFAULT_MAX_AGE_MS, EspnAdapter, type EspnAdapterOptions, FLAVOR_LEVELS, FakeMarketProvider, type FakeMarketProviderOptions, type FavoriteStrength, type FlavorLevel, type FormatOpts, type LedgerRow, type LiveResult, type MapContext, type MarketFavorite, type MarketMapping, type MarketMappingTable, type MarketOutcome, type MarketOutcomeKind, type MarketProvider, type MarketSignal, type MarketSignalOptions, type MarketSignalsResult, type Match, type MatchEvent, type Outcome, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, type PunditPick, SHARE_DISCLAIMER, SHARE_HASHTAG, type ShareSnippetInput, type ShareSnippetOptions, type ShareStyle, type Stage, type StandingRow, type Status, type Team, allFixtures, asFlavorLevel, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, deriveFavorite, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, flagEmoji, formatDate, formatKickoff, formatShareSnippet, formatTime, getLiveMatches, getMarketSignal, getMarketSignals, getMatchesForDate, groups, hasSaneDistribution, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isStaleSignal, isValidDate, isValidTimeZone, liveSourceLabel, localDate, makeAdapter, makeMarketProvider, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketLine, marketProbabilityText, marketSourceLabel, matchFlavor, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeOutcomes, outcomeFromScore, resolveCompetition, resolveMarketSource, resolveTz, scoreline };
908
+ export { type BuildSignalInput, DEFAULT_COMPETITION, DEFAULT_FLAVOR, DEFAULT_MAX_AGE_MS, EspnAdapter, type EspnAdapterOptions, FLAVOR_LEVELS, FakeMarketProvider, type FakeMarketProviderOptions, type FavoriteStrength, type FlavorLevel, type FormatOpts, type GroupStandings, LIVE_WINDOW_MS, type LedgerRow, type LiveResult, type MapContext, type MarketFavorite, type MarketMapping, type MarketMappingTable, type MarketOutcome, type MarketOutcomeKind, type MarketProvider, type MarketSignal, type MarketSignalOptions, type MarketSignalsResult, type Match, type MatchByIdResult, type MatchEvent, type Outcome, PolymarketProvider, type PolymarketProviderOptions, type ProviderAdapter, type ProviderCapabilities, type PunditPick, SHARE_DISCLAIMER, SHARE_HASHTAG, type ShareSnippetInput, type ShareSnippetOptions, type ShareStyle, type ShareTableInput, type Stage, type StandingRow, type StandingsResult, type Status, type Team, allFixtures, asFlavorLevel, buildMarketSignal, byKickoff, competitionBase, computeStandings, countdown, currentOrNextFixtureForTeam, deriveFavorite, favoriteStrength, fixturesByDate, fixturesByGroup, fixturesByTeam, fixturesInLiveWindow, flagEmoji, formatDate, formatKickoff, formatShareSnippet, formatShareTable, formatTime, getLiveMatches, getMarketSignal, getMarketSignals, getMatchById, getMatchesForDate, getStandings, groups, hasSaneDistribution, isFinished, isFlavorLevel, isLive, isReliableMarketSignal, isStaleSignal, isValidDate, isValidTimeZone, liveSourceLabel, localDate, makeAdapter, makeMarketProvider, mapEspnEvent, mapsCleanly, marketAttributionText, marketBlock, marketFavoriteText, marketFixtureForTeam, marketLine, marketProbabilityText, marketRelevant, marketSourceLabel, matchFlavor, matchLocation, mergeLive, nationToFlag, nationToRegion, nextFixtureForTeam, normalizeOutcomes, outcomeFromScore, parseStandings, resolveCompetition, resolveMarketSource, resolveTz, scoreline };
package/dist/index.js CHANGED
@@ -2622,6 +2622,22 @@ function nextFixtureForTeam(code, opts = {}) {
2622
2622
  (m) => new Date(m.kickoff).getTime() >= from.getTime()
2623
2623
  );
2624
2624
  }
2625
+ var LIVE_WINDOW_MS = 140 * 6e4;
2626
+ function fixturesInLiveWindow(now = Date.now(), fixtures = SCHEDULE) {
2627
+ return fixtures.filter((m) => {
2628
+ const k = Date.parse(m.kickoff);
2629
+ return now >= k && now <= k + LIVE_WINDOW_MS;
2630
+ }).sort(byKickoff);
2631
+ }
2632
+ function currentOrNextFixtureForTeam(code, opts = {}) {
2633
+ const from = opts.from ?? /* @__PURE__ */ new Date();
2634
+ const nowMs = from.getTime();
2635
+ const inPlay = fixturesByTeam(code, opts.fixtures ?? SCHEDULE).find((m) => {
2636
+ const k = Date.parse(m.kickoff);
2637
+ return nowMs >= k && nowMs <= k + LIVE_WINDOW_MS;
2638
+ });
2639
+ return inPlay ?? nextFixtureForTeam(code, opts);
2640
+ }
2625
2641
  function groups(fixtures = SCHEDULE) {
2626
2642
  const set = /* @__PURE__ */ new Set();
2627
2643
  for (const m of fixtures) if (m.group) set.add(m.group);
@@ -2771,6 +2787,43 @@ function mapEspnEvent(ev, ctx = {}) {
2771
2787
  function toEspnDate(d) {
2772
2788
  return d.replace(/\D/g, "").slice(0, 8);
2773
2789
  }
2790
+ function statVal(stats, name) {
2791
+ const v = stats?.find((s) => s.name === name)?.value;
2792
+ return typeof v === "number" && Number.isFinite(v) ? Math.round(v) : 0;
2793
+ }
2794
+ function entryToRow(e) {
2795
+ return {
2796
+ team: toTeam(e.team),
2797
+ played: statVal(e.stats, "gamesPlayed"),
2798
+ won: statVal(e.stats, "wins"),
2799
+ drawn: statVal(e.stats, "ties"),
2800
+ lost: statVal(e.stats, "losses"),
2801
+ goalsFor: statVal(e.stats, "pointsFor"),
2802
+ goalsAgainst: statVal(e.stats, "pointsAgainst"),
2803
+ goalDiff: statVal(e.stats, "pointDifferential"),
2804
+ points: statVal(e.stats, "points")
2805
+ };
2806
+ }
2807
+ function parseStandings(data) {
2808
+ const out = [];
2809
+ for (const child of data.children ?? []) {
2810
+ const letter = (child.name ?? child.abbreviation ?? "").match(/Group\s+([A-L])/i)?.[1]?.toUpperCase();
2811
+ if (!letter) continue;
2812
+ const ranked = (child.standings?.entries ?? []).map((e) => ({
2813
+ row: entryToRow(e),
2814
+ rank: statVal(e.stats, "rank")
2815
+ }));
2816
+ ranked.sort((a, b) => {
2817
+ if (a.rank && b.rank && a.rank !== b.rank) return a.rank - b.rank;
2818
+ const r = a.row;
2819
+ const s = b.row;
2820
+ return s.points - r.points || s.goalDiff - r.goalDiff || s.goalsFor - r.goalsFor || r.team.code.localeCompare(s.team.code);
2821
+ });
2822
+ out.push({ group: letter, rows: ranked.map((x) => x.row) });
2823
+ }
2824
+ out.sort((a, b) => a.group.localeCompare(b.group));
2825
+ return out;
2826
+ }
2774
2827
  var EspnAdapter = class {
2775
2828
  constructor(opts = {}) {
2776
2829
  this.opts = opts;
@@ -2790,25 +2843,30 @@ var EspnAdapter = class {
2790
2843
  const today = await this.fetchScoreboard();
2791
2844
  return today.filter((m) => m.status === "LIVE" || m.status === "HT");
2792
2845
  }
2846
+ /** Standings endpoint URL (lives under apis/v2, not site/v2; derived from base). */
2847
+ standingsUrl() {
2848
+ const base = this.opts.baseUrl ?? DEFAULT_BASE;
2849
+ return `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
2850
+ }
2851
+ /**
2852
+ * Authoritative, cumulative group tables from the standings endpoint. Throws
2853
+ * on fetch/parse failure (the caller decides the fallback). Group-stage only:
2854
+ * non-group `children` are filtered out by {@link parseStandings}.
2855
+ */
2856
+ async fetchStandings() {
2857
+ return parseStandings(await this.get(this.standingsUrl()));
2858
+ }
2793
2859
  /**
2794
2860
  * Build (and cache) a team-code -> group-letter map from the standings
2795
- * endpoint. Best-effort: returns {} if standings are unavailable.
2861
+ * endpoint. Best-effort: returns {} if standings are unavailable. Reuses the
2862
+ * same parse as {@link fetchStandings}, so the two never drift.
2796
2863
  */
2797
2864
  async fetchGroupMap(force = false) {
2798
2865
  if (this.groupMap && !force) return this.groupMap;
2799
- const base = this.opts.baseUrl ?? DEFAULT_BASE;
2800
- const standingsUrl = `${base.replace("/apis/site/v2/", "/apis/v2/")}/standings`;
2801
2866
  const map = {};
2802
2867
  try {
2803
- const data = await this.get(standingsUrl);
2804
- for (const child of data.children ?? []) {
2805
- const letter = (child.name ?? child.abbreviation ?? "").match(/Group\s+([A-L])/i)?.[1]?.toUpperCase();
2806
- if (!letter) continue;
2807
- for (const e of child.standings?.entries ?? []) {
2808
- const code = e.team?.abbreviation?.toUpperCase();
2809
- if (code) map[code] = letter;
2810
- }
2811
- }
2868
+ const tables = parseStandings(await this.get(this.standingsUrl()));
2869
+ for (const t of tables) for (const r of t.rows) map[r.team.code] = t.group;
2812
2870
  } catch {
2813
2871
  }
2814
2872
  this.groupMap = map;
@@ -2881,10 +2939,53 @@ async function getMatchesForDate(adapter, dateISO) {
2881
2939
  return { matches: base, degraded: true };
2882
2940
  }
2883
2941
  }
2942
+ async function getStandings(adapter, group) {
2943
+ const want = group?.toUpperCase();
2944
+ if (adapter.fetchStandings) {
2945
+ try {
2946
+ const all = await adapter.fetchStandings();
2947
+ const tables2 = (want ? all.filter((t) => t.group === want) : all).sort(
2948
+ (a, b) => a.group.localeCompare(b.group)
2949
+ );
2950
+ return { tables: tables2, degraded: false, source: adapter.name };
2951
+ } catch {
2952
+ }
2953
+ }
2954
+ const letters = want ? [want] : groups();
2955
+ const tables = letters.map((g) => ({ group: g, rows: computeStandings(fixturesByGroup(g)) })).filter((t) => t.rows.length > 0);
2956
+ return { tables, degraded: true };
2957
+ }
2884
2958
  function shiftUtcDate(dateISO, days) {
2885
2959
  const [y, m, d] = dateISO.slice(0, 10).split("-").map(Number);
2886
2960
  return new Date(Date.UTC(y ?? 1970, (m ?? 1) - 1, (d ?? 1) + days)).toISOString().slice(0, 10);
2887
2961
  }
2962
+ var EXTRA_TIME_SLACK_MS = 60 * 6e4;
2963
+ async function marketFixtureForTeam(adapter, code, now = /* @__PURE__ */ new Date()) {
2964
+ const nowMs = now.getTime();
2965
+ const candidate = fixturesByTeam(code).find((m) => {
2966
+ const k = Date.parse(m.kickoff);
2967
+ return nowMs >= k && nowMs <= k + LIVE_WINDOW_MS + EXTRA_TIME_SLACK_MS;
2968
+ });
2969
+ if (candidate) {
2970
+ const r = await getMatchById(adapter, candidate.id);
2971
+ const m = r.match ?? candidate;
2972
+ if (!isFinished(m.status)) return { ...r, match: m };
2973
+ }
2974
+ const next = nextFixtureForTeam(code, { from: now });
2975
+ return { match: next, degraded: false };
2976
+ }
2977
+ async function getMatchById(adapter, id) {
2978
+ const base = allFixtures().find((m) => m.id === id);
2979
+ if (!base) return { match: void 0, degraded: false };
2980
+ const day = base.kickoff.slice(0, 10);
2981
+ try {
2982
+ const live = adapter.fetchWindow ? await adapter.fetchWindow(shiftUtcDate(day, -1), shiftUtcDate(day, 1)) : await adapter.fetchByDate(day);
2983
+ const hit = live.find((m) => m.id === id);
2984
+ return { match: hit ?? base, degraded: false, source: hit ? adapter.name : void 0 };
2985
+ } catch {
2986
+ return { match: base, degraded: true };
2987
+ }
2988
+ }
2888
2989
  async function getLiveMatches(adapter) {
2889
2990
  try {
2890
2991
  return { matches: await adapter.fetchLive(), degraded: false, source: adapter.name };
@@ -2895,6 +2996,12 @@ async function getLiveMatches(adapter) {
2895
2996
 
2896
2997
  // src/markets/normalize.ts
2897
2998
  var DEFAULT_MAX_AGE_MS = 15 * 6e4;
2999
+ function marketRelevant(match, now = /* @__PURE__ */ new Date()) {
3000
+ if (isLive(match.status)) return true;
3001
+ if (isFinished(match.status)) return false;
3002
+ const k = Date.parse(match.kickoff);
3003
+ return !Number.isFinite(k) || now.getTime() <= k + LIVE_WINDOW_MS;
3004
+ }
2898
3005
  function normalizeOutcomes(outcomes) {
2899
3006
  const sum = outcomes.reduce(
2900
3007
  (s, o) => s + (Number.isFinite(o.probability) && o.probability > 0 ? o.probability : 0),
@@ -3401,11 +3508,50 @@ function formatShareSnippet(input, options = {}) {
3401
3508
  blocks.push(card.join("\n"));
3402
3509
  }
3403
3510
  }
3511
+ blocks.push(
3512
+ shareFooter({
3513
+ source: input.source,
3514
+ installLine: input.installLine,
3515
+ includeHashtag,
3516
+ includeInstall
3517
+ })
3518
+ );
3519
+ return blocks.join("\n\n");
3520
+ }
3521
+ function shareFooter(opts) {
3404
3522
  const footer = [];
3405
- if (input.source) footer.push(`Live data: ${liveSourceLabel(input.source)}`);
3406
- footer.push([includeHashtag ? SHARE_HASHTAG : "", SHARE_DISCLAIMER].filter(Boolean).join(" \xB7 "));
3407
- if (includeInstall && input.installLine) footer.push(`Try it: ${input.installLine}`);
3408
- blocks.push(footer.join("\n"));
3523
+ if (opts.source) footer.push(`Live data: ${liveSourceLabel(opts.source)}`);
3524
+ footer.push(
3525
+ [opts.includeHashtag ? SHARE_HASHTAG : "", SHARE_DISCLAIMER].filter(Boolean).join(" \xB7 ")
3526
+ );
3527
+ if (opts.includeInstall && opts.installLine) footer.push(`Try it: ${opts.installLine}`);
3528
+ return footer.join("\n");
3529
+ }
3530
+ function gd(n) {
3531
+ return n > 0 ? `+${n}` : `${n}`;
3532
+ }
3533
+ function tableRow(r, rank) {
3534
+ return `${rank}. ${r.team.flag} ${r.team.code} ${r.points} pts \xB7 ${r.won}-${r.drawn}-${r.lost} \xB7 ${gd(r.goalDiff)}`;
3535
+ }
3536
+ function formatShareTable(input, options = {}) {
3537
+ const includeHashtag = options.includeHashtag !== false;
3538
+ const includeInstall = options.includeInstallLine !== false;
3539
+ const blocks = [];
3540
+ if (input.tables.length === 0) {
3541
+ blocks.push(input.emptyNote ?? "No standings available.");
3542
+ } else {
3543
+ for (const { group, rows } of input.tables) {
3544
+ blocks.push(
3545
+ [`Group ${group} \xB7 standings`, "", ...rows.map((r, i) => tableRow(r, i + 1))].join("\n")
3546
+ );
3547
+ }
3548
+ if (input.degraded) {
3549
+ blocks.push("(Live standings unavailable \u2014 group roster, not live results.)");
3550
+ }
3551
+ }
3552
+ blocks.push(
3553
+ shareFooter({ source: input.source, installLine: input.installLine, includeHashtag, includeInstall })
3554
+ );
3409
3555
  return blocks.join("\n\n");
3410
3556
  }
3411
3557
  export {
@@ -3415,6 +3561,7 @@ export {
3415
3561
  EspnAdapter,
3416
3562
  FLAVOR_LEVELS,
3417
3563
  FakeMarketProvider,
3564
+ LIVE_WINDOW_MS,
3418
3565
  PolymarketProvider,
3419
3566
  SHARE_DISCLAIMER,
3420
3567
  SHARE_HASHTAG,
@@ -3425,20 +3572,25 @@ export {
3425
3572
  competitionBase,
3426
3573
  computeStandings,
3427
3574
  countdown,
3575
+ currentOrNextFixtureForTeam,
3428
3576
  deriveFavorite,
3429
3577
  favoriteStrength,
3430
3578
  fixturesByDate,
3431
3579
  fixturesByGroup,
3432
3580
  fixturesByTeam,
3581
+ fixturesInLiveWindow,
3433
3582
  flagEmoji,
3434
3583
  formatDate,
3435
3584
  formatKickoff,
3436
3585
  formatShareSnippet,
3586
+ formatShareTable,
3437
3587
  formatTime,
3438
3588
  getLiveMatches,
3439
3589
  getMarketSignal,
3440
3590
  getMarketSignals,
3591
+ getMatchById,
3441
3592
  getMatchesForDate,
3593
+ getStandings,
3442
3594
  groups,
3443
3595
  hasSaneDistribution,
3444
3596
  isFinished,
@@ -3457,8 +3609,10 @@ export {
3457
3609
  marketAttributionText,
3458
3610
  marketBlock,
3459
3611
  marketFavoriteText,
3612
+ marketFixtureForTeam,
3460
3613
  marketLine,
3461
3614
  marketProbabilityText,
3615
+ marketRelevant,
3462
3616
  marketSourceLabel,
3463
3617
  matchFlavor,
3464
3618
  matchLocation,
@@ -3468,6 +3622,7 @@ export {
3468
3622
  nextFixtureForTeam,
3469
3623
  normalizeOutcomes,
3470
3624
  outcomeFromScore,
3625
+ parseStandings,
3471
3626
  resolveCompetition,
3472
3627
  resolveMarketSource,
3473
3628
  resolveTz,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claudinho/core",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Domain model, provider adapters (ESPN), standings, bundled schedule, and the read-only Polymarket market-signal sidecar powering Claudinho. Not affiliated with FIFA or Anthropic.",
5
5
  "type": "module",
6
6
  "license": "MIT",