MediaWiki:Common.js: Difference between revisions

From zunagi

mNo edit summary
mNo edit summary
 
(133 intermediate revisions by the same user not shown)
Line 1: Line 1:
/* Any JavaScript here will be loaded for all users on every page load. */
/* ==========================================================
  ZUNAGI BEACH VOLLEYBALL CALENDAR
  ========================================================== */
 
$( function () {
$( function () {
    const form = document.getElementById( 'zunagi-search-form' );
    const box = document.getElementById( 'zunagi-search-suggestions' );


     if ( !form || !box ) {
    /*
    * IMPORTANT: this check runs BEFORE requesting the
    * DataTables module, not after. mw.loader.using() is a
    * client-side call that fetches the module over the
    * network the moment it runs, regardless of what
    * LocalSettings.php's addModules() gate decided
    * server-side - so checking for the table only inside
    * the .then() callback (after the module was already
    * requested) meant the full DataTables library was being
    * downloaded on every single page, even ones with no
    * calendar table at all, wasting bandwidth and defeating
    * the point of the category-based loading gate.
    */
 
    const table =
        document.querySelector( '.zunagi-calendar' );
 
     if ( !table ) {
         return;
         return;
     }
     }


     const input = form.querySelector(
 
         'input[type="search"], input[name="search"], input[name="searchText"]'
     mw.loader.using( 'ext.zunagi.datatables' ).then( function () {
 
        /* ==================================================
        * 1. FIX MEDIAWIKI HEADER
        * ==================================================
        */
 
        if ( !table.tHead ) {
 
            const firstRow =
                table.querySelector(
                    'tbody > tr:first-child'
                );
 
            if (
                firstRow &&
                firstRow.querySelectorAll( 'th' ).length > 0
            ) {
 
                const thead =
                    table.createTHead();
 
                thead.appendChild(
                    firstRow
                );
            }
         }
 
 
        /* ==================================================
        * 2. COLUMN MAP
        * ==================================================
        *
        * 0 = Month        hidden
        * 1 = Date
        * 2 = Org
        * 3 = Event
        * 4 = Region
        * 5 = Category
        * 6 = Level
        * 7 = Venue
        * 8 = Major Event  hidden
        * 9 = End Date    hidden - falls back to Date (col 1) when blank
        */
 
 
        const filterColumns = [
            0, // Month
            2, // Org
            4, // Region
            5  // Category
        ];
 
 
        const filterSelects = {};
 
        let searchInput = null;
        let majorEventCheckbox = null;
        let showPastCheckbox = null;
 
 
        /* ==================================================
        * 3. CELL HELPERS
        * ==================================================
        */
 
        function getCellFilterValue( cell ) {
 
            if ( !cell ) {
                return '';
            }
 
            const dataSearch =
                cell.getAttribute(
                    'data-search'
                );
 
            if ( dataSearch !== null ) {
                return dataSearch.trim();
            }
 
            return cell.textContent.trim();
        }
 
 
        function getCellOrderValue( cell ) {
 
            if ( !cell ) {
                return '';
            }
 
            const dataOrder =
                cell.getAttribute(
                    'data-order'
                );
 
            if ( dataOrder !== null ) {
                return dataOrder.trim();
            }
 
            return getCellFilterValue(
                cell
            );
        }
 
 
        /* ==================================================
        * 4. TODAY
        * ==================================================
        */
 
        function getLocalToday() {
 
            const now =
                new Date();
 
            const year =
                now.getFullYear();
 
            const month =
                String(
                    now.getMonth() + 1
                ).padStart(
                    2,
                    '0'
                );
 
            const day =
                String(
                    now.getDate()
                ).padStart(
                    2,
                    '0'
                );
 
            return (
                year +
                '-' +
                month +
                '-' +
                day
            );
        }
 
 
        /* ==================================================
        * 5. CACHE SOURCE ROWS
        * ==================================================
        */
 
        const sourceRows =
            Array.from(
                table.querySelectorAll(
                    'tbody > tr'
                )
            )
            .map(
                function ( row ) {
 
                    const cells =
                        Array.from(
                            row.querySelectorAll(
                                'td'
                            )
                        );
 
                    if ( cells.length !== 10 ) {  // was 9
 
                        console.warn(
                            'Zunagi calendar row contains ' +
                            cells.length +
                            ' cells; expected 10.',  // was 9
                            row
                        );
 
                        return null;
                    }
 
                    const date =
                        getCellOrderValue(
                            cells[ 1 ]
                        );
 
                    const endDate =
                        getCellOrderValue(
                            cells[ 9 ]
                        ) || date;
 
                    row.dataset.zunagiDate =
                        date;
 
                    row.dataset.zunagiEndDate =
                        endDate;
 
                    row.dataset.zunagiMajor =
                        getCellFilterValue(
                            cells[ 8 ]
                        );
 
                    return {
 
                        row: row,
                        month: getCellFilterValue( cells[ 0 ] ),
                        monthOrder: getCellOrderValue( cells[ 0 ] ),
                        date: date,
                        endDate: endDate,  // new
 
                        org:
                            getCellFilterValue(
                                cells[ 2 ]
                            ),
 
                        event:
                            getCellFilterValue(
                                cells[ 3 ]
                            ),
 
                        region:
                            getCellFilterValue(
                                cells[ 4 ]
                            ),
 
                        category:
                            getCellFilterValue(
                                cells[ 5 ]
                            ),
 
                        level:
                            getCellFilterValue(
                                cells[ 6 ]
                            ),
 
                        venue:
                            getCellFilterValue(
                                cells[ 7 ]
                            ),
 
                        majorEvent:
                            getCellFilterValue(
                                cells[ 8 ]
                            ),
 
                        searchText:
                            row.textContent
                                .replace(
                                    /\s+/g,
                                    ' '
                                )
                                .trim()
                                .toLowerCase()
                    };
                }
            )
            .filter(
                function ( row ) {
                    return row !== null;
                }
            );
 
 
        /* ==================================================
        * 6. PAST EVENT CLASSES
        * ==================================================
        */
 
        function applyPastEventClasses() {
 
            const today =
                getLocalToday();
 
            sourceRows.forEach(
                function ( rowData ) {
 
                    rowData.row.classList.toggle(
                        'zunagi-past-event',
                        Boolean(
                            rowData.endDate &&
                            rowData.endDate < today
                        )
                    );
                }
            );
        }
 
 
        /* ==================================================
        * 7. ORGANISATION COLOURS
        * ==================================================
        */
 
        function applyOrgColours( api ) {
 
            api.rows().every(
                function () {
 
                    const row =
                        this.node();
 
                    if ( !row ) {
                        return;
                    }
 
 
                    const orgCell =
                        api
                            .cell(
                                row,
                                2
                            )
                            .node();
 
                    if ( !orgCell ) {
                        return;
                    }
 
                    const org =
                        getCellFilterValue(
                            orgCell
                        );
 
 
                    orgCell.classList.remove(
                        'org-vnsw',
                        'org-qbvt',
                        'org-nbva',
                        'org-va',
                        'org-vsa',
                        'org-vv',
                        'org-vact',
                        'org-vwa'
                    );
 
 
                    if ( org === 'VNSW' ) {
 
                        orgCell.classList.add(
                            'org-vnsw'
                        );
 
                    } else if ( org === 'QBVT' ) {
 
                        orgCell.classList.add(
                            'org-qbvt'
                        );
 
                    } else if ( org === 'NBVA' ) {
 
                        orgCell.classList.add(
                            'org-nbva'
                        );
 
                    } else if ( org === 'VA' ) {
 
                        orgCell.classList.add(
                            'org-va'
                        );
 
                    } else if ( org === 'VSA' ) {
 
                        orgCell.classList.add(
                            'org-vsa'
                        );
 
                    } else if ( org === 'VACT' ) {
 
                        orgCell.classList.add(
                            'org-vact'
                        );
 
                    } else if ( org === 'VWA' ) {
 
                        orgCell.classList.add(
                            'org-vwa'
                        );
 
                    } else if ( org === 'VV' ) {
 
                        orgCell.classList.add(
                            'org-vv'
                        );
                    }
                }
            );
        }
 
 
/* ==================================================
* 8. MAJOR EVENT BADGES
* ==================================================
*/
 
function applyMajorEventBadges( api ) {
 
    api.rows().every(
        function () {
 
            const row =
                this.node();
 
            if ( !row ) {
                return;
            }
 
            const eventCell =
                api
                    .cell(
                        row,
                        3
                    )
                    .node();
 
            const majorCell =
                api
                    .cell(
                        row,
                        8
                    )
                    .node();
 
            if (
                !eventCell ||
                !majorCell
            ) {
                return;
            }
 
            /*
            * Remove any existing badge first.
            */
 
            eventCell
                .querySelectorAll(
                    '.zunagi-major-badge'
                )
                .forEach(
                    function ( badge ) {
                        badge.remove();
                    }
                );
 
            const isMajor =
                getCellFilterValue(
                    majorCell
                ) === 'Yes';
 
            if ( !isMajor ) {
                return;
            }
 
            const badge =
                document.createElement(
                    'span'
                );
 
            badge.className =
                'zunagi-major-badge';
 
            badge.textContent =
                'MAJOR';
 
            badge.style.marginLeft =
                '0';
 
            badge.style.marginRight =
                '6px';
 
            eventCell.insertBefore(
                badge,
                eventCell.querySelector(
                    'a'
                )
            );
        }
    );
}
 
/* ==================================================
* 8b. YOUTH EVENT BADGES
* ==================================================
*/
 
function applyYouthEventBadges( api ) {
 
    api.rows().every(
        function () {
 
            const row =
                this.node();
 
            if ( !row ) {
                return;
            }
 
            const eventCell =
                api
                    .cell(
                        row,
                        3
                    )
                    .node();
 
            const categoryCell =
                api
                    .cell(
                        row,
                        5
                    )
                    .node();
 
            if (
                !eventCell ||
                !categoryCell
            ) {
                return;
            }
 
            /*
            * Remove any existing badge first.
            */
 
            eventCell
                .querySelectorAll(
                    '.zunagi-youth-badge'
                )
                .forEach(
                    function ( badge ) {
                        badge.remove();
                    }
                );
 
            const isYouth =
                getCellFilterValue(
                    categoryCell
                ) === 'Youth';
 
            if ( !isYouth ) {
                return;
            }
 
            const badge =
                document.createElement(
                    'span'
                );
 
            badge.className =
                'zunagi-youth-badge';
 
            badge.textContent =
                'YOUTH';
 
            badge.style.marginLeft =
                '0';
 
            badge.style.marginRight =
                '6px';
 
            eventCell.insertBefore(
                badge,
                eventCell.querySelector(
                    'a'
                )
            );
        }
    );
}
 
/* ==================================================
* 8c. JUNIOR EVENT BADGES
* ==================================================
*/
 
function applyJuniorEventBadges( api ) {
 
    api.rows().every(
        function () {
 
            const row =
                this.node();
 
            if ( !row ) {
                return;
            }
 
            const eventCell =
                api
                    .cell(
                        row,
                        3
                    )
                    .node();
 
            const categoryCell =
                api
                    .cell(
                        row,
                        5
                    )
                    .node();
 
            if (
                !eventCell ||
                !categoryCell
            ) {
                return;
            }
 
            /*
            * Remove any existing badge first.
            */
 
            eventCell
                .querySelectorAll(
                    '.zunagi-junior-badge'
                )
                .forEach(
                    function ( badge ) {
                        badge.remove();
                    }
                );
 
            const isJunior =
                getCellFilterValue(
                    categoryCell
                ) === 'Junior';
 
            if ( !isJunior ) {
                return;
            }
 
            const badge =
                document.createElement(
                    'span'
                );
 
            badge.className =
                'zunagi-junior-badge';
 
            badge.textContent =
                'JUNIOR';
 
            badge.style.marginLeft =
                '0';
 
            badge.style.marginRight =
                '6px';
 
            eventCell.insertBefore(
                badge,
                eventCell.querySelector(
                    'a'
                )
            );
        }
     );
     );
}


     if ( !input ) {
/* ==================================================
* 8d. FILTER STATE PERSISTENCE (event-page round trip only)
* ==================================================
*
* Filters are remembered across exactly one kind of trip: leaving
* the calendar by clicking through to an event's own page, then
* coming back - either via that page's "Back to Calendar" link or
* the browser's own Back button. A direct or fresh visit to the
* calendar (Main Page, a bookmark, a new tab, a nav-menu click)
* never restores anything, because nothing is ever saved except at
* the moment of clicking an event link.
*/
 
const FILTER_STATE_KEY =
    'zunagiCalendarFilterState';
 
function saveFilterState() {
 
    const selects = {};
 
    filterColumns.forEach(
        function ( columnIndex ) {
 
            if ( filterSelects[ columnIndex ] ) {
 
                selects[ columnIndex ] =
                    filterSelects[ columnIndex ].value;
            }
        }
    );
 
    const payload = {
 
        selects: selects,
 
        major:
            Boolean(
                majorEventCheckbox &&
                majorEventCheckbox.checked
            ),
 
        past:
            Boolean(
                showPastCheckbox &&
                showPastCheckbox.checked
            ),
 
        search:
            searchInput ?
                searchInput.value :
                ''
    };
 
    try {
 
        sessionStorage.setItem(
            FILTER_STATE_KEY,
            JSON.stringify( payload )
        );
 
    } catch ( e ) {
 
        /*
        * Ignore - e.g. private-browsing storage restrictions.
        * Worst case the round trip just doesn't restore.
        */
    }
}
 
function restoreFilterState( api ) {
 
    let raw;
 
    try {
 
        raw =
            sessionStorage.getItem(
                FILTER_STATE_KEY
            );
 
    } catch ( e ) {
 
        return;
    }
 
     if ( !raw ) {
         return;
         return;
     }
     }


     let timer = null;
     /*
     let requestNumber = 0;
    * Consume it immediately - it must only ever apply to the one
    let selectedIndex = -1;
    * page load right after clicking an event link, never to any
     let results = [];
    * later visit.
    */
 
     try {
 
        sessionStorage.removeItem(
            FILTER_STATE_KEY
        );
 
     } catch ( e ) {}


     function closeSuggestions() {
     let state;
        box.innerHTML = '';
 
        box.classList.remove( 'is-open' );
    try {
         selectedIndex = -1;
 
         results = [];
        state =
            JSON.parse( raw );
 
    } catch ( e ) {
 
        return;
    }
 
    if ( !state ) {
        return;
    }
 
    filterColumns.forEach(
        function ( columnIndex ) {
 
            const value =
                state.selects &&
                state.selects[ columnIndex ];
 
            if ( !value ) {
                return;
            }
 
            if ( filterSelects[ columnIndex ] ) {
 
                filterSelects[ columnIndex ].value =
                    value;
            }
 
            api
                .column( columnIndex )
                .search(
                    value,
                    {
                        exact: true
                    }
                );
         }
    );
 
    if ( majorEventCheckbox ) {
 
         majorEventCheckbox.checked =
            Boolean( state.major );
     }
     }


     function openPage( title ) {
     if ( showPastCheckbox ) {
         window.location.href = mw.util.getUrl( title );
 
         showPastCheckbox.checked =
            Boolean( state.past );
     }
     }


     function stripHtml( html ) {
     if ( searchInput && state.search ) {
         const element = document.createElement( 'div' );
 
         element.innerHTML = html;
         searchInput.value =
        return element.textContent || '';
            state.search;
 
         api.search( state.search );
     }
     }
}


    function renderSuggestions( pages ) {
        box.innerHTML = '';
        results = pages;
        selectedIndex = -1;


        if ( !pages.length ) {
/* ==================================================
             closeSuggestions();
* 8e. SAVE FILTER STATE WHEN LEAVING FOR AN EVENT PAGE
* ==================================================
*/
 
table.addEventListener(
    'click',
    function ( event ) {
 
        const link =
             event.target.closest(
                'td.zunagi-event-column a'
            );
 
        if ( !link ) {
             return;
             return;
         }
         }


         pages.forEach( function ( page ) {
         saveFilterState();
             const link = document.createElement( 'a' );
    }
            const title = document.createElement( 'span' );
);
             const description = document.createElement( 'span' );
 
 
 
 
        /* ==================================================
        * 9. TABLE ADJUSTMENT
        * ==================================================
        */
 
        function adjustTable( api ) {
 
            requestAnimationFrame(
                function () {
 
                    api.columns.adjust();
 
                    rebuildFixedHeader();
                }
             );
        }
 
 
        /* ==================================================
        * 9b. FIXED HEADER (JS affix pattern)
        * ==================================================
        */
 
        let wrapperEl = null;
 
        let dtApi = null;
 
        let fixedHeaderOuter = null;
        let fixedHeaderInner = null;
 
        let fixedHeaderVisible = false;
 
 
        const FIXED_HEADER_TOP_OFFSET = 0;
 
 
        const DEBUG_FIXED_HEADER = false;
 
        let debugOverlay = null;
 
        function updateDebugOverlay() {
 
            if ( !DEBUG_FIXED_HEADER ) {
                return;
            }
 
            if ( !debugOverlay ) {
 
                debugOverlay =
                    document.createElement( 'div' );
 
                debugOverlay.style.position =
                    'fixed';
 
                debugOverlay.style.bottom =
                    '8px';
 
                debugOverlay.style.right =
                    '8px';
 
                debugOverlay.style.zIndex =
                    '99999';
 
                debugOverlay.style.background =
                    'rgba(0,0,0,0.75)';
 
                debugOverlay.style.color =
                    '#0f0';
 
                debugOverlay.style.font =
                    '11px monospace';
 
                debugOverlay.style.padding =
                    '4px 6px';
 
                debugOverlay.style.pointerEvents =
                    'none';
 
                debugOverlay.style.whiteSpace =
                    'pre';
 
                document.body.appendChild(
                    debugOverlay
                );
             }
 
            debugOverlay.textContent =
                'wrapperEl.scrollLeft: ' +
                ( wrapperEl ? wrapperEl.scrollLeft : 'n/a' ) +
                '\nfixedHeaderVisible: ' +
                fixedHeaderVisible;
        }
 
 
        function buildFixedHeader() {
 
            if ( !wrapperEl || !table.tHead ) {
                return;
            }
 
 
            if ( fixedHeaderOuter ) {
 
                fixedHeaderOuter.remove();
 
                fixedHeaderOuter = null;
                fixedHeaderInner = null;
            }
 
 
            let realThs;
 
            if ( dtApi ) {


            link.href = mw.util.getUrl( page.title );
                realThs =
            link.className = 'zunagi-search-suggestion';
                    dtApi
                        .columns( ':visible' )
                        .header()
                        .toArray();


             title.className = 'zunagi-search-suggestion-title';
             } else {
            title.textContent = page.title;


            description.className = 'zunagi-search-suggestion-description';
                realThs =
            description.textContent = stripHtml( page.snippet || '' );
                    Array.from(
                        table.tHead.querySelectorAll( 'th' )
                    ).filter(
                        function ( th ) {


            link.appendChild( title );
                            const rect =
                                th.getBoundingClientRect();


            if ( description.textContent ) {
                            return (
                link.appendChild( description );
                                rect.width > 0 &&
                                rect.height > 0
                            );
                        }
                    );
             }
             }


             box.appendChild( link );
             if ( realThs.length === 0 ) {
         } );
                return;
            }
 
 
            const theadRect =
                table.tHead.getBoundingClientRect();
 
            const tableRect =
                table.getBoundingClientRect();
 
 
            fixedHeaderInner =
                document.createElement( 'div' );
 
            fixedHeaderInner.className =
                'zunagi-fixed-header-inner ' +
                table.className;
 
            fixedHeaderInner.style.position =
                'relative';
 
            const headerHeight =
                Math.round( theadRect.height );
 
            fixedHeaderInner.style.height =
                headerHeight + 'px';
 
 
            let maxRight = 0;
 
            realThs.forEach(
                function ( th ) {
 
                    const rect =
                        th.getBoundingClientRect();
 
                    const left =
                        Math.round(
                            rect.left - tableRect.left
                        );
 
                    const width =
                        Math.round(
                            rect.right - tableRect.left
                        ) - left;
 
                    maxRight =
                        Math.max(
                            maxRight,
                            left + width
                        );
 
 
                    const cell =
                        th.cloneNode( true );
 
                    cell
                        .querySelectorAll(
                            '.dt-column-order'
                        )
                        .forEach(
                            function ( orderEl ) {
 
                                orderEl.remove();
                            }
                        );
 
 
                    const textAlign =
                        getComputedStyle( th )
                            .textAlign;
 
                    let justify =
                        'flex-start';
 
                    if ( textAlign === 'center' ) {
 
                        justify =
                            'center';
 
                    } else if (
                        textAlign === 'right' ||
                        textAlign === 'end'
                    ) {
 
                        justify =
                            'flex-end';
                    }
 
 
                    const innerHeader =
                        cell.querySelector(
                            '.dt-column-header'
                        );
 
                    if ( innerHeader ) {
 
                        innerHeader.style.display =
                            'flex';
 
                        innerHeader.style.justifyContent =
                            justify;
 
                        innerHeader.style.alignItems =
                            'center';
 
                        innerHeader.style.width =
                            '100%';
 
                        innerHeader.style.height =
                            '100%';
 
 
                        const sortDirection =
                            th.getAttribute( 'aria-sort' );
 
                        if (
                            sortDirection === 'ascending' ||
                            sortDirection === 'descending'
                        ) {
 
                            const arrow =
                                document.createElement(
                                    'span'
                                );
 
                            arrow.className =
                                'zunagi-fixed-header-sort-arrow';
 
                            arrow.setAttribute(
                                'data-direction',
                                sortDirection
                            );
 
                            innerHeader.insertBefore(
                                arrow,
                                innerHeader.firstChild
                            );
 
 
                            const titleEl =
                                innerHeader.querySelector(
                                    '.dt-column-title'
                                );
 
                            if ( titleEl ) {
 
                                titleEl.style.flex =
                                    '0 0 auto';
 
                                titleEl.style.width =
                                    'auto';
                            }
 
                            arrow.style.flex =
                                '0 0 auto';
                        }
                    }
 
 
                    cell.style.position =
                        'absolute';
 
                    cell.style.left =
                        left + 'px';
 
                    cell.style.top =
                        '0';
 
                    cell.style.setProperty(
                        'width',
                        width + 'px',
                        'important'
                    );
 
                    cell.style.setProperty(
                        'min-width',
                        width + 'px',
                        'important'
                    );
 
                    cell.style.setProperty(
                        'max-width',
                        width + 'px',
                        'important'
                    );
 
                    cell.style.height =
                        headerHeight + 'px';
 
                    cell.style.boxSizing =
                        'border-box';
 
                    cell.style.display =
                        'flex';
 
                    cell.style.alignItems =
                        'center';
 
                    cell.style.justifyContent =
                        justify;
 
 
                    fixedHeaderInner.appendChild(
                        cell
                    );
                }
            );
 
            fixedHeaderInner.style.width =
                maxRight + 'px';
 
 
            fixedHeaderOuter =
                document.createElement( 'div' );
 
            fixedHeaderOuter.className =
                'zunagi-fixed-header-outer';
 
            fixedHeaderOuter.appendChild(
                fixedHeaderInner
            );
 
 
            document.body.appendChild(
                fixedHeaderOuter
            );
 
 
            fixedHeaderVisible = false;
        }
 
 
        function positionFixedHeader() {
 
            if (
                !fixedHeaderOuter ||
                !wrapperEl
            ) {
                return;
            }
 
 
            const wrapperRect =
                wrapperEl.getBoundingClientRect();
 
            fixedHeaderOuter.style.left =
                wrapperRect.left + 'px';
 
            fixedHeaderOuter.style.width =
                wrapperRect.width + 'px';
 
            fixedHeaderOuter.style.top =
                FIXED_HEADER_TOP_OFFSET + 'px';
 
 
            if ( fixedHeaderInner ) {
 
                fixedHeaderInner.style.transform =
                    'translateX(-' +
                    Math.round( wrapperEl.scrollLeft ) +
                    'px)';
            }
        }
 
 
        function updateFixedHeaderVisibility() {
 
            if (
                !wrapperEl ||
                !fixedHeaderOuter ||
                !table.tHead
            ) {
                return;
            }
 
 
            const theadRect =
                table.tHead.getBoundingClientRect();
 
            const wrapperRect =
                wrapperEl.getBoundingClientRect();
 
 
            const shouldShow =
                theadRect.top < FIXED_HEADER_TOP_OFFSET &&
                wrapperRect.bottom >
                    FIXED_HEADER_TOP_OFFSET + 40;
 
 
            if (
                shouldShow &&
                !fixedHeaderVisible
            ) {
 
                fixedHeaderOuter.style.display =
                    'block';
 
                fixedHeaderVisible = true;
 
            } else if (
                !shouldShow &&
                fixedHeaderVisible
            ) {
 
                fixedHeaderOuter.style.display =
                    'none';
 
                fixedHeaderVisible = false;
            }
 
 
            if ( fixedHeaderVisible ) {
 
                positionFixedHeader();
            }
        }
 
 
        function rebuildFixedHeader() {
 
            const wasVisible =
                fixedHeaderVisible;
 
            buildFixedHeader();
 
            if (
                wasVisible &&
                fixedHeaderOuter
            ) {
 
                fixedHeaderOuter.style.display =
                    'block';
 
                fixedHeaderVisible = true;
 
                positionFixedHeader();
            }
        }
 
 
        function fixedHeaderSyncLoop() {
 
            updateFixedHeaderVisibility();
 
            updateDebugOverlay();
 
            requestAnimationFrame(
                fixedHeaderSyncLoop
            );
         }
 
 
        /* ==================================================
        * 10. INITIALISE DATATABLE
        * ==================================================
        */
 
        const dt =
            new DataTable(
                table,
                {
 
                    paging: false,
 
                    searching: true,
 
                    ordering: true,
 
                    info: false,
 
                    autoWidth: false,
 
 
                    order: [
                        [
                            1,
                            'asc'
                        ]
                    ],
 
 
                    columnDefs: [
 
                        /*
                        * Month - hidden/filterable.
                        */
 
                        {
                            targets: 0,
 
                            visible: false,
 
                            searchable: true,
 
                            orderable: false
                        },
 
 
                        /*
                        * Date.
                        */
 
                        {
                            targets: 1,
 
                            width: '95px',
 
                            orderable: true,
 
                            className:
                                'zunagi-date-column'
                        },
 
                        /*
                        * Organisation - hidden/filterable.
                        */
 
                        {
                            targets: 2,
 
                            visible: false,
 
                            searchable: true,
 
                            orderable: false
                        },
 
 
                        /*
                        * Event.
                        */
 
                        {
                            targets: 3,
 
                            width: '330px',
 
                            orderable: false,
 
                            className:
                                'zunagi-event-column'
                        },
 
 
                        /*
                        * Region.
                        */
 
                        {
                            targets: 4,
 
                            width: '85px',
 
                            orderable: false,
 
                            className:
                                'zunagi-region-column'
                        },
 
 
                        /*
                        * Category.
                        */
 
                        {
                            targets: 5,
 
                            width: '100px',
 
                            orderable: false,
 
                            className:
                                'zunagi-category-column'
                        },
 
 
                        /*
                        * Level.
                        */
 
                        {
                            targets: 6,
 
                            width: '85px',
 
                            orderable: false,
 
                            className:
                                'zunagi-level-column'
                        },
 
 
                        /*
                        * Venue.
                        */
 
                        {
                            targets: 7,
 
                            width: '200px',
 
                            orderable: false,
 
                            className:
                                'zunagi-venue-column'
                        },
 
 
                        /*
                        * Major Event - hidden/filterable.
                        */
 
                        {
                            targets: 8,
                            visible: false,
                            searchable: true,
                            orderable: false
                        },
 
                        {
                            targets: 9,
                            visible: false,
                            searchable: false,
                            orderable: false
                        }
                    ],
 
 
                    layout: {
 
                        topStart: null,
 
                        topEnd: null,
 
                        bottomStart: null,
 
                        bottomEnd: null
                    },
 
 
                    initComplete:
                        function () {
 
                            const api =
                                this.api();
 
                            dtApi = api;
 
 
                            const filterBar =
                                document.createElement(
                                    'div'
                                );
 
                            filterBar.className =
                                'zunagi-calendar-filters';
 
 
                            filterColumns.forEach(
                                function (
                                    columnIndex
                                ) {
 
                                    const column =
                                        api.column(
                                            columnIndex
                                        );
 
 
                                    const heading =
                                        column
                                            .header()
                                            .textContent
                                            .trim();
 
 
                                    const wrapper =
                                        document.createElement(
                                            'div'
                                        );
 
                                    wrapper.className =
                                        'zunagi-filter';
 
 
                                    const label =
                                        document.createElement(
                                            'label'
                                        );
 
                                    label.textContent =
                                        heading;
 
 
                                    const select =
                                        document.createElement(
                                            'select'
                                        );
 
 
                                    filterSelects[
                                        columnIndex
                                    ] = select;
 
 
                                    select.addEventListener(
                                        'change',
                                        function () {
 
                                            column
                                                .search(
                                                    this.value,
                                                    {
                                                        exact:
                                                            true
                                                    }
                                                )
                                                .draw();
                                        }
                                    );
 
 
                                    wrapper.appendChild(
                                        label
                                    );
 
                                    wrapper.appendChild(
                                        select
                                    );
 
                                    filterBar.appendChild(
                                        wrapper
                                    );
                                }
                            );
 
 
                            const majorWrapper =
                                document.createElement(
                                    'div'
                                );
 
                            majorWrapper.className =
                                'zunagi-filter zunagi-checkbox-filter';
 
 
                            const majorHeading =
                                document.createElement(
                                    'span'
                                );
 
                            majorHeading.className =
                                'zunagi-checkbox-heading';
 
                            majorHeading.textContent =
                                'Major Events';
 
 
                            const majorLabel =
                                document.createElement(
                                    'label'
                                );
 
                            majorLabel.className =
                                'zunagi-checkbox-label';
 
 
                            majorEventCheckbox =
                                document.createElement(
                                    'input'
                                );
 
                            majorEventCheckbox.type =
                                'checkbox';
 
                            majorEventCheckbox.checked =
                                false;
 
 
                            const majorText =
                                document.createElement(
                                    'span'
                                );
 
                            majorText.textContent =
                                'Show';
 
 
                            majorEventCheckbox.addEventListener(
                                'change',
                                function () {
 
                                    api.draw();
                                }
                            );
 
 
                            majorLabel.appendChild(
                                majorEventCheckbox
                            );
 
                            majorLabel.appendChild(
                                majorText
                            );
 
 
                            majorWrapper.appendChild(
                                majorHeading
                            );
 
                            majorWrapper.appendChild(
                                majorLabel
                            );
 
 
                            filterBar.appendChild(
                                majorWrapper
                            );
 
 
                            api.search.fixed(
                                'zunagiMajorEvents',
                                function (
                                    searchString,
                                    rowData,
                                    rowIndex
                                ) {
 
                                    if (
                                        !majorEventCheckbox ||
                                        !majorEventCheckbox.checked
                                    ) {
 
                                        return true;
                                    }
 
 
                                    const row =
                                        api
                                            .row(
                                                rowIndex
                                            )
                                            .node();
 
 
                                    if ( !row ) {
                                        return true;
                                    }
 
 
                                    return (
                                        row.dataset.zunagiMajor ===
                                        'Yes'
                                    );
                                }
                            );
 
 
                            const pastWrapper =
                                document.createElement(
                                    'div'
                                );
 
                            pastWrapper.className =
                                'zunagi-filter zunagi-checkbox-filter';
 
 
                            const pastHeading =
                                document.createElement(
                                    'span'
                                );
 
                            pastHeading.className =
                                'zunagi-checkbox-heading';
 
                            pastHeading.textContent =
                                'Past Events';
 
 
                            const pastLabel =
                                document.createElement(
                                    'label'
                                );
 
                            pastLabel.className =
                                'zunagi-checkbox-label';
 
 
                            showPastCheckbox =
                                document.createElement(
                                    'input'
                                );
 
                            showPastCheckbox.type =
                                'checkbox';
 
                            showPastCheckbox.checked =
                                false;
 
 
                            const pastText =
                                document.createElement(
                                    'span'
                                );
 
                            pastText.textContent =
                                'Show';
 
 
                            showPastCheckbox
                                .addEventListener(
                                    'change',
                                    function () {
 
                                        api.draw();
                                    }
                                );
 
 
                            pastLabel.appendChild(
                                showPastCheckbox
                            );
 
                            pastLabel.appendChild(
                                pastText
                            );
 
                            pastWrapper.appendChild(
                                pastHeading
                            );
 
                            pastWrapper.appendChild(
                                pastLabel
                            );
 
                            filterBar.appendChild(
                                pastWrapper
                            );
 
 
                            api.search.fixed(
                                'zunagiPastEvents',
                                function (
                                    searchString,
                                    rowData,
                                    rowIndex
                                ) {
 
                                    if (
                                        showPastCheckbox &&
                                        showPastCheckbox.checked
                                    ) {
 
                                        return true;
                                    }
 
 
                                    const row =
                                        api
                                            .row(
                                                rowIndex
                                            )
                                            .node();
 
 
                                    if ( !row ) {
                                        return true;
                                    }
 
 
                                    const eventEndDate =
                                        row.dataset.zunagiEndDate ||
                                        row.dataset.zunagiDate;
 
                                    if ( !eventEndDate ) {
                                        return true;
                                    }
 
                                    return (
                                        eventEndDate >= getLocalToday()
                                    );
                                }
                            );
 
 
                            const searchWrapper =
                                document.createElement(
                                    'div'
                                );
 
                            searchWrapper.className =
                                'zunagi-filter zunagi-search-filter';
 
 
                            const searchLabel =
                                document.createElement(
                                    'label'
                                );
 
                            searchLabel.textContent =
                                'Search';
 
 
                            searchInput =
                                document.createElement(
                                    'input'
                                );
 
                            searchInput.type =
                                'search';
 
                            searchInput.placeholder =
                                'Search events...';
 
 
                            searchInput.addEventListener(
                                'input',
                                function () {
 
                                    api
                                        .search(
                                            this.value
                                        )
                                        .draw();
                                }
                            );
 
 
                            searchWrapper.appendChild(
                                searchLabel
                            );
 
                            searchWrapper.appendChild(
                                searchInput
                            );
 
                            filterBar.appendChild(
                                searchWrapper
                            );
 
 
                            const resetWrapper =
                                document.createElement(
                                    'div'
                                );
 
                            resetWrapper.className =
                                'zunagi-filter zunagi-reset-filter';
 
 
                            const resetButton =
                                document.createElement(
                                    'button'
                                );
 
                            resetButton.type =
                                'button';
 
                            resetButton.className =
                                'zunagi-reset-button';
 
                            resetButton.textContent =
                                'Reset filters';
 
 
                            resetButton.addEventListener(
                                'click',
                                function () {
 
                                    filterColumns.forEach(
                                        function (
                                            columnIndex
                                        ) {
 
                                            api
                                                .column(
                                                    columnIndex
                                                )
                                                .search(
                                                    ''
                                                );
 
 
                                            if (
                                                filterSelects[
                                                    columnIndex
                                                ]
                                            ) {
 
                                                filterSelects[
                                                    columnIndex
                                                ].value =
                                                    '';
                                            }
                                        }
                                    );
 
 
                                    if (
                                        majorEventCheckbox
                                    ) {
 
                                        majorEventCheckbox.checked =
                                            false;
                                    }
 
 
                                    if (
                                        showPastCheckbox
                                    ) {
 
                                        showPastCheckbox.checked =
                                            false;
                                    }
 
 
                                    api.search( '' );
 
 
                                    if (
                                        searchInput
                                    ) {
 
                                        searchInput.value =
                                            '';
                                    }
 
 
                                    api.order(
                                        [
                                            [
                                                1,
                                                'asc'
                                            ]
                                        ]
                                    );
 
 
                                    api.draw();
                                }
                            );
 
 
                            resetWrapper.appendChild(
                                resetButton
                            );
 
                            filterBar.appendChild(
                                resetWrapper
                            );
 
 
                            const calendarWrapper =
                                table.closest(
                                    '.zunagi-calendar-table'
                                );
 
 
                            if ( calendarWrapper ) {
 
                                calendarWrapper
                                    .parentNode
                                    .insertBefore(
                                        filterBar,
                                        calendarWrapper
                                    );
                            }
 
 
                            wrapperEl =
                                calendarWrapper;
 
 
                            applyPastEventClasses();
 
                            applyOrgColours(
                                api
                            );
 
                            applyMajorEventBadges(
                                api
                            );
 
                            applyYouthEventBadges(
                                api
                            );
 
                            applyJuniorEventBadges(
                                api
                            );
 
                            rebuildFilters();
 
                            restoreFilterState(
                                api
                            );
 
                            rebuildFilters();
 
                            api.draw();
 
                            adjustTable(
                                api
                            );
 


        box.classList.add( 'is-open' );
                            buildFixedHeader();
    }


    function updateSelection() {
                            updateFixedHeaderVisibility();
        const items = box.querySelectorAll( '.zunagi-search-suggestion' );


        items.forEach( function ( item, index ) {
                            fixedHeaderSyncLoop();
            item.classList.toggle(
                        }
                'is-selected',
                 }
                 index === selectedIndex
             );
             );
        } );
    }


    input.setAttribute( 'autocomplete', 'off' );


    input.addEventListener( 'input', function () {
        /* ==================================================
        const query = input.value.trim();
        * 11. CASCADING FILTER MATCH
        * ==================================================
        */
 
        function rowMatchesOtherFilters(
            row,
            targetColumn
        ) {
 
            if (
                !showPastCheckbox ||
                !showPastCheckbox.checked
            ) {
 
                if (
                    row.endDate &&
                    row.endDate <
                    getLocalToday()
                ) {
 
                    return false;
                }
            }
 
 
            if (
                majorEventCheckbox &&
                majorEventCheckbox.checked &&
                row.majorEvent !== 'Yes'
            ) {
 
                return false;
            }
 
 
            if (
                searchInput &&
                searchInput.value.trim()
            ) {
 
                const query =
                    searchInput
                        .value
                        .trim()
                        .toLowerCase();
 
 
                if (
                    !row.searchText.includes(
                        query
                    )
                ) {
 
                    return false;
                }
            }
 
 
            const rowValues = {
 
                0:
                    row.month,
 
                2:
                    row.org,
 
                4:
                    row.region,
 
                5:
                    row.category
            };
 
 
            for (
                const filterColumn
                of filterColumns
            ) {
 
                if (
                    filterColumn ===
                    targetColumn
                ) {
 
                    continue;
                }
 
 
                const select =
                    filterSelects[
                        filterColumn
                    ];
 
 
                if (
                    !select ||
                    !select.value
                ) {
 
                    continue;
                }
 
 
                if (
                    rowValues[
                        filterColumn
                    ] !==
                    select.value
                ) {


        clearTimeout( timer );
                    return false;
                }
            }


        if ( query.length < 2 ) {
 
            closeSuggestions();
             return true;
             return;
         }
         }


        timer = setTimeout( function () {
            const thisRequest = ++requestNumber;


             new mw.Api().get( {
        /* ==================================================
                action: 'query',
        * 12. REBUILD CASCADING FILTERS
                list: 'search',
        * ==================================================
                srsearch: query,
        */
                srnamespace: 0,
 
                srlimit: 8,
        function rebuildFilters() {
                srprop: 'snippet',
 
                format: 'json'
             filterColumns.forEach(
            } ).done( function ( data ) {
                function (
                if ( thisRequest !== requestNumber ) {
                    targetColumn
                     return;
                ) {
 
                    const select =
                        filterSelects[
                            targetColumn
                        ];
 
 
                    if ( !select ) {
                        return;
                    }
 
 
                    const selectedValue =
                        select.value;
 
 
                    const availableValues =
                        new Set();
 
 
                    const sortValues =
                        new Map();
 
 
                    sourceRows.forEach(
                        function ( row ) {
 
                            if (
                                !rowMatchesOtherFilters(
                                    row,
                                    targetColumn
                                )
                            ) {
 
                                return;
                            }
 
 
                            let value =
                                '';
 
 
                            switch (
                                targetColumn
                            ) {
 
                                case 0:
 
                                    value =
                                        row.month;
 
                                    break;
 
 
                                case 2:
 
                                    value =
                                        row.org;
 
                                    break;
 
 
                                case 4:
 
                                    value =
                                        row.region;
 
                                    break;
 
 
                                case 5:
 
                                    value =
                                        row.category;
 
                                    break;
                            }
 
 
                            if ( !value ) {
                                return;
                            }
 
 
                            availableValues.add(
                                value
                            );
 
 
                            if (
                                targetColumn === 0
                            ) {
 
                                sortValues.set(
                                    value,
                                    row.monthOrder
                                );
                            }
                        }
                    );
 
 
                    select.innerHTML =
                        '';
 
 
                    const allOption =
                        document.createElement(
                            'option'
                        );
 
                    allOption.value =
                        '';
 
                    allOption.textContent =
                        'All';
 
 
                    select.appendChild(
                        allOption
                    );
 
 
                    const sortedValues =
                        Array.from(
                            availableValues
                        );
 
 
                    if (
                        targetColumn === 0
                    ) {
 
                        sortedValues.sort(
                            function (
                                a,
                                b
                            ) {
 
                                const orderA =
                                    sortValues.get(
                                        a
                                    ) || a;
 
 
                                const orderB =
                                    sortValues.get(
                                        b
                                    ) || b;
 
 
                                return orderA.localeCompare(
                                    orderB
                                );
                            }
                        );
 
                    } else {
 
                        sortedValues.sort(
                            function (
                                a,
                                b
                            ) {
 
                                return a.localeCompare(
                                    b,
                                    undefined,
                                    {
                                        numeric:
                                            true,
 
                                        sensitivity:
                                            'base'
                                    }
                                );
                            }
                        );
                    }
 
 
                    sortedValues.forEach(
                        function (
                            value
                        ) {
 
                            const option =
                                document.createElement(
                                    'option'
                                );
 
                            option.value =
                                value;
 
                            option.textContent =
                                value;
 
 
                            select.appendChild(
                                option
                            );
                        }
                    );
 
 
                    if (
                        selectedValue &&
                        availableValues.has(
                            selectedValue
                        )
                    ) {
 
                        select.value =
                            selectedValue;
 
                     } else {
 
                        select.value =
                            '';
                    }
                 }
                 }
            );
        }
        /* ==================================================
        * 13. AFTER DRAW
        * ==================================================
        */
        dt.on(
            'draw',
            function () {
                rebuildFilters();
                applyPastEventClasses();
                applyOrgColours(
                    dt
                );
                applyMajorEventBadges(
                    dt
                );


                 renderSuggestions(
                 applyYouthEventBadges(
                     data.query && data.query.search
                     dt
                        ? data.query.search
                        : []
                 );
                 );
            } ).fail( function () {
                closeSuggestions();
            } );
        }, 300 );
    } );


    input.addEventListener( 'keydown', function ( event ) {
                applyJuniorEventBadges(
         const items = box.querySelectorAll(
                    dt
             '.zunagi-search-suggestion'
                );
 
                adjustTable(
                    dt
                );
            }
        );
 
 
 
// ==========================================================
// COLLAPSIBLE CALENDAR FILTERS ON MOBILE
// ==========================================================
( function () {
'use strict';
 
function initFilterToggle() {
var filters = document.querySelector( '.zunagi-calendar-filters' );
if ( !filters ) {
return;
}
 
var toggle = document.createElement( 'button' );
toggle.type = 'button';
toggle.className = 'zunagi-filters-toggle';
toggle.textContent = 'Show filters';
toggle.setAttribute( 'aria-expanded', 'false' );
 
toggle.addEventListener( 'click', function () {
var isOpen = filters.classList.toggle( 'is-open' );
toggle.textContent = isOpen ? 'Hide filters' : 'Show filters';
toggle.setAttribute( 'aria-expanded', isOpen ? 'true' : 'false' );
} );
 
filters.parentNode.insertBefore( toggle, filters );
}
 
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', initFilterToggle );
} else {
initFilterToggle();
}
}() );
 
         /* ==================================================
        * 14. WINDOW RESIZE
        * ==================================================
        */
 
        let resizeTimer =
            null;
 
 
        window.addEventListener(
             'resize',
            function () {
 
                clearTimeout(
                    resizeTimer
                );
 
 
                resizeTimer =
                    setTimeout(
                        function () {
 
                            dt.columns.adjust();
 
                            rebuildFixedHeader();
 
                            updateFixedHeaderVisibility();
                        },
                        150
                    );
            }
        );
 
 
        /* ==================================================
        * 15. ORIENTATION CHANGE
        * ==================================================
        */
 
        window.addEventListener(
            'orientationchange',
            function () {
 
                setTimeout(
                    function () {
 
                        dt.columns.adjust();
 
                        rebuildFixedHeader();
 
                        updateFixedHeaderVisibility();
                    },
                    300
                );
            }
         );
         );


        if ( event.key === 'ArrowDown' && items.length ) {
    } ).catch(
            event.preventDefault();
         function ( error ) {
            selectedIndex =
 
                ( selectedIndex + 1 ) % items.length;
             console.error(
            updateSelection();
                 'Zunagi DataTables module failed to load:',
         } else if ( event.key === 'ArrowUp' && items.length ) {
                error
             event.preventDefault();
             );
            selectedIndex =
                 selectedIndex <= 0
                    ? items.length - 1
                    : selectedIndex - 1;
            updateSelection();
        } else if (
            event.key === 'Enter' &&
            selectedIndex >= 0 &&
             results[ selectedIndex ]
        ) {
            event.preventDefault();
            openPage( results[ selectedIndex ].title );
        } else if ( event.key === 'Escape' ) {
            closeSuggestions();
         }
         }
    );
} );
/* ==========================================================
  ABVRS POINTS TABLE — EQUAL COLUMN WIDTHS
  ========================================================== */
$( function () {
    /*
    * table-layout:fixed with percentage column widths doesn't
    * reliably stretch to fill its wrapper in every browser once
    * the table sits inside the skin's flex-based grid layout
    * (Bootstrap's .row / .col-*) - the table's own outer box
    * stretches to 100% correctly, but its internal column grid
    * can fall back to each column's own content width instead,
    * leaving blank space to the right. Most visible on mobile,
    * where the page's flex column collapses to 100% width and
    * exposes the quirk (confirmed on zunagi.com directly: the
    * <table> box was the full container width, but its <tr>
    * inside was stuck at content width regardless of any CSS
    * width/table-layout/colgroup value thrown at it).
    *
    * Setting each column's width, min-width and max-width in
    * pixels — computed from the wrapper's own real rendered
    * width — forces every column to the same size regardless.
    * min-width matters as much as width here: table cells have
    * the same "shrink to at least my content" floor that flex
    * items do, and a plain width alone doesn't override it.
    */
    const tables =
        document.querySelectorAll( '.zunagi-points-table' );
    if ( !tables.length ) {
        return;
    }
    function fixPointsTableColumns() {
        tables.forEach( function ( table ) {
            const wrapper =
                table.closest( '.zunagi-points-table-wrapper' );
            if ( !wrapper || !table.rows.length ) {
                return;
            }
            const columnCount =
                table.rows[ 0 ].cells.length;
            if ( !columnCount ) {
                return;
            }
            const wrapperWidth =
                wrapper.clientWidth;
            if ( !wrapperWidth ) {
                return;
            }
            // Subtract a couple of pixels of headroom before dividing: an exact,
            // unpadded division (e.g. 800 / 5 = 160px each) can still overflow the
            // wrapper by a sub-pixel amount once collapsed cell borders are added in,
            // which is enough to trigger an unwanted scrollbar on an overflow:auto
            // wrapper even though the table visually looks like it fits.
            const columnWidth =
                Math.floor( ( wrapperWidth - 2 ) / columnCount ) + 'px';
            for ( let r = 0; r < table.rows.length; r++ ) {
                const row =
                    table.rows[ r ];
                for ( let c = 0; c < row.cells.length; c++ ) {
                    const cell =
                        row.cells[ c ];
                    cell.style.width = columnWidth;
                    cell.style.minWidth = columnWidth;
                    cell.style.maxWidth = columnWidth;
                }
            }
        } );
    }
    fixPointsTableColumns();
    // Re-run on resize/orientation change so rotating a phone (or resizing a
    // desktop window) recalculates the column width for the new wrapper size.
    let resizeTimer = null;
    window.addEventListener( 'resize', function () {
        clearTimeout( resizeTimer );
        resizeTimer = setTimeout( fixPointsTableColumns, 150 );
     } );
     } );
} );
/* ==========================================================
  NBVA POINTS TABLE — MOBILE-ONLY DIVISION TABS
  ========================================================== */
$( function () {
    const tabsContainer =
        document.querySelector( '.zunagi-nbva-points-tabs' );
    if ( !tabsContainer ) {
        return; // Not an NBVA event page - nothing to do.
    }


     document.addEventListener( 'click', function ( event ) {
     const tabs =
         if (
        tabsContainer.querySelectorAll( '.zunagi-nbva-points-tab' );
             !form.contains( event.target ) &&
 
             !box.contains( event.target )
    // Both the NBVA and ABVRS tables share this class, so one query and one
         ) {
    // loop below covers whichever of the two (or both) are on the page.
             closeSuggestions();
    const tables =
        document.querySelectorAll( '.zunagi-points-table' );
 
    function isMobile() {
        return window.matchMedia( '(max-width: 900px)' ).matches;
    }
 
    // The equal-column-width fix (claude/abvrs-points-table.md, section 6)
    // sizes every column in pixels based on the table's *total* column
    // count, and runs before it has any idea some of those columns are
    // about to be hidden by the tabs below. Once columns are hidden, the
    // ones left visible are still sized for the old (larger) column count
    // and no longer fill the wrapper - visible as blank space to the right
    // of a narrower table (found live 2026-09-18). This recomputes
    // width/min-width/max-width using only the currently-visible columns,
    // so it has to be re-run every time applyTab() changes which columns
    // are shown.
    function recalculateVisibleColumnWidths( table ) {
 
        const wrapper =
            table.closest( '.zunagi-points-table-wrapper' );
 
         if ( !wrapper || !table.rows.length ) {
             return;
        }
 
        const visibleColumnCount =
            Array.prototype.filter.call(
                table.rows[ 0 ].cells,
                function ( cell ) {
                    return cell.style.display !== 'none';
                }
            ).length;
 
        const wrapperWidth =
             wrapper.clientWidth;
 
        if ( !visibleColumnCount || !wrapperWidth ) {
            return;
        }
 
        // Same -2px headroom as the original fix, to avoid a 1px overflow
        // scrollbar from collapsed cell borders.
        const columnWidth =
            Math.floor( ( wrapperWidth - 2 ) / visibleColumnCount ) + 'px';
 
         for ( let r = 0; r < table.rows.length; r++ ) {
 
             const row = table.rows[ r ];
 
            for ( let c = 0; c < row.cells.length; c++ ) {
 
                const cell = row.cells[ c ];
 
                if ( cell.style.display === 'none' ) {
                    continue;
                }
 
                cell.style.width = columnWidth;
                cell.style.minWidth = columnWidth;
                cell.style.maxWidth = columnWidth;
            }
         }
         }
    }
    function applyTab( tabNumber ) {
        tabs.forEach( function ( tab ) {
            tab.classList.toggle(
                'active',
                tab.dataset.tab === String( tabNumber )
            );
        } );
        tables.forEach( function ( table ) {
            if ( !table.rows.length ) {
                return;
            }
            // Includes the "Place" column at index 0.
            const columnCount =
                table.rows[ 0 ].cells.length;
            for ( let r = 0; r < table.rows.length; r++ ) {
                const cells =
                    table.rows[ r ].cells;
                for ( let c = 1; c < cells.length; c++ ) { // skip Place
                    if ( !isMobile() ) {
                        // Desktop: tab bar is hidden by CSS - every column
                        // always shows, regardless of which tab was last
                        // clicked or whether the resize came from mobile.
                        cells[ c ].style.display = '';
                        continue;
                    }
                    // A table with 4 or fewer total columns (Place + <=3
                    // divisions, e.g. a 3-Star ABVRS table) has nothing
                    // beyond what tab 1 already shows - leave it fully
                    // visible on both tabs rather than showing an
                    // empty-looking table with only "Place" on tab 2.
                    if ( columnCount <= 4 ) {
                        cells[ c ].style.display = '';
                        continue;
                    }
                    const isFirstThreeDivisions =
                        c <= 3;
                    const shouldShow =
                        tabNumber === 1 ? isFirstThreeDivisions : !isFirstThreeDivisions;
                    cells[ c ].style.display =
                        shouldShow ? '' : 'none';
                }
            }
            // Re-fill the wrapper's width using only the columns left
            // visible above - see the function's own comment for why this
            // has to be redone every time, not just once on load.
            recalculateVisibleColumnWidths( table );
            // Swap the "Division 1 is..."/"Division 4 is..." subheading text
            // to match the active tab. The subheading is the element
            // immediately before this table's wrapper in the DOM
            // (zunagi-points-subheading, then zunagi-points-table-wrapper -
            // see Module:NBVA Points / Module:ABVRS Points). Only swaps on
            // mobile (desktop always shows every column, so the subheading
            // always reads as Division 1), and only when the module actually
            // supplied a tab-2 variant - a table with 4 or fewer total
            // columns never gets data-tab2-text, so its subheading is left
            // alone, same as its columns are on the guard above.
            const wrapper = table.closest( '.zunagi-points-table-wrapper' );
            const subheading = wrapper && wrapper.previousElementSibling;
            if (
                subheading &&
                subheading.classList.contains( 'zunagi-points-subheading' ) &&
                subheading.dataset.tab2Text
            ) {
                const showTab2Text = isMobile() && tabNumber === 2;
                subheading.textContent = showTab2Text ? subheading.dataset.tab2Text : subheading.dataset.tab1Text;
            }
        } );
    }
    let activeTab = 1;
    tabs.forEach( function ( tab ) {
        tab.addEventListener( 'click', function () {
            activeTab = Number( tab.dataset.tab );
            applyTab( activeTab );
        } );
        // <span role="button"> isn't natively keyboard-activatable the way a
        // real <button> is (MediaWiki's sanitizer strips raw <button> tags -
        // see the bug note above, which is why these are spans at all) -
        // Enter/Space need wiring up by hand to keep the tabs operable
        // without a mouse.
        tab.addEventListener( 'keydown', function ( e ) {
            if ( e.key === 'Enter' || e.key === ' ' ) {
                e.preventDefault();
                activeTab = Number( tab.dataset.tab );
                applyTab( activeTab );
            }
        } );
     } );
     } );
    applyTab( activeTab ); // Set the initial state (tab 1, columns trimmed) on page load.
    // Re-run on resize/orientation change so crossing the mobile breakpoint
    // re-evaluates whether columns should be hidden - same debounce pattern
    // as the ABVRS points table's own equal-column-width fix above.
    let resizeTimer = null;
    window.addEventListener( 'resize', function () {
        clearTimeout( resizeTimer );
        resizeTimer = setTimeout( function () {
            applyTab( activeTab );
        }, 150 );
    } );
} );
} );

