class MatchesWidget extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.isInitialized = false;
this.resizeObserver = null;
this.state = {
currentMatchDate: new Date(),
visiblePickerMonth: new Date(),
isDatePickerOpen: false,
matches: [],
loading: false,
error: null,
};
this.state.currentMatchDate.setHours(0, 0, 0, 0);
this.state.visiblePickerMonth = new Date(this.state.currentMatchDate.getFullYear(), this.state.currentMatchDate.getMonth(), 1);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleDocumentKeydown = this.handleDocumentKeydown.bind(this);
}
connectedCallback() {
if (this.isInitialized) {
return;
}
this.isInitialized = true;
this.renderShell();
this.appContainerElement = this.shadowRoot.querySelector(".app-container");
this.matchDateElement = this.shadowRoot.getElementById("match-date");
this.datePickerElement = this.shadowRoot.getElementById("match-date-picker");
this.datePickerAnchorElement = this.shadowRoot.querySelector(".matches-date-picker-anchor");
this.matchesContentElement = this.shadowRoot.getElementById("matches-content");
this.previousDateButton = this.shadowRoot.querySelector('[data-direction="previous"]');
this.nextDateButton = this.shadowRoot.querySelector('[data-direction="next"]');
if (
!this.appContainerElement ||
!this.matchDateElement ||
!this.datePickerElement ||
!this.datePickerAnchorElement ||
!this.matchesContentElement
) {
return;
}
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
this.dateFormatter = new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: browserTimeZone,
});
this.timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
timeZone: browserTimeZone,
});
this.monthFormatter = new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
timeZone: browserTimeZone,
});
this.weekdayFormatter = new Intl.DateTimeFormat(undefined, {
weekday: "short",
timeZone: browserTimeZone,
});
this.matchDateElement.addEventListener("click", () => {
this.toggleDatePicker();
});
this.datePickerElement.addEventListener("click", (event) => {
this.handleDatePickerClick(event);
});
document.addEventListener("click", this.handleDocumentClick);
document.addEventListener("keydown", this.handleDocumentKeydown);
this.matchesContentElement.addEventListener("click", (event) => {
if (event.target.closest(".match-broadcast-link")) {
return;
}
const matchCard = event.target.closest(".match-card");
if (!matchCard) {
return;
}
this.toggleMatchCard(matchCard);
});
this.matchesContentElement.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
if (event.target.closest(".match-broadcast-link")) {
return;
}
const matchCard = event.target.closest(".match-card");
if (!matchCard) {
return;
}
event.preventDefault();
this.toggleMatchCard(matchCard);
});
this.previousDateButton?.addEventListener("click", () => {
const previousDate = new Date(this.state.currentMatchDate);
previousDate.setDate(previousDate.getDate() - 1);
this.setCurrentMatchDate(previousDate);
this.fetchUpcomingMatches();
});
this.nextDateButton?.addEventListener("click", () => {
const nextDate = new Date(this.state.currentMatchDate);
nextDate.setDate(nextDate.getDate() + 1);
this.setCurrentMatchDate(nextDate);
this.fetchUpcomingMatches();
});
if (typeof ResizeObserver !== "undefined") {
this.resizeObserver = new ResizeObserver(() => {
this.updateExpandedHeights();
});
this.resizeObserver.observe(this.appContainerElement);
}
this.renderMatchDate();
this.renderDatePicker();
this.renderMatches();
this.fetchUpcomingMatches();
}
disconnectedCallback() {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
document.removeEventListener("click", this.handleDocumentClick);
document.removeEventListener("keydown", this.handleDocumentKeydown);
}
escapeHtml(value = "") {
return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
}
formatApiDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}T00:00:00Z`;
}
getSelectedDayRequestRange() {
const startDate = new Date(this.state.currentMatchDate);
startDate.setHours(0, 0, 0, 0);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 1);
return { startDate, endDate };
}
normalizeHexColor(color) {
if (!color || typeof color !== "string") {
return null;
}
return color.replace(/^0x[0-9a-fA-F]{2}/, "#");
}
normalizeBroadcastLink(value) {
if (typeof value !== "string") {
return null;
}
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
try {
const parsedUrl = new URL(trimmedValue);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
return null;
}
return parsedUrl.toString();
} catch {
return null;
}
}
getTeamColorStyle(team) {
const primaryColor = team?.primary_color_hex || "#d9d9d9";
const secondaryColor = team?.secondary_color_hex || primaryColor;
return `background: linear-gradient(to right, ${primaryColor} 50%, ${secondaryColor} 50%);`;
}
normalizeMatch(match) {
console.log("Normalizing match:", match);
return {
id: match._id,
//venueName: match.pitch?.name || match.club_name || "Unknown venue",
tournamentName: match.tournament_name || "Unknown tournament",
venueName: match.club_name || "Unknown venue",
clubLogo: match.club_logo,
broadcastLink: this.normalizeBroadcastLink(match.broadcast_link),
startTimeLabel: match.start_time ? this.timeFormatter.format(new Date(match.start_time)) : "TBD",
scoreOne: match.score_one,
scoreTwo: match.score_two,
teamOne: {
name: match.team_one?.name || "Team One",
primary_color_hex: this.normalizeHexColor(match.team_one?.primary_color),
secondary_color_hex: this.normalizeHexColor(match.team_one?.secondary_color),
players: match.team_one?.players || [],
},
teamTwo: {
name: match.team_two?.name || "Team Two",
primary_color_hex: this.normalizeHexColor(match.team_two?.primary_color),
secondary_color_hex: this.normalizeHexColor(match.team_two?.secondary_color),
players: match.team_two?.players || [],
},
};
}
renderPlayerList(players = []) {
if (!players.length) {
return `
No lineup available
-
`;
}
return players
.map(
(player) => `
${this.escapeHtml(player.name || "Unknown player")}
${this.escapeHtml(player.handicap ?? "-")}
`,
)
.join("");
}
calculateTeamTotal(players = []) {
return players.reduce((total, player) => total + (Number(player?.handicap) || 0), 0);
}
renderMatchTeamTotal(players = []) {
if (!players.length) {
return "";
}
const total = this.calculateTeamTotal(players);
return `
Total
${this.escapeHtml(total)}
`;
}
renderMatchTeamSection(team) {
return `
${this.renderPlayerList(team.players)}
${this.renderMatchTeamTotal(team.players)}
`;
}
renderBroadcastAction(match, showBroadcastColumn) {
if (!showBroadcastColumn) {
return "";
}
if (!match.broadcastLink) {
return '';
}
return `
`;
}
renderBroadcastBalanceSlot(showBroadcastColumn) {
if (!showBroadcastColumn) {
return "";
}
return '';
}
renderMatchCard(match, showBroadcastColumn = false) {
const matchCardClassName = showBroadcastColumn ? "match-card match-card--with-broadcast-column" : "match-card";
const scoreSummaryClassName = showBroadcastColumn ? "score-summary score-summary--with-broadcast" : "score-summary";
return `
${this.renderBroadcastBalanceSlot(showBroadcastColumn)}
${this.renderBroadcastAction(match, showBroadcastColumn)}
${this.renderMatchTeamSection(match.teamOne)}
${this.renderMatchTeamSection(match.teamTwo)}
`;
}
renderMatchDate() {
this.matchDateElement.textContent = this.dateFormatter.format(this.state.currentMatchDate);
this.matchDateElement.setAttribute("aria-expanded", String(this.state.isDatePickerOpen));
}
setCurrentMatchDate(date) {
const nextDate = new Date(date);
nextDate.setHours(0, 0, 0, 0);
this.state.currentMatchDate = nextDate;
this.state.visiblePickerMonth = new Date(nextDate.getFullYear(), nextDate.getMonth(), 1);
this.renderMatchDate();
this.renderDatePicker();
}
toggleDatePicker() {
if (this.state.isDatePickerOpen) {
this.closeDatePicker();
return;
}
this.openDatePicker();
}
openDatePicker() {
this.state.visiblePickerMonth = new Date(this.state.currentMatchDate.getFullYear(), this.state.currentMatchDate.getMonth(), 1);
this.state.isDatePickerOpen = true;
this.renderDatePicker();
}
closeDatePicker() {
if (!this.state.isDatePickerOpen) {
return;
}
this.state.isDatePickerOpen = false;
this.renderDatePicker();
}
handleDocumentClick(event) {
if (!this.state.isDatePickerOpen) {
return;
}
if (event.composedPath().includes(this.datePickerAnchorElement)) {
return;
}
this.closeDatePicker();
}
handleDocumentKeydown(event) {
if (event.key !== "Escape" || !this.state.isDatePickerOpen) {
return;
}
this.closeDatePicker();
this.matchDateElement?.focus();
}
handleDatePickerClick(event) {
const actionButton = event.target.closest("[data-date-action]");
if (actionButton?.dataset.dateAction === "today") {
const today = new Date();
today.setHours(0, 0, 0, 0);
this.setCurrentMatchDate(today);
this.closeDatePicker();
this.fetchUpcomingMatches();
return;
}
const monthButton = event.target.closest("[data-calendar-direction]");
if (monthButton) {
const monthOffset = monthButton.dataset.calendarDirection === "previous" ? -1 : 1;
this.state.visiblePickerMonth = new Date(
this.state.visiblePickerMonth.getFullYear(),
this.state.visiblePickerMonth.getMonth() + monthOffset,
1,
);
this.renderDatePicker();
return;
}
const dateButton = event.target.closest("[data-date-value]");
if (!dateButton) {
return;
}
const selectedDate = this.parseDateValue(dateButton.dataset.dateValue);
if (!selectedDate) {
return;
}
this.setCurrentMatchDate(selectedDate);
this.closeDatePicker();
this.fetchUpcomingMatches();
}
parseDateValue(dateValue) {
if (!dateValue) {
return null;
}
const [year, month, day] = dateValue.split("-").map((value) => Number.parseInt(value, 10));
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day);
}
formatDateValue(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
isSameDate(firstDate, secondDate) {
return (
firstDate.getFullYear() === secondDate.getFullYear() &&
firstDate.getMonth() === secondDate.getMonth() &&
firstDate.getDate() === secondDate.getDate()
);
}
getWeekdayLabels() {
const sunday = new Date(2024, 0, 7);
return Array.from({ length: 7 }, (_, index) => {
const weekdayDate = new Date(sunday);
weekdayDate.setDate(sunday.getDate() + index);
return this.weekdayFormatter.format(weekdayDate);
});
}
getCalendarDays() {
const year = this.state.visiblePickerMonth.getFullYear();
const month = this.state.visiblePickerMonth.getMonth();
const firstDayOfMonth = new Date(year, month, 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const leadingEmptyDays = firstDayOfMonth.getDay();
const calendarDays = [];
for (let index = 0; index < leadingEmptyDays; index += 1) {
calendarDays.push(null);
}
for (let day = 1; day <= daysInMonth; day += 1) {
calendarDays.push(new Date(year, month, day));
}
while (calendarDays.length % 7 !== 0) {
calendarDays.push(null);
}
return calendarDays;
}
renderDatePickerDay(date) {
if (!date) {
return '';
}
const today = new Date();
today.setHours(0, 0, 0, 0);
const isSelected = this.isSameDate(date, this.state.currentMatchDate);
const isToday = this.isSameDate(date, today);
const classNames = ["date-picker-day-button"];
if (isSelected) {
classNames.push("is-selected");
}
if (isToday) {
classNames.push("is-today");
}
return `
`;
}
renderDatePicker() {
if (!this.datePickerElement) {
return;
}
this.matchDateElement?.setAttribute("aria-expanded", String(this.state.isDatePickerOpen));
if (!this.state.isDatePickerOpen) {
this.datePickerElement.hidden = true;
this.datePickerElement.innerHTML = "";
return;
}
const weekdayLabels = this.getWeekdayLabels();
const calendarDays = this.getCalendarDays();
this.datePickerElement.hidden = false;
this.datePickerElement.innerHTML = `
${weekdayLabels.map((label) => `${this.escapeHtml(label)}`).join("")}
${calendarDays.map((date) => this.renderDatePickerDay(date)).join("")}
`;
}
renderMatches() {
if (this.state.loading) {
this.matchesContentElement.innerHTML = 'Loading matches...
';
return;
}
if (this.state.error) {
this.matchesContentElement.innerHTML = `${this.escapeHtml(this.state.error)}
`;
return;
}
if (!this.state.matches.length) {
this.matchesContentElement.innerHTML = 'No matches scheduled for this date.
';
return;
}
const showBroadcastColumn = this.state.matches.some((match) => Boolean(match.broadcastLink));
this.matchesContentElement.innerHTML = this.state.matches.map((match) => this.renderMatchCard(match, showBroadcastColumn)).join("");
}
isMatchOnSelectedDay(match) {
if (!match?.start_time) {
return false;
}
const matchStartDate = new Date(match.start_time);
if (Number.isNaN(matchStartDate.getTime())) {
return false;
}
return this.isSameDate(matchStartDate, this.state.currentMatchDate);
}
async fetchUpcomingMatches() {
const { startDate, endDate } = this.getSelectedDayRequestRange();
const requestUrl = new URL("https://api.lineuppolo.com/api/v2/widgetUpcomingMatches");
requestUrl.searchParams.set("start_date", this.formatApiDate(startDate));
requestUrl.searchParams.set("end_date", this.formatApiDate(endDate));
this.state.loading = true;
this.state.error = null;
this.renderMatches();
try {
const response = await fetch(requestUrl);
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
console.log("Upcoming matches response:", data);
const matches = data?.result?.result || [];
this.state.matches = matches.filter((match) => this.isMatchOnSelectedDay(match)).map((match) => this.normalizeMatch(match));
} catch (error) {
console.error("Failed to fetch upcoming matches:", error);
this.state.matches = [];
this.state.error = "Unable to load matches right now.";
} finally {
this.state.loading = false;
this.renderMatches();
}
}
toggleMatchCard(matchCard) {
const teamsContainer = matchCard?.querySelector(".teams-container");
if (!teamsContainer) {
return;
}
const isExpanded = !teamsContainer.classList.contains("is-expanded");
if (isExpanded) {
teamsContainer.classList.add("is-expanded");
teamsContainer.style.maxHeight = `${teamsContainer.scrollHeight}px`;
} else {
teamsContainer.style.maxHeight = "0px";
teamsContainer.classList.remove("is-expanded");
}
matchCard.setAttribute("aria-expanded", String(isExpanded));
}
updateExpandedHeights() {
const expandedContainers = this.matchesContentElement.querySelectorAll(".teams-container.is-expanded");
expandedContainers.forEach((teamsContainer) => {
teamsContainer.style.maxHeight = `${teamsContainer.scrollHeight}px`;
});
}
renderShell() {
this.shadowRoot.innerHTML = `
`;
}
}
customElements.define("matches-widget", MatchesWidget);