assets/js/main.js

/* ==========================================================================
   Dashboard auto-refresh
   ========================================================================== */

/*
|--------------------------------------------------------------------------
| Automatic refresh
|--------------------------------------------------------------------------
|
| Refresh the dashboard at the configured interval while preserving
| the current view. Refreshes are suspended whenever a modal dialog
| is open to avoid interrupting user interactions.
|
*/

(() => {
    const seconds = Number(window.BRIVACIA_AUTO_REFRESH_SECONDS || 0);

    if (seconds <= 0) {
        return;
    }

    function hasOpenModal() {
        return Boolean(document.querySelector('dialog[open], dialog.modal[open], .modal[open], .modal.is-open') || document.body.dataset.updating === '1');
    }

    window.setInterval(() => {
        if (hasOpenModal()) {
            return;
        }

        window.location.reload();
    }, seconds * 1000);
})();


/* ==========================================================================
   Date picker
   ========================================================================== */

/*
|--------------------------------------------------------------------------
| Native date picker support
|--------------------------------------------------------------------------
|
| iOS handles date-related input types differently from most browsers.
| This section restores the expected picker behaviour and keeps the
| dashboard date selector consistent across platforms.
|
*/

const isIOS =
    /iPad|iPhone|iPod/.test(navigator.userAgent) ||
    (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);

if (isIOS) {
    document.documentElement.classList.add('ios');

    const periodInput = document.getElementById('period');

    if (periodInput?.dataset.iosType) {
        periodInput.type = periodInput.dataset.iosType;
        periodInput.value = periodInput.dataset.iosValue ?? periodInput.value;
    }
}

const dateButton = document.querySelector('button[data-period-type]');
const dateInput = document.querySelector('.date-form input:not([type="hidden"])');

dateButton?.addEventListener('click', () => {
    dateInput?.showPicker?.();
});


/* ==========================================================================
   Dropdowns
   ========================================================================== */

/*
|--------------------------------------------------------------------------
| Shared dropdown behaviour
|--------------------------------------------------------------------------
|
| Used by export, import and future dropdown menus. Only one menu can
| remain open at a time and all menus close automatically when the
| user clicks elsewhere or presses Escape.
|
*/

const dropdowns = document.querySelectorAll('[data-dropdown]');

function closeMenus(selector = '[data-dropdown-menu], [data-dropdown-submenu]') {
    document.querySelectorAll(selector).forEach((menu) => {
        menu.hidden = true;
    });
}

dropdowns.forEach((dropdown) => {
    const toggle = dropdown.querySelector(':scope > [data-dropdown-toggle]');
    const menu = dropdown.querySelector(':scope > [data-dropdown-menu], :scope > [data-dropdown-submenu]');

    if (!toggle || !menu) {
        return;
    }

    toggle.addEventListener('click', (event) => {
        event.stopPropagation();

        const isSubmenu = menu.hasAttribute('data-dropdown-submenu');
        const willOpen = menu.hidden;

        if (isSubmenu) {
            dropdown.parentElement
                ?.querySelectorAll(':scope > [data-dropdown] > [data-dropdown-submenu]')
                .forEach((submenu) => {
                    if (submenu !== menu) {
                        submenu.hidden = true;
                    }
                });
        } else {
            closeMenus();
        }

        menu.hidden = !willOpen;
    });
});

document.addEventListener('click', () => closeMenus());

document.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') {
        closeMenus();
    }
});


/* ==========================================================================
   Internationalization
   ========================================================================== */

/*
|--------------------------------------------------------------------------
| Locale-aware formatting
|--------------------------------------------------------------------------
|
| Use the visitor's preferred browser language for dates, times and
| country names so the dashboard feels native without requiring
| additional translations for generated values.
|
*/

const lang = navigator.language;

const dateFormatter = new Intl.DateTimeFormat(lang, {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit'
});

const timeFormatter = new Intl.DateTimeFormat(lang, {
    hour: 'numeric',
    minute: '2-digit'
});

const countryNames = new Intl.DisplayNames([lang], { type: 'region' });

