@v4/ecal

The ECAL “Add to calendar” integration: the one place ECAL's widget loads, our own sync buttons and the Games page's schedule picker.

2026-11-07

Overview

@v4/ecal is the "Add to calendar" integration with ECAL. It is the only place ECAL's widget script (https://sync.ecal.com/v2/ecal.widget.js) is loaded, so the vendor can be swapped in one place. Club-only, with no runtime dependencies (React is a peer); how it sits among the club's other third-party seams is in Club integrations.

ECAL draws its own pop-up — schedule picker, calendar picker, confirmation — in a frame over the page. The site supplies two things:

  • The button, styled as ours. It uses the embed code's "no-styling variation" (data-ecal-no-styling="true"); without it ECAL's widget empties the button and puts its own button image in its place.
  • Its own schedule picker first, where it knows the competition's teams. The Games page shows @v4/ghost-basketball's EcalSchedulePicker ("All games" or one or more teams) and opens ECAL's pop-up already narrowed to that choice, by passing the matching ECAL categories.

Entry points

@v4/ecal — pure and server-safe (src/index.ts)

  • Button (src/button.ts) — ECAL_SCRIPT_URL; ECAL_BUTTON_CLASS (ecal-sync-widget-button, the class the widget finds its buttons by); ecalButtonAttributes(display) → data-ecal-widget-id, data-ecal-category, data-ecal-no-styling="true".
  • Categories (src/categories.ts) — ECAL filters its schedule page by data-ecal-category, a comma-separated list of category paths. ECAL Admin files every team's schedule as Fixture/<country>/<team name>, and ECAL substitutes {{ECAL_USER_COUNTRYCODE}} with the visitor's country itself.
    • ECAL_TEAM_PLACEHOLDER ({{team}}), ECAL_DEFAULT_TEAM_CATEGORY (Fixture/{{ECAL_USER_COUNTRYCODE}}/{{team}}).
    • allGamesCategory(display), teamCategory(display, team) (spelt as ECAL names the team via teamNames, else as the engine does), scheduleCategories(display, choice) (one category for all games, one per chosen team), ecalCategoryList (comma-joins them).
    • Types EcalScheduleChoice ({ kind: "all" } or { kind: "teams", teams }), EcalTeamRef (code, name).
  • Settings (src/settings.ts) — types only:
    • EcalSettings — apiKey and displays, keyed by competition section slug (euroleague, eurocup). A competition with no display draws no button.
    • EcalDisplay — widgetId, category, optional teamCategory, teamNames (engine club code → ECAL's spelling, only where they differ) and logo (the competition's square mark for the picker's header).

@v4/ecal/client — "use client" (src/client/index.ts)

The client boundary sits on the entry itself, so a server layout can render the provider.

  • EcalProvider / useEcalSettings — carries the settings to every button. undefined settings, or a blank key, is OFF; the key is trimmed, and the value is held stable by value across server re-renders.
  • useEcalButton(competition) → EcalButtonProps | undefined — { className, nativeProps }: ECAL's class and data attributes, applied to one of our buttons to make it an ECAL sync button. The hook loads ECAL (or asks it to rescan) once the button is mounted. undefined means draw no button: ECAL is off or the competition has no display.
  • useEcalLauncher(competition) → EcalLauncher | undefined (types EcalLaunch, EcalLauncher; attribute ECAL_LAUNCHER_ATTRIBUTE, data-ecal-launcher). It returns the competition's display, a hidden anchor — a bound sync button, never in the tab order, rendered once outside any dialog — and launch({ categories }), which sets the categories on the anchor and presses it. Call launch from the user's own click (the picker's Continue), never from an effect. A bound anchor is used rather than ECAL's JavaScript show() because the data attributes are ECAL's documented contract.

The binding gotcha

ECAL's widget (v2.011, read 28 Oct 2026) finds its buttons once, with a single query at start-up — no observer, no delegated listener. So a button that arrives later (a page reached by in-app navigation, a switch to another competition) is invisible to it until the widget is asked to look again.

bindEcalButtons (src/client/load.ts, internal) handles both halves:

  • The first call adds ECAL's loader tag. ECAL's loader has no guard against running twice, so the tag is added once per page, and only ever from an effect, never during a render.
  • Later calls ask the running widget to rescan with window.EcalWidget.initButtons() and loadButtons(). While the widget is still starting there is nothing to ask, and its own start-up finds the button.

The widget reads the widget id when it binds a button and the other attributes when it is pressed, so the hooks re-bind whenever the competition's widget id changes.

Configuration

Variable Meaning
NEXT_PUBLIC_ECAL_API_KEY ECAL's key. Public by nature (it sits on the script tag). Unset or blank: OFF — no button is drawn and the script never loads

The displays are club facts in apps/web/club.config.ts (features.calendar = { provider: "ecal", apiKey: env.public("NEXT_PUBLIC_ECAL_API_KEY"), displays }), declared only when the key is set:

Competition widgetId category logo
euroleague 690b6b695512cc0008cbb8f6 Fixture/{{ECAL_USER_COUNTRYCODE}}/EuroLeague /brand/euroleague-mark.svg
eurocup 698a5563566b2500021b7b34 Fixture/{{ECAL_USER_COUNTRYCODE}}/BKT EuroCup — (a design question)

NextGen's display is still to come from ELB, so NextGen draws no button. No teamNames corrections are known yet. The schema (packages/template-config/src/schema.ts) rejects a blank key: omit the feature instead.

How the app wires it

File Role
apps/web/src/lib/ecal.ts The ONE config point, server-only: ecalSettings, or undefined when off. Client code reads useEcalSettings() instead, so club.config never reaches the browser bundle
apps/web/src/app/[locale]/layout.tsx Wraps the shell in <EcalProvider settings={ecalSettings}> when ECAL is on
packages/basketball-app/src/games/games-shell.tsx The Games page: useEcalButton, useEcalLauncher and scheduleCategories, with EcalSchedulePicker from @v4/ghost-basketball/ecal-schedule-picker. Without the competition's teams it falls back to the plain button and ECAL's own picker
apps/web/next.config.ts Listed in transpilePackages (raw TS source, no build)

The picker's UI — ecal-all-games, ecal-team-option, ecal-schedule-picker in @v4/ghost-basketball — is built from the client's concept file ELB – Web – EXT, whose frames carry no Token Studio data, so their theme leaves are inferred.

Scripts and tests

test / test:watch (vitest, jsdom), lint, check-types. Suites: button, categories, client/load, client/use-ecal-button, client/use-ecal-launcher, and client/ssr (a server render of the client entry).