Latest revision as of 15:32, 18 September 2026

/* ==========================================================
   ZUNAGI BEACH VOLLEYBALL CALENDAR
   ========================================================== */

$( function () {

    /*
     * IMPORTANT: this check runs BEFORE requesting the
     * DataTables module, not after. mw.loader.using() is a
     * client-side call that fetches the module over the
     * network the moment it runs, regardless of what
     * LocalSettings.php's addModules() gate decided
     * server-side - so checking for the table only inside
     * the .then() callback (after the module was already
     * requested) meant the full DataTables library was being
     * downloaded on every single page, even ones with no
     * calendar table at all, wasting bandwidth and defeating
     * the point of the category-based loading gate.
     */

    const table =
        document.querySelector( '.zunagi-calendar' );

    if ( !table ) {
        return;
    }


    mw.loader.using( 'ext.zunagi.datatables' ).then( function () {

        /* ==================================================
         * 1. FIX MEDIAWIKI HEADER
         * ==================================================
         */

        if ( !table.tHead ) {

            const firstRow =
                table.querySelector(
                    'tbody > tr:first-child'
                );

            if (
                firstRow &&
                firstRow.querySelectorAll( 'th' ).length > 0
            ) {

                const thead =
                    table.createTHead();

                thead.appendChild(
                    firstRow
                );
            }
        }


        /* ==================================================
         * 2. COLUMN MAP
         * ==================================================
         *
         * 0 = Month        hidden
         * 1 = Date
         * 2 = Org
         * 3 = Event
         * 4 = Region
         * 5 = Category
         * 6 = Level
         * 7 = Venue
         * 8 = Major Event  hidden
         * 9 = End Date     hidden - falls back to Date (col 1) when blank
         */


        const filterColumns = [
            0, // Month
            2, // Org
            4, // Region
            5  // Category
        ];


        const filterSelects = {};

        let searchInput = null;
        let majorEventCheckbox = null;
        let showPastCheckbox = null;


        /* ==================================================
         * 3. CELL HELPERS
         * ==================================================
         */

        function getCellFilterValue( cell ) {

            if ( !cell ) {
                return '';
            }

            const dataSearch =
                cell.getAttribute(
                    'data-search'
                );

            if ( dataSearch !== null ) {
                return dataSearch.trim();
            }

            return cell.textContent.trim();
        }


        function getCellOrderValue( cell ) {

            if ( !cell ) {
                return '';
            }

            const dataOrder =
                cell.getAttribute(
                    'data-order'
                );

            if ( dataOrder !== null ) {
                return dataOrder.trim();
            }

            return getCellFilterValue(
                cell
            );
        }


        /* ==================================================
         * 4. TODAY
         * ==================================================
         */

        function getLocalToday() {

            const now =
                new Date();

            const year =
                now.getFullYear();

            const month =
                String(
                    now.getMonth() + 1
                ).padStart(
                    2,
                    '0'
                );

            const day =
                String(
                    now.getDate()
                ).padStart(
                    2,
                    '0'
                );

            return (
                year +
                '-' +
                month +
                '-' +
                day
            );
        }


        /* ==================================================
         * 5. CACHE SOURCE ROWS
         * ==================================================
         */

        const sourceRows =
            Array.from(
                table.querySelectorAll(
                    'tbody > tr'
                )
            )
            .map(
                function ( row ) {

                    const cells =
                        Array.from(
                            row.querySelectorAll(
                                'td'
                            )
                        );

                    if ( cells.length !== 10 ) {   // was 9

                        console.warn(
                            'Zunagi calendar row contains ' +
                            cells.length +
                            ' cells; expected 10.',   // was 9
                            row
                        );

                        return null;
                    }

                    const date =
                        getCellOrderValue(
                            cells[ 1 ]
                        );

                    const endDate =
                        getCellOrderValue(
                            cells[ 9 ]
                        ) || date;

                    row.dataset.zunagiDate =
                        date;

                    row.dataset.zunagiEndDate =
                        endDate;

                    row.dataset.zunagiMajor =
                        getCellFilterValue(
                            cells[ 8 ]
                        );

                    return {

                        row: row,
                        month: getCellFilterValue( cells[ 0 ] ),
                        monthOrder: getCellOrderValue( cells[ 0 ] ),
                        date: date,
                        endDate: endDate,   // new

                        org:
                            getCellFilterValue(
                                cells[ 2 ]
                            ),

                        event:
                            getCellFilterValue(
                                cells[ 3 ]
                            ),

                        region:
                            getCellFilterValue(
                                cells[ 4 ]
                            ),

                        category:
                            getCellFilterValue(
                                cells[ 5 ]
                            ),

                        level:
                            getCellFilterValue(
                                cells[ 6 ]
                            ),

                        venue:
                            getCellFilterValue(
                                cells[ 7 ]
                            ),

                        majorEvent:
                            getCellFilterValue(
                                cells[ 8 ]
                            ),

                        searchText:
                            row.textContent
                                .replace(
                                    /\s+/g,
                                    ' '
                                )
                                .trim()
                                .toLowerCase()
                    };
                }
            )
            .filter(
                function ( row ) {
                    return row !== null;
                }
            );


        /* ==================================================
         * 6. PAST EVENT CLASSES
         * ==================================================
         */

        function applyPastEventClasses() {

            const today =
                getLocalToday();

            sourceRows.forEach(
                function ( rowData ) {

                    rowData.row.classList.toggle(
                        'zunagi-past-event',
                        Boolean(
                            rowData.endDate &&
                            rowData.endDate < today
                        )
                    );
                }
            );
        }


        /* ==================================================
         * 7. ORGANISATION COLOURS
         * ==================================================
         */

        function applyOrgColours( api ) {

            api.rows().every(
                function () {

                    const row =
                        this.node();

                    if ( !row ) {
                        return;
                    }


                    const orgCell =
                        api
                            .cell(
                                row,
                                2
                            )
                            .node();

                    if ( !orgCell ) {
                        return;
                    }

                    const org =
                        getCellFilterValue(
                            orgCell
                        );


                    orgCell.classList.remove(
                        'org-vnsw',
                        'org-qbvt',
                        'org-nbva',
                        'org-va',
                        'org-vsa',
                        'org-vv',
                        'org-vact',
                        'org-vwa'
                    );


                    if ( org === 'VNSW' ) {

                        orgCell.classList.add(
                            'org-vnsw'
                        );

                    } else if ( org === 'QBVT' ) {

                        orgCell.classList.add(
                            'org-qbvt'
                        );

                    } else if ( org === 'NBVA' ) {

                        orgCell.classList.add(
                            'org-nbva'
                        );

                    } else if ( org === 'VA' ) {

                        orgCell.classList.add(
                            'org-va'
                        );

                    } else if ( org === 'VSA' ) {

                        orgCell.classList.add(
                            'org-vsa'
                        );

                    } else if ( org === 'VACT' ) {

                        orgCell.classList.add(
                            'org-vact'
                        );

                    } else if ( org === 'VWA' ) {

                        orgCell.classList.add(
                            'org-vwa'
                        );

                    } else if ( org === 'VV' ) {

                        orgCell.classList.add(
                            'org-vv'
                        );
                    }
                }
            );
        }


/* ==================================================
 * 8. MAJOR EVENT BADGES
 * ==================================================
 */

function applyMajorEventBadges( api ) {

    api.rows().every(
        function () {

            const row =
                this.node();

            if ( !row ) {
                return;
            }

            const eventCell =
                api
                    .cell(
                        row,
                        3
                    )
                    .node();

            const majorCell =
                api
                    .cell(
                        row,
                        8
                    )
                    .node();

            if (
                !eventCell ||
                !majorCell
            ) {
                return;
            }

            /*
             * Remove any existing badge first.
             */

            eventCell
                .querySelectorAll(
                    '.zunagi-major-badge'
                )
                .forEach(
                    function ( badge ) {
                        badge.remove();
                    }
                );

            const isMajor =
                getCellFilterValue(
                    majorCell
                ) === 'Yes';

            if ( !isMajor ) {
                return;
            }

            const badge =
                document.createElement(
                    'span'
                );

            badge.className =
                'zunagi-major-badge';

            badge.textContent =
                'MAJOR';

            badge.style.marginLeft =
                '0';

            badge.style.marginRight =
                '6px';

            eventCell.insertBefore(
                badge,
                eventCell.querySelector(
                    'a'
                )
            );
        }
    );
}

/* ==================================================
 * 8b. YOUTH EVENT BADGES
 * ==================================================
 */

function applyYouthEventBadges( api ) {

    api.rows().every(
        function () {

            const row =
                this.node();

            if ( !row ) {
                return;
            }

            const eventCell =
                api
                    .cell(
                        row,
                        3
                    )
                    .node();

            const categoryCell =
                api
                    .cell(
                        row,
                        5
                    )
                    .node();

            if (
                !eventCell ||
                !categoryCell
            ) {
                return;
            }

            /*
             * Remove any existing badge first.
             */

            eventCell
                .querySelectorAll(
                    '.zunagi-youth-badge'
                )
                .forEach(
                    function ( badge ) {
                        badge.remove();
                    }
                );

            const isYouth =
                getCellFilterValue(
                    categoryCell
                ) === 'Youth';

            if ( !isYouth ) {
                return;
            }

            const badge =
                document.createElement(
                    'span'
                );

            badge.className =
                'zunagi-youth-badge';

            badge.textContent =
                'YOUTH';

            badge.style.marginLeft =
                '0';

            badge.style.marginRight =
                '6px';

            eventCell.insertBefore(
                badge,
                eventCell.querySelector(
                    'a'
                )
            );
        }
    );
}

/* ==================================================
 * 8c. JUNIOR EVENT BADGES
 * ==================================================
 */

function applyJuniorEventBadges( api ) {

    api.rows().every(
        function () {

            const row =
                this.node();

            if ( !row ) {
                return;
            }

            const eventCell =
                api
                    .cell(
                        row,
                        3
                    )
                    .node();

            const categoryCell =
                api
                    .cell(
                        row,
                        5
                    )
                    .node();

            if (
                !eventCell ||
                !categoryCell
            ) {
                return;
            }

            /*
             * Remove any existing badge first.
             */

            eventCell
                .querySelectorAll(
                    '.zunagi-junior-badge'
                )
                .forEach(
                    function ( badge ) {
                        badge.remove();
                    }
                );

            const isJunior =
                getCellFilterValue(
                    categoryCell
                ) === 'Junior';

            if ( !isJunior ) {
                return;
            }

            const badge =
                document.createElement(
                    'span'
                );

            badge.className =
                'zunagi-junior-badge';

            badge.textContent =
                'JUNIOR';

            badge.style.marginLeft =
                '0';

            badge.style.marginRight =
                '6px';

            eventCell.insertBefore(
                badge,
                eventCell.querySelector(
                    'a'
                )
            );
        }
    );
}

/* ==================================================
 * 8d. FILTER STATE PERSISTENCE (event-page round trip only)
 * ==================================================
 *
 * Filters are remembered across exactly one kind of trip: leaving
 * the calendar by clicking through to an event's own page, then
 * coming back - either via that page's "Back to Calendar" link or
 * the browser's own Back button. A direct or fresh visit to the
 * calendar (Main Page, a bookmark, a new tab, a nav-menu click)
 * never restores anything, because nothing is ever saved except at
 * the moment of clicking an event link.
 */

const FILTER_STATE_KEY =
    'zunagiCalendarFilterState';

function saveFilterState() {

    const selects = {};

    filterColumns.forEach(
        function ( columnIndex ) {

            if ( filterSelects[ columnIndex ] ) {

                selects[ columnIndex ] =
                    filterSelects[ columnIndex ].value;
            }
        }
    );

    const payload = {

        selects: selects,

        major:
            Boolean(
                majorEventCheckbox &&
                majorEventCheckbox.checked
            ),

        past:
            Boolean(
                showPastCheckbox &&
                showPastCheckbox.checked
            ),

        search:
            searchInput ?
                searchInput.value :
                ''
    };

    try {

        sessionStorage.setItem(
            FILTER_STATE_KEY,
            JSON.stringify( payload )
        );

    } catch ( e ) {

        /*
         * Ignore - e.g. private-browsing storage restrictions.
         * Worst case the round trip just doesn't restore.
         */
    }
}

function restoreFilterState( api ) {

    let raw;

    try {

        raw =
            sessionStorage.getItem(
                FILTER_STATE_KEY
            );

    } catch ( e ) {

        return;
    }

    if ( !raw ) {
        return;
    }

    /*
     * Consume it immediately - it must only ever apply to the one
     * page load right after clicking an event link, never to any
     * later visit.
     */

    try {

        sessionStorage.removeItem(
            FILTER_STATE_KEY
        );

    } catch ( e ) {}

    let state;

    try {

        state =
            JSON.parse( raw );

    } catch ( e ) {

        return;
    }

    if ( !state ) {
        return;
    }

    filterColumns.forEach(
        function ( columnIndex ) {

            const value =
                state.selects &&
                state.selects[ columnIndex ];

            if ( !value ) {
                return;
            }

            if ( filterSelects[ columnIndex ] ) {

                filterSelects[ columnIndex ].value =
                    value;
            }

            api
                .column( columnIndex )
                .search(
                    value,
                    {
                        exact: true
                    }
                );
        }
    );

    if ( majorEventCheckbox ) {

        majorEventCheckbox.checked =
            Boolean( state.major );
    }

    if ( showPastCheckbox ) {

        showPastCheckbox.checked =
            Boolean( state.past );
    }

    if ( searchInput && state.search ) {

        searchInput.value =
            state.search;

        api.search( state.search );
    }
}


/* ==================================================
 * 8e. SAVE FILTER STATE WHEN LEAVING FOR AN EVENT PAGE
 * ==================================================
 */

table.addEventListener(
    'click',
    function ( event ) {

        const link =
            event.target.closest(
                'td.zunagi-event-column a'
            );

        if ( !link ) {
            return;
        }

        saveFilterState();
    }
);




        /* ==================================================
         * 9. TABLE ADJUSTMENT
         * ==================================================
         */

        function adjustTable( api ) {

            requestAnimationFrame(
                function () {

                    api.columns.adjust();

                    rebuildFixedHeader();
                }
            );
        }


        /* ==================================================
         * 9b. FIXED HEADER (JS affix pattern)
         * ==================================================
         */

        let wrapperEl = null;

        let dtApi = null;

        let fixedHeaderOuter = null;
        let fixedHeaderInner = null;

        let fixedHeaderVisible = false;


        const FIXED_HEADER_TOP_OFFSET = 0;


        const DEBUG_FIXED_HEADER = false;

        let debugOverlay = null;

        function updateDebugOverlay() {

            if ( !DEBUG_FIXED_HEADER ) {
                return;
            }

            if ( !debugOverlay ) {

                debugOverlay =
                    document.createElement( 'div' );

                debugOverlay.style.position =
                    'fixed';

                debugOverlay.style.bottom =
                    '8px';

                debugOverlay.style.right =
                    '8px';

                debugOverlay.style.zIndex =
                    '99999';

                debugOverlay.style.background =
                    'rgba(0,0,0,0.75)';

                debugOverlay.style.color =
                    '#0f0';

                debugOverlay.style.font =
                    '11px monospace';

                debugOverlay.style.padding =
                    '4px 6px';

                debugOverlay.style.pointerEvents =
                    'none';

                debugOverlay.style.whiteSpace =
                    'pre';

                document.body.appendChild(
                    debugOverlay
                );
            }

            debugOverlay.textContent =
                'wrapperEl.scrollLeft: ' +
                ( wrapperEl ? wrapperEl.scrollLeft : 'n/a' ) +
                '\nfixedHeaderVisible: ' +
                fixedHeaderVisible;
        }


        function buildFixedHeader() {

            if ( !wrapperEl || !table.tHead ) {
                return;
            }


            if ( fixedHeaderOuter ) {

                fixedHeaderOuter.remove();

                fixedHeaderOuter = null;
                fixedHeaderInner = null;
            }


            let realThs;

            if ( dtApi ) {

                realThs =
                    dtApi
                        .columns( ':visible' )
                        .header()
                        .toArray();

            } else {

                realThs =
                    Array.from(
                        table.tHead.querySelectorAll( 'th' )
                    ).filter(
                        function ( th ) {

                            const rect =
                                th.getBoundingClientRect();

                            return (
                                rect.width > 0 &&
                                rect.height > 0
                            );
                        }
                    );
            }

            if ( realThs.length === 0 ) {
                return;
            }


            const theadRect =
                table.tHead.getBoundingClientRect();

            const tableRect =
                table.getBoundingClientRect();


            fixedHeaderInner =
                document.createElement( 'div' );

            fixedHeaderInner.className =
                'zunagi-fixed-header-inner ' +
                table.className;

            fixedHeaderInner.style.position =
                'relative';

            const headerHeight =
                Math.round( theadRect.height );

            fixedHeaderInner.style.height =
                headerHeight + 'px';


            let maxRight = 0;

            realThs.forEach(
                function ( th ) {

                    const rect =
                        th.getBoundingClientRect();

                    const left =
                        Math.round(
                            rect.left - tableRect.left
                        );

                    const width =
                        Math.round(
                            rect.right - tableRect.left
                        ) - left;

                    maxRight =
                        Math.max(
                            maxRight,
                            left + width
                        );


                    const cell =
                        th.cloneNode( true );

                    cell
                        .querySelectorAll(
                            '.dt-column-order'
                        )
                        .forEach(
                            function ( orderEl ) {

                                orderEl.remove();
                            }
                        );


                    const textAlign =
                        getComputedStyle( th )
                            .textAlign;

                    let justify =
                        'flex-start';

                    if ( textAlign === 'center' ) {

                        justify =
                            'center';

                    } else if (
                        textAlign === 'right' ||
                        textAlign === 'end'
                    ) {

                        justify =
                            'flex-end';
                    }


                    const innerHeader =
                        cell.querySelector(
                            '.dt-column-header'
                        );

                    if ( innerHeader ) {

                        innerHeader.style.display =
                            'flex';

                        innerHeader.style.justifyContent =
                            justify;

                        innerHeader.style.alignItems =
                            'center';

                        innerHeader.style.width =
                            '100%';

                        innerHeader.style.height =
                            '100%';


                        const sortDirection =
                            th.getAttribute( 'aria-sort' );

                        if (
                            sortDirection === 'ascending' ||
                            sortDirection === 'descending'
                        ) {

                            const arrow =
                                document.createElement(
                                    'span'
                                );

                            arrow.className =
                                'zunagi-fixed-header-sort-arrow';

                            arrow.setAttribute(
                                'data-direction',
                                sortDirection
                            );

                            innerHeader.insertBefore(
                                arrow,
                                innerHeader.firstChild
                            );


                            const titleEl =
                                innerHeader.querySelector(
                                    '.dt-column-title'
                                );

                            if ( titleEl ) {

                                titleEl.style.flex =
                                    '0 0 auto';

                                titleEl.style.width =
                                    'auto';
                            }

                            arrow.style.flex =
                                '0 0 auto';
                        }
                    }


                    cell.style.position =
                        'absolute';

                    cell.style.left =
                        left + 'px';

                    cell.style.top =
                        '0';

                    cell.style.setProperty(
                        'width',
                        width + 'px',
                        'important'
                    );

                    cell.style.setProperty(
                        'min-width',
                        width + 'px',
                        'important'
                    );

                    cell.style.setProperty(
                        'max-width',
                        width + 'px',
                        'important'
                    );

                    cell.style.height =
                        headerHeight + 'px';

                    cell.style.boxSizing =
                        'border-box';

                    cell.style.display =
                        'flex';

                    cell.style.alignItems =
                        'center';

                    cell.style.justifyContent =
                        justify;


                    fixedHeaderInner.appendChild(
                        cell
                    );
                }
            );

            fixedHeaderInner.style.width =
                maxRight + 'px';


            fixedHeaderOuter =
                document.createElement( 'div' );

            fixedHeaderOuter.className =
                'zunagi-fixed-header-outer';

            fixedHeaderOuter.appendChild(
                fixedHeaderInner
            );


            document.body.appendChild(
                fixedHeaderOuter
            );


            fixedHeaderVisible = false;
        }


        function positionFixedHeader() {

            if (
                !fixedHeaderOuter ||
                !wrapperEl
            ) {
                return;
            }


            const wrapperRect =
                wrapperEl.getBoundingClientRect();

            fixedHeaderOuter.style.left =
                wrapperRect.left + 'px';

            fixedHeaderOuter.style.width =
                wrapperRect.width + 'px';

            fixedHeaderOuter.style.top =
                FIXED_HEADER_TOP_OFFSET + 'px';


            if ( fixedHeaderInner ) {

                fixedHeaderInner.style.transform =
                    'translateX(-' +
                    Math.round( wrapperEl.scrollLeft ) +
                    'px)';
            }
        }


        function updateFixedHeaderVisibility() {

            if (
                !wrapperEl ||
                !fixedHeaderOuter ||
                !table.tHead
            ) {
                return;
            }


            const theadRect =
                table.tHead.getBoundingClientRect();

            const wrapperRect =
                wrapperEl.getBoundingClientRect();


            const shouldShow =
                theadRect.top < FIXED_HEADER_TOP_OFFSET &&
                wrapperRect.bottom >
                    FIXED_HEADER_TOP_OFFSET + 40;


            if (
                shouldShow &&
                !fixedHeaderVisible
            ) {

                fixedHeaderOuter.style.display =
                    'block';

                fixedHeaderVisible = true;

            } else if (
                !shouldShow &&
                fixedHeaderVisible
            ) {

                fixedHeaderOuter.style.display =
                    'none';

                fixedHeaderVisible = false;
            }


            if ( fixedHeaderVisible ) {

                positionFixedHeader();
            }
        }


        function rebuildFixedHeader() {

            const wasVisible =
                fixedHeaderVisible;

            buildFixedHeader();

            if (
                wasVisible &&
                fixedHeaderOuter
            ) {

                fixedHeaderOuter.style.display =
                    'block';

                fixedHeaderVisible = true;

                positionFixedHeader();
            }
        }


        function fixedHeaderSyncLoop() {

            updateFixedHeaderVisibility();

            updateDebugOverlay();

            requestAnimationFrame(
                fixedHeaderSyncLoop
            );
        }


        /* ==================================================
         * 10. INITIALISE DATATABLE
         * ==================================================
         */

        const dt =
            new DataTable(
                table,
                {

                    paging: false,

                    searching: true,

                    ordering: true,

                    info: false,

                    autoWidth: false,


                    order: [
                        [
                            1,
                            'asc'
                        ]
                    ],


                    columnDefs: [

                        /*
                         * Month - hidden/filterable.
                         */

                        {
                            targets: 0,

                            visible: false,

                            searchable: true,

                            orderable: false
                        },


                        /*
                         * Date.
                         */

                        {
                            targets: 1,

                            width: '95px',

                            orderable: true,

                            className:
                                'zunagi-date-column'
                        },

                        /*
                         * Organisation - hidden/filterable.
                         */

                        {
                            targets: 2,

                            visible: false,

                            searchable: true,

                            orderable: false
                        },


                        /*
                         * Event.
                         */

                        {
                            targets: 3,

                            width: '330px',

                            orderable: false,

                            className:
                                'zunagi-event-column'
                        },


                        /*
                         * Region.
                         */

                        {
                            targets: 4,

                            width: '85px',

                            orderable: false,

                            className:
                                'zunagi-region-column'
                        },


                        /*
                         * Category.
                         */

                        {
                            targets: 5,

                            width: '100px',

                            orderable: false,

                            className:
                                'zunagi-category-column'
                        },


                        /*
                         * Level.
                         */

                        {
                            targets: 6,

                            width: '85px',

                            orderable: false,

                            className:
                                'zunagi-level-column'
                        },


                        /*
                         * Venue.
                         */

                        {
                            targets: 7,

                            width: '200px',

                            orderable: false,

                            className:
                                'zunagi-venue-column'
                        },


                        /*
                         * Major Event - hidden/filterable.
                         */

                        {
                            targets: 8,
                            visible: false,
                            searchable: true,
                            orderable: false
                        },

                        {
                            targets: 9,
                            visible: false,
                            searchable: false,
                            orderable: false
                        }
                    ],


                    layout: {

                        topStart: null,

                        topEnd: null,

                        bottomStart: null,

                        bottomEnd: null
                    },


                    initComplete:
                        function () {

                            const api =
                                this.api();

                            dtApi = api;


                            const filterBar =
                                document.createElement(
                                    'div'
                                );

                            filterBar.className =
                                'zunagi-calendar-filters';


                            filterColumns.forEach(
                                function (
                                    columnIndex
                                ) {

                                    const column =
                                        api.column(
                                            columnIndex
                                        );


                                    const heading =
                                        column
                                            .header()
                                            .textContent
                                            .trim();


                                    const wrapper =
                                        document.createElement(
                                            'div'
                                        );

                                    wrapper.className =
                                        'zunagi-filter';


                                    const label =
                                        document.createElement(
                                            'label'
                                        );

                                    label.textContent =
                                        heading;


                                    const select =
                                        document.createElement(
                                            'select'
                                        );


                                    filterSelects[
                                        columnIndex
                                    ] = select;


                                    select.addEventListener(
                                        'change',
                                        function () {

                                            column
                                                .search(
                                                    this.value,
                                                    {
                                                        exact:
                                                            true
                                                    }
                                                )
                                                .draw();
                                        }
                                    );


                                    wrapper.appendChild(
                                        label
                                    );

                                    wrapper.appendChild(
                                        select
                                    );

                                    filterBar.appendChild(
                                        wrapper
                                    );
                                }
                            );


                            const majorWrapper =
                                document.createElement(
                                    'div'
                                );

                            majorWrapper.className =
                                'zunagi-filter zunagi-checkbox-filter';


                            const majorHeading =
                                document.createElement(
                                    'span'
                                );

                            majorHeading.className =
                                'zunagi-checkbox-heading';

                            majorHeading.textContent =
                                'Major Events';


                            const majorLabel =
                                document.createElement(
                                    'label'
                                );

                            majorLabel.className =
                                'zunagi-checkbox-label';


                            majorEventCheckbox =
                                document.createElement(
                                    'input'
                                );

                            majorEventCheckbox.type =
                                'checkbox';

                            majorEventCheckbox.checked =
                                false;


                            const majorText =
                                document.createElement(
                                    'span'
                                );

                            majorText.textContent =
                                'Show';


                            majorEventCheckbox.addEventListener(
                                'change',
                                function () {

                                    api.draw();
                                }
                            );


                            majorLabel.appendChild(
                                majorEventCheckbox
                            );

                            majorLabel.appendChild(
                                majorText
                            );


                            majorWrapper.appendChild(
                                majorHeading
                            );

                            majorWrapper.appendChild(
                                majorLabel
                            );


                            filterBar.appendChild(
                                majorWrapper
                            );


                            api.search.fixed(
                                'zunagiMajorEvents',
                                function (
                                    searchString,
                                    rowData,
                                    rowIndex
                                ) {

                                    if (
                                        !majorEventCheckbox ||
                                        !majorEventCheckbox.checked
                                    ) {

                                        return true;
                                    }


                                    const row =
                                        api
                                            .row(
                                                rowIndex
                                            )
                                            .node();


                                    if ( !row ) {
                                        return true;
                                    }


                                    return (
                                        row.dataset.zunagiMajor ===
                                        'Yes'
                                    );
                                }
                            );


                            const pastWrapper =
                                document.createElement(
                                    'div'
                                );

                            pastWrapper.className =
                                'zunagi-filter zunagi-checkbox-filter';


                            const pastHeading =
                                document.createElement(
                                    'span'
                                );

                            pastHeading.className =
                                'zunagi-checkbox-heading';

                            pastHeading.textContent =
                                'Past Events';


                            const pastLabel =
                                document.createElement(
                                    'label'
                                );

                            pastLabel.className =
                                'zunagi-checkbox-label';


                            showPastCheckbox =
                                document.createElement(
                                    'input'
                                );

                            showPastCheckbox.type =
                                'checkbox';

                            showPastCheckbox.checked =
                                false;


                            const pastText =
                                document.createElement(
                                    'span'
                                );

                            pastText.textContent =
                                'Show';


                            showPastCheckbox
                                .addEventListener(
                                    'change',
                                    function () {

                                        api.draw();
                                    }
                                );


                            pastLabel.appendChild(
                                showPastCheckbox
                            );

                            pastLabel.appendChild(
                                pastText
                            );

                            pastWrapper.appendChild(
                                pastHeading
                            );

                            pastWrapper.appendChild(
                                pastLabel
                            );

                            filterBar.appendChild(
                                pastWrapper
                            );


                            api.search.fixed(
                                'zunagiPastEvents',
                                function (
                                    searchString,
                                    rowData,
                                    rowIndex
                                ) {

                                    if (
                                        showPastCheckbox &&
                                        showPastCheckbox.checked
                                    ) {

                                        return true;
                                    }


                                    const row =
                                        api
                                            .row(
                                                rowIndex
                                            )
                                            .node();


                                    if ( !row ) {
                                        return true;
                                    }


                                    const eventEndDate =
                                        row.dataset.zunagiEndDate ||
                                        row.dataset.zunagiDate;

                                    if ( !eventEndDate ) {
                                        return true;
                                    }

                                    return (
                                        eventEndDate >= getLocalToday()
                                    );
                                }
                            );


                            const searchWrapper =
                                document.createElement(
                                    'div'
                                );

                            searchWrapper.className =
                                'zunagi-filter zunagi-search-filter';


                            const searchLabel =
                                document.createElement(
                                    'label'
                                );

                            searchLabel.textContent =
                                'Search';


                            searchInput =
                                document.createElement(
                                    'input'
                                );

                            searchInput.type =
                                'search';

                            searchInput.placeholder =
                                'Search events...';


                            searchInput.addEventListener(
                                'input',
                                function () {

                                    api
                                        .search(
                                            this.value
                                        )
                                        .draw();
                                }
                            );


                            searchWrapper.appendChild(
                                searchLabel
                            );

                            searchWrapper.appendChild(
                                searchInput
                            );

                            filterBar.appendChild(
                                searchWrapper
                            );


                            const resetWrapper =
                                document.createElement(
                                    'div'
                                );

                            resetWrapper.className =
                                'zunagi-filter zunagi-reset-filter';


                            const resetButton =
                                document.createElement(
                                    'button'
                                );

                            resetButton.type =
                                'button';

                            resetButton.className =
                                'zunagi-reset-button';

                            resetButton.textContent =
                                'Reset filters';


                            resetButton.addEventListener(
                                'click',
                                function () {

                                    filterColumns.forEach(
                                        function (
                                            columnIndex
                                        ) {

                                            api
                                                .column(
                                                    columnIndex
                                                )
                                                .search(
                                                    ''
                                                );


                                            if (
                                                filterSelects[
                                                    columnIndex
                                                ]
                                            ) {

                                                filterSelects[
                                                    columnIndex
                                                ].value =
                                                    '';
                                            }
                                        }
                                    );


                                    if (
                                        majorEventCheckbox
                                    ) {

                                        majorEventCheckbox.checked =
                                            false;
                                    }


                                    if (
                                        showPastCheckbox
                                    ) {

                                        showPastCheckbox.checked =
                                            false;
                                    }


                                    api.search( '' );


                                    if (
                                        searchInput
                                    ) {

                                        searchInput.value =
                                            '';
                                    }


                                    api.order(
                                        [
                                            [
                                                1,
                                                'asc'
                                            ]
                                        ]
                                    );


                                    api.draw();
                                }
                            );


                            resetWrapper.appendChild(
                                resetButton
                            );

                            filterBar.appendChild(
                                resetWrapper
                            );


                            const calendarWrapper =
                                table.closest(
                                    '.zunagi-calendar-table'
                                );


                            if ( calendarWrapper ) {

                                calendarWrapper
                                    .parentNode
                                    .insertBefore(
                                        filterBar,
                                        calendarWrapper
                                    );
                            }


                            wrapperEl =
                                calendarWrapper;


                            applyPastEventClasses();

                            applyOrgColours(
                                api
                            );

                            applyMajorEventBadges(
                                api
                            );

                            applyYouthEventBadges(
                                api
                            );

                            applyJuniorEventBadges(
                                api
                            );

                            rebuildFilters();

                            restoreFilterState(
                                api
                            );

                            rebuildFilters();

                            api.draw();

                            adjustTable(
                                api
                            );


                            buildFixedHeader();

                            updateFixedHeaderVisibility();

                            fixedHeaderSyncLoop();
                        }
                }
            );


        /* ==================================================
         * 11. CASCADING FILTER MATCH
         * ==================================================
         */

        function rowMatchesOtherFilters(
            row,
            targetColumn
        ) {

            if (
                !showPastCheckbox ||
                !showPastCheckbox.checked
            ) {

                if (
                    row.endDate &&
                    row.endDate <
                    getLocalToday()
                ) {

                    return false;
                }
            }


            if (
                majorEventCheckbox &&
                majorEventCheckbox.checked &&
                row.majorEvent !== 'Yes'
            ) {

                return false;
            }


            if (
                searchInput &&
                searchInput.value.trim()
            ) {

                const query =
                    searchInput
                        .value
                        .trim()
                        .toLowerCase();


                if (
                    !row.searchText.includes(
                        query
                    )
                ) {

                    return false;
                }
            }


            const rowValues = {

                0:
                    row.month,

                2:
                    row.org,

                4:
                    row.region,

                5:
                    row.category
            };


            for (
                const filterColumn
                of filterColumns
            ) {

                if (
                    filterColumn ===
                    targetColumn
                ) {

                    continue;
                }


                const select =
                    filterSelects[
                        filterColumn
                    ];


                if (
                    !select ||
                    !select.value
                ) {

                    continue;
                }


                if (
                    rowValues[
                        filterColumn
                    ] !==
                    select.value
                ) {

                    return false;
                }
            }


            return true;
        }


        /* ==================================================
         * 12. REBUILD CASCADING FILTERS
         * ==================================================
         */

        function rebuildFilters() {

            filterColumns.forEach(
                function (
                    targetColumn
                ) {

                    const select =
                        filterSelects[
                            targetColumn
                        ];


                    if ( !select ) {
                        return;
                    }


                    const selectedValue =
                        select.value;


                    const availableValues =
                        new Set();


                    const sortValues =
                        new Map();


                    sourceRows.forEach(
                        function ( row ) {

                            if (
                                !rowMatchesOtherFilters(
                                    row,
                                    targetColumn
                                )
                            ) {

                                return;
                            }


                            let value =
                                '';


                            switch (
                                targetColumn
                            ) {

                                case 0:

                                    value =
                                        row.month;

                                    break;


                                case 2:

                                    value =
                                        row.org;

                                    break;


                                case 4:

                                    value =
                                        row.region;

                                    break;


                                case 5:

                                    value =
                                        row.category;

                                    break;
                            }


                            if ( !value ) {
                                return;
                            }


                            availableValues.add(
                                value
                            );


                            if (
                                targetColumn === 0
                            ) {

                                sortValues.set(
                                    value,
                                    row.monthOrder
                                );
                            }
                        }
                    );


                    select.innerHTML =
                        '';


                    const allOption =
                        document.createElement(
                            'option'
                        );

                    allOption.value =
                        '';

                    allOption.textContent =
                        'All';


                    select.appendChild(
                        allOption
                    );


                    const sortedValues =
                        Array.from(
                            availableValues
                        );


                    if (
                        targetColumn === 0
                    ) {

                        sortedValues.sort(
                            function (
                                a,
                                b
                            ) {

                                const orderA =
                                    sortValues.get(
                                        a
                                    ) || a;


                                const orderB =
                                    sortValues.get(
                                        b
                                    ) || b;


                                return orderA.localeCompare(
                                    orderB
                                );
                            }
                        );

                    } else {

                        sortedValues.sort(
                            function (
                                a,
                                b
                            ) {

                                return a.localeCompare(
                                    b,
                                    undefined,
                                    {
                                        numeric:
                                            true,

                                        sensitivity:
                                            'base'
                                    }
                                );
                            }
                        );
                    }


                    sortedValues.forEach(
                        function (
                            value
                        ) {

                            const option =
                                document.createElement(
                                    'option'
                                );

                            option.value =
                                value;

                            option.textContent =
                                value;


                            select.appendChild(
                                option
                            );
                        }
                    );


                    if (
                        selectedValue &&
                        availableValues.has(
                            selectedValue
                        )
                    ) {

                        select.value =
                            selectedValue;

                    } else {

                        select.value =
                            '';
                    }
                }
            );
        }


        /* ==================================================
         * 13. AFTER DRAW
         * ==================================================
         */

        dt.on(
            'draw',
            function () {

                rebuildFilters();

                applyPastEventClasses();

                applyOrgColours(
                    dt
                );

                applyMajorEventBadges(
                    dt
                );

                applyYouthEventBadges(
                    dt
                );

                applyJuniorEventBadges(
                    dt
                );

                adjustTable(
                    dt
                );
            }
        );



// ==========================================================
// COLLAPSIBLE CALENDAR FILTERS ON MOBILE
// ==========================================================
( function () {
	'use strict';

	function initFilterToggle() {
		var filters = document.querySelector( '.zunagi-calendar-filters' );
		if ( !filters ) {
			return;
		}

		var toggle = document.createElement( 'button' );
		toggle.type = 'button';
		toggle.className = 'zunagi-filters-toggle';
		toggle.textContent = 'Show filters';
		toggle.setAttribute( 'aria-expanded', 'false' );

		toggle.addEventListener( 'click', function () {
			var isOpen = filters.classList.toggle( 'is-open' );
			toggle.textContent = isOpen ? 'Hide filters' : 'Show filters';
			toggle.setAttribute( 'aria-expanded', isOpen ? 'true' : 'false' );
		} );

		filters.parentNode.insertBefore( toggle, filters );
	}

	if ( document.readyState === 'loading' ) {
		document.addEventListener( 'DOMContentLoaded', initFilterToggle );
	} else {
		initFilterToggle();
	}
}() );

        /* ==================================================
         * 14. WINDOW RESIZE
         * ==================================================
         */

        let resizeTimer =
            null;


        window.addEventListener(
            'resize',
            function () {

                clearTimeout(
                    resizeTimer
                );


                resizeTimer =
                    setTimeout(
                        function () {

                            dt.columns.adjust();

                            rebuildFixedHeader();

                            updateFixedHeaderVisibility();
                        },
                        150
                    );
            }
        );


        /* ==================================================
         * 15. ORIENTATION CHANGE
         * ==================================================
         */

        window.addEventListener(
            'orientationchange',
            function () {

                setTimeout(
                    function () {

                        dt.columns.adjust();

                        rebuildFixedHeader();

                        updateFixedHeaderVisibility();
                    },
                    300
                );
            }
        );

    } ).catch(
        function ( error ) {

            console.error(
                'Zunagi DataTables module failed to load:',
                error
            );
        }
    );

} );