function formatPeriodLabel(el, now = new Date()) {
    const type = el.dataset.periodType;
    const value = el.dataset.periodValue;

    if (type === 'today') {
        return dateFormatter.format(now);
    }

    if (type === 'week') {
        const week = value.split('-W')[1] ?? value;
        return `${el.dataset.weekLabel ?? 'Week'} ${week}`;
    }

    if (type === 'month') {
        const text = new Intl.DateTimeFormat(lang, {
            year: 'numeric',
            month: 'long'
        }).format(new Date(value + '-01T00:00:00'));

        return text.charAt(0).toUpperCase() + text.slice(1);
    }

    if (type === 'year') {
        return value.slice(0, 4);
    }

    if (type === 'all') {
        return el.dataset.allLabel ?? el.textContent.trim();
    }

    return el.textContent.trim();
}

function updateDateTime() {
    const now = new Date();
    const currentDate = dateFormatter.format(now);
    const currentTime = timeFormatter.format(now);

    for (const el of document.querySelectorAll('[data-live-date]')) {
        el.textContent = currentDate;
    }

    for (const el of document.querySelectorAll('[data-current-time]')) {
        el.textContent = currentTime;
    }
}

updateDateTime();
setInterval(updateDateTime, 1000);

for (const el of document.querySelectorAll('span[data-country]')) {
    if (el.dataset.country === 'XX') {
        continue;
    }

    el.textContent =
        countryNames.of(el.dataset.country) ??
        el.dataset.country;
}


/* ==========================================================================
   Modals
   ========================================================================== */

/*
|--------------------------------------------------------------------------
| Modal helpers
|--------------------------------------------------------------------------
|
| Keep modal behaviour centralized:
| - open dialogs
| - close dialogs when clicking outside their content
| - remove focus from fields when a modal is opened or closed
|
*/

function openModal(selector) {
    document
        .querySelector(selector)
        ?.closest('.modal')
        ?.showModal();
}

function blurModalFields(modal) {
    modal
        ?.querySelectorAll('input, select, textarea, button')
        ?.forEach((element) => element.blur());
}

function closeModal(event) {
    if (!event.target.classList.contains('modal')) {
        return;
    }

    blurModalFields(event.target);
    event.target.close();
}

/*
|--------------------------------------------------------------------------
| Modal initialization
|--------------------------------------------------------------------------
|
| Attach close handlers to all dialogs present in the page.
|
*/

for (const modal of document.querySelectorAll('.modal')) {
    modal.addEventListener('click', closeModal);

    modal.addEventListener('close', () => {
        blurModalFields(modal);
        document.body.style.overflow = '';
    });
}

/*
|--------------------------------------------------------------------------
| Lazy-loaded import providers
|--------------------------------------------------------------------------
|
| Import tools are rarely used during normal dashboard navigation.
| Load the import providers script only when an import modal is opened,
| exactly like Prism is loaded only when the code modal is opened.
|
*/

let importsLoaded = false;

async function loadImports(modal = document.getElementById('import-modal')) {
    if (!importsLoaded) {
        importsLoaded = true;

        try {
            await loadScript('/assets/js/imports.min.js');
        } catch (error) {
            importsLoaded = false;
            throw error;
        }
    }

    window.brivaciaSetImportProvider?.(
        modal?.dataset.importProvider || 'matomo'
    );
}

/*
|--------------------------------------------------------------------------
| Modal triggers
|--------------------------------------------------------------------------
|
| Buttons use data-modal-open="<modal-id>".
| The matching dialog is opened directly from the DOM.
|
*/

for (const button of document.querySelectorAll('[data-modal-open]')) {
    button.addEventListener('click', async () => {
        const modal = document.getElementById(button.dataset.modalOpen);

        if (!modal) {
            return;
        }

        if (button.dataset.importProvider) {
            modal.dataset.importProvider = button.dataset.importProvider;
        }

        modal.showModal();

        if (modal.hasAttribute('data-import')) {
            loadImports(modal);
        }

        if (modal.hasAttribute('data-prism')) {
            loadPrism();
        }

        /*
        |--------------------------------------------------------------------------
        | Mobile usability
        |--------------------------------------------------------------------------
        |
        | Prevent mobile browsers from focusing the first input
        | and automatically opening the virtual keyboard.
        |
        */

        requestAnimationFrame(() => {
            blurModalFields(modal);
            modal.scrollTo?.(0, 0);
            modal.querySelector(':scope > div')?.scrollTo?.(0, 0);
        });
    });
}


/* ==========================================================================
   Settings
   ========================================================================== */

