MediaWiki:Common.js

From zunagi

Revision as of 15:32, 18 September 2026 by Lee G (talk | contribs)
(diff) โ† Older revision | Latest revision (diff) | Newer revision โ†’ (diff)

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (โŒ˜-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (โŒ˜-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* ==========================================================
   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 );
    } );

} );