/* ==========================================================
   ABVRS POINTS TABLE — EQUAL COLUMN WIDTHS
   ========================================================== */
 
$( function () {
 
    /*
     * table-layout:fixed with percentage column widths doesn't
     * reliably stretch to fill its wrapper in every browser once
     * the table sits inside the skin's flex-based grid layout
     * (Bootstrap's .row / .col-*) - the table's own outer box
     * stretches to 100% correctly, but its internal column grid
     * can fall back to each column's own content width instead,
     * leaving blank space to the right. Most visible on mobile,
     * where the page's flex column collapses to 100% width and
     * exposes the quirk (confirmed on zunagi.com directly: the
     * <table> box was the full container width, but its <tr>
     * inside was stuck at content width regardless of any CSS
     * width/table-layout/colgroup value thrown at it).
     *
     * Setting each column's width, min-width and max-width in
     * pixels — computed from the wrapper's own real rendered
     * width — forces every column to the same size regardless.
     * min-width matters as much as width here: table cells have
     * the same "shrink to at least my content" floor that flex
     * items do, and a plain width alone doesn't override it.
     */
 
    const tables =
        document.querySelectorAll( '.zunagi-points-table' );
 
    if ( !tables.length ) {
        return;
    }
 
    function fixPointsTableColumns() {
 
        tables.forEach( function ( table ) {
 
            const wrapper =
                table.closest( '.zunagi-points-table-wrapper' );
 
            if ( !wrapper || !table.rows.length ) {
                return;
            }
 
            const columnCount =
                table.rows[ 0 ].cells.length;
 
            if ( !columnCount ) {
                return;
            }
 
            const wrapperWidth =
                wrapper.clientWidth;
 
            if ( !wrapperWidth ) {
                return;
            }
 
            // Subtract a couple of pixels of headroom before dividing: an exact,
            // unpadded division (e.g. 800 / 5 = 160px each) can still overflow the
            // wrapper by a sub-pixel amount once collapsed cell borders are added in,
            // which is enough to trigger an unwanted scrollbar on an overflow:auto
            // wrapper even though the table visually looks like it fits.
            const columnWidth =
                Math.floor( ( wrapperWidth - 2 ) / columnCount ) + 'px';
 
            for ( let r = 0; r < table.rows.length; r++ ) {
 
                const row =
                    table.rows[ r ];
 
                for ( let c = 0; c < row.cells.length; c++ ) {
 
                    const cell =
                        row.cells[ c ];
 
                    cell.style.width = columnWidth;
                    cell.style.minWidth = columnWidth;
                    cell.style.maxWidth = columnWidth;
                }
            }
        } );
    }
 
    fixPointsTableColumns();
 
    // Re-run on resize/orientation change so rotating a phone (or resizing a
    // desktop window) recalculates the column width for the new wrapper size.
    let resizeTimer = null;
 
    window.addEventListener( 'resize', function () {
        clearTimeout( resizeTimer );
        resizeTimer = setTimeout( fixPointsTableColumns, 150 );
    } );
 
} );