(() => {
    const form = document.getElementById('settings-form');
    const sites = document.getElementById('settings-sites');
    const template = document.getElementById('settings-site-template');
    const message = document.getElementById('settings-message');

    const settingsText = {
        saved: message?.dataset.saved ?? 'Settings saved.',
        error: message?.dataset.error ?? 'Unable to save settings.',
        networkError: message?.dataset.networkError ?? 'Network error.'
    };

    if (!form) {
        return;
    }

    function setMessage(text, status = '') {
        if (!message) {
            return;
        }

        message.className = status;
        message.textContent = text || '';
    }

    function closeSettingsModal() {
        form.closest('dialog')?.close();
    }

    function siteList() {
        return sites?.querySelector(':scope > div') || null;
    }

    function reindexSites() {
        const list = siteList();

        if (!list) return;

        [...list.children].forEach((fieldset, index) => {
            const code = fieldset.querySelector('input[name$="[code]"]');
            const domain = fieldset.querySelector('input[name$="[domain]"]');

            if (code) code.name = `sites[${index}][code]`;
            if (domain) domain.name = `sites[${index}][domain]`;
        });
    }

    function addSite() {
        const list = siteList();

        if (!list || !template) return;

        const index = list.children.length;
        const wrapper = document.createElement('div');

        wrapper.innerHTML = template.innerHTML
            .replaceAll('__INDEX__', String(index))
            .trim();

        if (wrapper.firstElementChild) {
            list.appendChild(wrapper.firstElementChild);
        }
    }

    function removeSite(button) {
        const list = siteList();
        const fieldset = button.closest('fieldset');

        if (!list || !fieldset || list.children.length <= 1) return;

        fieldset.remove();
        reindexSites();
    }

    function formToObject() {
        const data = new FormData(form);
        const result = {};

        for (const [name, value] of data.entries()) {
            if (!name.startsWith('sites[')) {
                result[name] = value;
            }
        }

        form.querySelectorAll('input[type="checkbox"]').forEach((input) => {
            result[input.name] = input.checked;
        });

        result.sites = [];

        siteList()?.querySelectorAll(':scope > fieldset').forEach((fieldset) => {
            const code = fieldset.querySelector('input[name$="[code]"]')?.value.trim() || '';
            const domain = fieldset.querySelector('input[name$="[domain]"]')?.value.trim() || '';

            if (code !== '' || domain !== '') {
                result.sites.push({ code, domain });
            }
        });

        return result;
    }

    form.addEventListener('click', (event) => {
        const button = event.target.closest('button');

        if (!button) return;

        if (button.value === 'cancel') {
            closeSettingsModal();
            return;
        }

        if (button.value === 'add-site') {
            addSite();
            return;
        }

        if (button.value === 'remove-site') {
            removeSite(button);
        }
    });

    form.addEventListener('submit', async (event) => {
        event.preventDefault();
        setMessage('');

        try {
            const response = await fetch('/api/settings.php?action=save', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(formToObject()),
            });

            const data = await response.json();

            if (!response.ok || !data.ok) {
                setMessage(data.error || settingsText.error, 'danger');
                return;
            }

            setMessage(settingsText.saved, 'success');

            setTimeout(() => {
                closeSettingsModal();
                window.location.reload();
            }, 600);
        } catch {
            setMessage(settingsText.networkError, 'warning');
        }
    });
})();


/* ==========================================================================
   Graphs
   ========================================================================== */

const graph = document.querySelector('.graph');

const graphText = {
    error: graph?.dataset.error ?? 'Unable to generate the chart',
    generating: graph?.dataset.generating ?? 'Generating chart...',
    noData: graph?.dataset.noData ?? 'No data available'
};

function graphModal() {
    return document.querySelector('dialog.modal:has(.graph)');
}

function resetGraphModal() {
    const modal = graphModal();

    if (!modal) {
        return;
    }

    modal.id = 'graph-modal';
    modal.removeAttribute('data-graph');
}

async function loadGraph(metric) {
    if (!graph) {
        return;
    }

    graph.textContent = graphText.generating;

    try {
        const params = new URLSearchParams(window.location.search);
        const lang = navigator.language || document.documentElement.lang || 'en';

        params.set('metric', metric);
        params.set('lang', lang);

        const response = await fetch(`/api/graphs.php?${params.toString()}`);

        if (!response.ok) {
            const message = (await response.text()).trim();

            graph.textContent = message !== ''
                ? message
                : graphText.error;

            return;
        }

        const html = await response.text();

        graph.innerHTML = html.trim() !== ''
            ? html
            : graphText.noData;

    } catch {
        graph.textContent = graphText.error;
    }
}

graphModal()?.addEventListener('close', resetGraphModal);

for (const button of document.querySelectorAll('[data-graph]')) {
    button.addEventListener('click', async () => {
        const metric = button.dataset.graph;
        const modal = document.getElementById('graph-modal');

        if (modal && metric) {
            modal.id = metric;
            modal.dataset.graph = metric;
        }

        openModal('.graph');
        await loadGraph(metric);
    });
}


/* ==========================================================================
   Graph: Interactive map
   ========================================================================== */

let mapScale = 1;
let mapTranslateX = 0;
let mapTranslateY = 0;
let mapTouchDistance = null;
let mapDragging = false;
let mapDragStartX = 0;
let mapDragStartY = 0;

function applyMapTransform(map) {
    map.querySelector('svg').style.transform =
        `translate(${mapTranslateX}px, ${mapTranslateY}px) scale(${mapScale})`;
}

function setMapScale(map, scale) {
    mapScale = Math.max(1, Math.min(scale, 8));

    if (mapScale === 1) {
        mapTranslateX = 0;
        mapTranslateY = 0;
    }

    applyMapTransform(map);
}

function touchDistance(touches) {
    const dx = touches[0].clientX - touches[1].clientX;
    const dy = touches[0].clientY - touches[1].clientY;

    return Math.hypot(dx, dy);
}

/*
|--------------------------------------------------------------------------
| Zoom origin
|--------------------------------------------------------------------------
|
| Update the SVG transform origin so zooming occurs around the cursor
| (or pinch center on touch devices) instead of always zooming from
| the middle of the map.
|
*/

function setMapOriginFromPoint(map, clientX, clientY) {
    const svg = map.querySelector('svg');
    const rect = svg.getBoundingClientRect();

    const x = ((clientX - rect.left) / rect.width) * 100;
    const y = ((clientY - rect.top) / rect.height) * 100;

    svg.style.transformOrigin = `${x}% ${y}%`;
}

document.addEventListener('wheel', (event) => {
    const map = event.target.closest('.graph-map');

    if (!map) {
        return;
    }

    event.preventDefault();

    setMapOriginFromPoint(map, event.clientX, event.clientY);

    setMapScale(
        map,
        mapScale * (event.deltaY > 0 ? 0.9 : 1.1)
    );
}, { passive: false });

document.addEventListener('pointerdown', (event) => {
    const map = event.target.closest('.graph-map');

    if (!map || mapScale <= 1 || event.pointerType === 'touch') {
        return;
    }

    mapDragging = true;
    mapDragStartX = event.clientX - mapTranslateX;
    mapDragStartY = event.clientY - mapTranslateY;
});

document.addEventListener('pointermove', (event) => {
    const map = event.target.closest('.graph-map');

    if (!map || !mapDragging) {
        return;
    }

    mapTranslateX = event.clientX - mapDragStartX;
    mapTranslateY = event.clientY - mapDragStartY;

    applyMapTransform(map);
});

document.addEventListener('pointerup', () => {
    mapDragging = false;
});

document.addEventListener('touchstart', (event) => {
    const map = event.target.closest('.graph-map');

    if (!map) {
        return;
    }

    if (event.touches.length === 2) {
        event.preventDefault();
        mapTouchDistance = touchDistance(event.touches);
        return;
    }

    if (event.touches.length === 1 && mapScale > 1) {
        event.preventDefault();

        mapDragging = true;
        mapDragStartX = event.touches[0].clientX - mapTranslateX;
        mapDragStartY = event.touches[0].clientY - mapTranslateY;
    }
}, { passive: false });

document.addEventListener('touchmove', (event) => {
    const map = event.target.closest('.graph-map');

    if (!map) {
        return;
    }

    if (event.touches.length === 2 && mapTouchDistance !== null) {
        event.preventDefault();

        const distance = touchDistance(event.touches);
        const ratio = distance / mapTouchDistance;

        const centerX = (event.touches[0].clientX + event.touches[1].clientX) / 2;
        const centerY = (event.touches[0].clientY + event.touches[1].clientY) / 2;

        setMapOriginFromPoint(map, centerX, centerY);
        setMapScale(map, mapScale * ratio);

        mapTouchDistance = distance;
        return;
    }

    if (event.touches.length === 1 && mapDragging && mapScale > 1) {
        event.preventDefault();

        mapTranslateX = event.touches[0].clientX - mapDragStartX;
        mapTranslateY = event.touches[0].clientY - mapDragStartY;

        applyMapTransform(map);
    }
}, { passive: false });