/* ==========================================================
   NBVA POINTS TABLE — MOBILE-ONLY DIVISION TABS
   ========================================================== */

$( function () {

    const tabsContainer =
        document.querySelector( '.zunagi-nbva-points-tabs' );

    if ( !tabsContainer ) {
        return; // Not an NBVA event page - nothing to do.
    }

    const tabs =
        tabsContainer.querySelectorAll( '.zunagi-nbva-points-tab' );

    // Both the NBVA and ABVRS tables share this class, so one query and one
    // loop below covers whichever of the two (or both) are on the page.
    const tables =
        document.querySelectorAll( '.zunagi-points-table' );

    function isMobile() {
        return window.matchMedia( '(max-width: 900px)' ).matches;
    }

    // The equal-column-width fix (claude/abvrs-points-table.md, section 6)
    // sizes every column in pixels based on the table's *total* column
    // count, and runs before it has any idea some of those columns are
    // about to be hidden by the tabs below. Once columns are hidden, the
    // ones left visible are still sized for the old (larger) column count
    // and no longer fill the wrapper - visible as blank space to the right
    // of a narrower table (found live 2026-09-18). This recomputes
    // width/min-width/max-width using only the currently-visible columns,
    // so it has to be re-run every time applyTab() changes which columns
    // are shown.
    function recalculateVisibleColumnWidths( table ) {

        const wrapper =
            table.closest( '.zunagi-points-table-wrapper' );

        if ( !wrapper || !table.rows.length ) {
            return;
        }

        const visibleColumnCount =
            Array.prototype.filter.call(
                table.rows[ 0 ].cells,
                function ( cell ) {
                    return cell.style.display !== 'none';
                }
            ).length;

        const wrapperWidth =
            wrapper.clientWidth;

        if ( !visibleColumnCount || !wrapperWidth ) {
            return;
        }

        // Same -2px headroom as the original fix, to avoid a 1px overflow
        // scrollbar from collapsed cell borders.
        const columnWidth =
            Math.floor( ( wrapperWidth - 2 ) / visibleColumnCount ) + 'px';

        for ( let r = 0; r < table.rows.length; r++ ) {

            const row = table.rows[ r ];

            for ( let c = 0; c < row.cells.length; c++ ) {

                const cell = row.cells[ c ];

                if ( cell.style.display === 'none' ) {
                    continue;
                }

                cell.style.width = columnWidth;
                cell.style.minWidth = columnWidth;
                cell.style.maxWidth = columnWidth;
            }
        }
    }

    function applyTab( tabNumber ) {

        tabs.forEach( function ( tab ) {
            tab.classList.toggle(
                'active',
                tab.dataset.tab === String( tabNumber )
            );
        } );

        tables.forEach( function ( table ) {

            if ( !table.rows.length ) {
                return;
            }

            // Includes the "Place" column at index 0.
            const columnCount =
                table.rows[ 0 ].cells.length;

            for ( let r = 0; r < table.rows.length; r++ ) {

                const cells =
                    table.rows[ r ].cells;

                for ( let c = 1; c < cells.length; c++ ) { // skip Place

                    if ( !isMobile() ) {
                        // Desktop: tab bar is hidden by CSS - every column
                        // always shows, regardless of which tab was last
                        // clicked or whether the resize came from mobile.
                        cells[ c ].style.display = '';
                        continue;
                    }

                    // A table with 4 or fewer total columns (Place + <=3
                    // divisions, e.g. a 3-Star ABVRS table) has nothing
                    // beyond what tab 1 already shows - leave it fully
                    // visible on both tabs rather than showing an
                    // empty-looking table with only "Place" on tab 2.
                    if ( columnCount <= 4 ) {
                        cells[ c ].style.display = '';
                        continue;
                    }

                    const isFirstThreeDivisions =
                        c <= 3;

                    const shouldShow =
                        tabNumber === 1 ? isFirstThreeDivisions : !isFirstThreeDivisions;

                    cells[ c ].style.display =
                        shouldShow ? '' : 'none';
                }
            }

            // Re-fill the wrapper's width using only the columns left
            // visible above - see the function's own comment for why this
            // has to be redone every time, not just once on load.
            recalculateVisibleColumnWidths( table );

            // Swap the "Division 1 is..."/"Division 4 is..." subheading text
            // to match the active tab. The subheading is the element
            // immediately before this table's wrapper in the DOM
            // (zunagi-points-subheading, then zunagi-points-table-wrapper -
            // see Module:NBVA Points / Module:ABVRS Points). Only swaps on
            // mobile (desktop always shows every column, so the subheading
            // always reads as Division 1), and only when the module actually
            // supplied a tab-2 variant - a table with 4 or fewer total
            // columns never gets data-tab2-text, so its subheading is left
            // alone, same as its columns are on the guard above.
            const wrapper = table.closest( '.zunagi-points-table-wrapper' );
            const subheading = wrapper && wrapper.previousElementSibling;

            if (
                subheading &&
                subheading.classList.contains( 'zunagi-points-subheading' ) &&
                subheading.dataset.tab2Text
            ) {
                const showTab2Text = isMobile() && tabNumber === 2;
                subheading.textContent = showTab2Text ? subheading.dataset.tab2Text : subheading.dataset.tab1Text;
            }
        } );
    }

    let activeTab = 1;

    tabs.forEach( function ( tab ) {

        tab.addEventListener( 'click', function () {
            activeTab = Number( tab.dataset.tab );
            applyTab( activeTab );
        } );

        // <span role="button"> isn't natively keyboard-activatable the way a
        // real <button> is (MediaWiki's sanitizer strips raw <button> tags -
        // see the bug note above, which is why these are spans at all) -
        // Enter/Space need wiring up by hand to keep the tabs operable
        // without a mouse.
        tab.addEventListener( 'keydown', function ( e ) {
            if ( e.key === 'Enter' || e.key === ' ' ) {
                e.preventDefault();
                activeTab = Number( tab.dataset.tab );
                applyTab( activeTab );
            }
        } );
    } );

    applyTab( activeTab ); // Set the initial state (tab 1, columns trimmed) on page load.

    // Re-run on resize/orientation change so crossing the mobile breakpoint
    // re-evaluates whether columns should be hidden - same debounce pattern
    // as the ABVRS points table's own equal-column-width fix above.
    let resizeTimer = null;

    window.addEventListener( 'resize', function () {
        clearTimeout( resizeTimer );
        resizeTimer = setTimeout( function () {
            applyTab( activeTab );
        }, 150 );
    } );

} );