document.addEventListener('touchend', () => {
    mapTouchDistance = null;
    mapDragging = false;
});


/* ==========================================================================
   Tooltips
   ========================================================================== */

/*
|--------------------------------------------------------------------------
| Dynamic tooltips
|--------------------------------------------------------------------------
|
| A single tooltip system is shared across the dashboard. Tooltips are
| created on demand and automatically positioned to remain visible
| inside the viewport or modal dialog.
|
*/

let tooltipElement = null;
let tooltipTarget = null;

function tooltipTargetFrom(event) {
    let element = event.target;

    while (element && element !== document) {
        if (element.dataset?.tooltip) {
            return element;
        }

        element = element.parentNode;
    }

    return null;
}

document.addEventListener('pointerover', (event) => {
    const element = tooltipTargetFrom(event);

    if (!element || tooltipTarget === element) {
        return;
    }

    openTooltip(element, event);
});

document.addEventListener('pointerout', (event) => {
    const element = tooltipTargetFrom(event);

    if (!element) {
        return;
    }

    if (event.relatedTarget && element.contains(event.relatedTarget)) {
        return;
    }

    closeTooltip();
});

document.addEventListener('focusin', (event) => {
    const element = tooltipTargetFrom(event);

    if (element) {
        openTooltip(element);
    }
});

document.addEventListener('focusout', (event) => {
    if (tooltipTargetFrom(event)) {
        closeTooltip();
    }
});

document.addEventListener('pointerdown', (event) => {
    if (!tooltipTargetFrom(event)) {
        closeTooltip();
    }
});

function openTooltip(element, event = null) {
    closeTooltip();

    tooltipTarget = element;
    tooltipElement = document.createElement('div');

    tooltipElement.setAttribute('data-tooltip-open', '');

    if (element.dataset.tooltipClass) {
        tooltipElement.classList.add(element.dataset.tooltipClass);
    }

    if (element.dataset.tooltipIcon) {
        tooltipElement.innerHTML =
            `<img alt="" class="tooltip-icon" src="${element.dataset.tooltipIcon}">` +
            `<span>${element.dataset.tooltip ?? ''}</span>`;
    } else {
        tooltipElement.textContent = element.dataset.tooltip ?? '';
    }

    (element.closest('dialog') ?? document.body).appendChild(tooltipElement);

    const margin = parseFloat(getComputedStyle(document.documentElement).fontSize);
    const gap = margin;

    const rect = element.getBoundingClientRect();
    const tooltipRect = tooltipElement.getBoundingClientRect();

    if (element.dataset.tooltipClass === 'graph-tooltip' && event && !element.dataset.tooltipPlacement) {
        const containerRect = (
            element.closest('dialog') ??
            document.body
        ).getBoundingClientRect();

        let left = event.clientX - (tooltipRect.width / 2) - containerRect.left;
        let top = event.clientY + gap - containerRect.top;

        if (event.clientY + gap + tooltipRect.height > window.innerHeight - margin) {
            top = event.clientY - tooltipRect.height - gap - containerRect.top;
        }

        left = Math.max(
            margin,
            Math.min(left, containerRect.width - tooltipRect.width - margin)
        );

        tooltipElement.style.setProperty('--tooltip-left', `${left}px`);
        tooltipElement.style.setProperty('--tooltip-top', `${top}px`);
        tooltipElement.setAttribute('data-tooltip-placement', 'graph');

        requestAnimationFrame(() => {
            tooltipElement?.setAttribute('data-tooltip-visible', '');
        });

        return;
    }

    let left;
    let top;
    let placement = element.dataset.tooltipPlacement ?? 'bottom';

    if (placement === 'left' && window.matchMedia('(hover: none)').matches) {
        placement = 'top';
    }

    if (placement === 'left') {
        left = rect.left - tooltipRect.width - gap;
        top = rect.top + (rect.height / 2) - (tooltipRect.height / 2);
    } else if (placement === 'right') {
        left = rect.right + gap;
        top = rect.top + (rect.height / 2) - (tooltipRect.height / 2);
    } else {
        left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
        top = rect.bottom + gap;

        if (top + tooltipRect.height > window.innerHeight - margin) {
            top = rect.top - tooltipRect.height - gap;
            placement = 'top';
        }
    }

    left = Math.max(margin, Math.min(left, window.innerWidth - tooltipRect.width - margin));
    top = Math.max(margin, Math.min(top, window.innerHeight - tooltipRect.height - margin));

    tooltipElement.setAttribute('data-tooltip-placement', placement);
    tooltipElement.style.setProperty('--tooltip-left', `${left}px`);
    tooltipElement.style.setProperty('--tooltip-top', `${top}px`);

    requestAnimationFrame(() => {
        tooltipElement?.setAttribute('data-tooltip-visible', '');
    });
}

function closeTooltip() {
    if (!tooltipElement) {
        return;
    }

    const currentTooltip = tooltipElement;

    currentTooltip.removeAttribute('data-tooltip-visible');

    setTimeout(() => {
        currentTooltip.remove();
    }, 120);

    tooltipElement = null;
    tooltipTarget = null;
}


/* ==========================================================================
   Prism
   ========================================================================== */

/*
|--------------------------------------------------------------------------
| Deferred syntax highlighting
|--------------------------------------------------------------------------
|
| Prism is loaded only when the integration code modal is opened.
| This avoids downloading the highlighting library for visitors who
| never view code snippets.
|
*/

let prismLoaded = false;

async function loadPrism() {
    if (prismLoaded) {
        return;
    }

    prismLoaded = true;

    await Promise.all([
        loadScript('/assets/js/prism.min.js')
    ]);

    Prism.highlightAllUnder(
        document.getElementById('pixel-modal')
    );
}

function loadScript(src) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');

        script.src = src;
        script.onload = resolve;
        script.onerror = reject;

        document.head.appendChild(script);
    });
}


/* ==========================================================================
   Privacy transparency
   ========================================================================== */

/*
|--------------------------------------------------------------------------
| Transparency modal
|--------------------------------------------------------------------------
|
| Display runtime information that may be useful to privacy-conscious
| visitors, such as the browser language detected by Brivacia.
|
*/

const privacyButton = document.querySelector('[data-privacy-open]');
const browserLanguage = document.querySelector('[data-browser-language]');

privacyButton?.addEventListener('click', () => {
    if (browserLanguage) {
        browserLanguage.textContent = navigator.language || 'no data';
    }

    openModal('.privacy');
});


/* ==========================================================================
   Update
   ========================================================================== */

(() => {
    const button = document.getElementById('update-install');

    if (!button) return;

    const originalHtml = button.innerHTML;

    const available = document.getElementById('update-available');
    const progress = document.getElementById('update-progress');
    const progressText = document.getElementById('update-progress-text');

    const labels = {
        download: button.dataset.updateDownload,
        extract: button.dataset.updateExtract,
        verify: button.dataset.updateVerify,
        install: button.dataset.updateInstalling,
        cleanup: button.dataset.updateCleanup,
    };

    let timer = null;

    const pollProgress = async () => {
        try {
            const response = await fetch('/api/update.php?progress=1', { cache: 'no-store' });
            const data = await response.json();

            if (data.running && data.step && labels[data.step]) {
                progressText.textContent = labels[data.step];
            }
        } catch {}
    };

    button.addEventListener('click', async event => {
        event.preventDefault();
        event.stopImmediatePropagation();

        console.log('Brivacia update clicked');

        if (button.disabled) {
            return;
        }

        button.disabled = true;
        document.body.dataset.updating = '1';

        available.hidden = true;
        progress.hidden = false;

        progressText.textContent = button.dataset.updateStarting;

        timer = setInterval(pollProgress, 400);

        try {
            console.log('Brivacia update POST start');

            const response = await fetch('/api/update.php', {
                method: 'POST',
                cache: 'no-store',
            });

            console.log('Brivacia update POST response', response.status);

            const text = await response.text();
            console.log('Brivacia update raw response', text);

            const data = JSON.parse(text);

            if (!response.ok || !data.ok) {
                throw new Error(data.error || button.dataset.updateFailed);
            }

            clearInterval(timer);

            progressText.textContent = button.dataset.updateReload;

            await new Promise(resolve => setTimeout(resolve, 1200));

            window.location.reload();
        } catch (error) {
            console.error('Brivacia update failed', error);

            clearInterval(timer);
            delete document.body.dataset.updating;
            button.disabled = false;

            available.hidden = false;
            progress.hidden = true;

            button.innerHTML = originalHtml;
            alert(error.message || button.dataset.updateFailed);
        }
    });
})();