Waze Scan Closures

Passively scan for road closures and get segment/primaryStreet/city/country details.

// ==UserScript==
// @name         Waze Scan Closures
// @namespace    https://github.com/WazeDev/waze-scan-closures
// @version      0.0.17
// @description  Passively scan for road closures and get segment/primaryStreet/city/country details.
// @author       Gavin Canon-Phratsachack (https://github.com/gncnpk)
// @match        https://beta.waze.com/*editor*
// @match        https://www.waze.com/*editor*
// @exclude      https://www.waze.com/*user/*editor/*
// @exclude      https://www.waze.com/discuss/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=waze.com
// @license      MIT
// @contributionURL https://github.com/WazeDev/Thank-The-Authors
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      wsc.gc-p.zip
// ==/UserScript==

(function() {
    'use strict';
    unsafeWindow.SDK_INITIALIZED.then(init);
    let sdk;
    let userReportedClosures = [];
    let trackedClosures = [];
    let wazeEditorName;
    let url = localStorage.getItem("waze-scan-closures-url") || "https://wsc.gc-p.zip";
    let endpoints = {
        "TRACKED_CLOSURES": `${url}/trackedClosures`,
        "UPLOAD_CLOSURES": `${url}/uploadClosures`
    }

    async function init() {
        sdk = unsafeWindow.getWmeSdk({
            scriptId: 'wme-scan-closures',
            scriptName: 'Waze Scan Closures'
        });
        while (sdk.State.getUserInfo() === null) {
            console.log("Waze Scan Closures: Waiting for user to be logged in...");
            await new Promise(resolve => setTimeout(resolve, 1000));
        }
        wazeEditorName = sdk.State.getUserInfo().userName;
        console.log(`Waze Scan Closures: Logged in as ${wazeEditorName}`);
        getTrackedClosures();
        sdk.Events.trackDataModelEvents({
            dataModelName: "roadClosures"
        });
        sdk.Events.on({
            eventName: "wme-data-model-objects-added",
            eventHandler: updateRoadClosures
        });
        userReportedClosures = filterUserClosures(sdk.DataModel.RoadClosures.getAll());
        sdk.Sidebar.registerScriptTab().then(async (res) => {
            res.tabLabel.innerText = "WSC";
            // Create text area for inputting url, update variable when value changes
            res.tabPane.innerHTML = `
                <div>
                    <label for="WSCApiUrl">API URL:</label>
                    <input type="text" id="WSCApiUrl" value="${url}" style="width: 100%;" />
                </div>
            `;
            res.tabPane.querySelector("#WSCApiUrl").addEventListener("input", (e) => {
                url = e.target.value;
                localStorage.setItem("waze-scan-closures-url", url);
                endpoints["TRACKED_CLOSURES"] = `${url}/trackedClosures`;
                endpoints["UPLOAD_CLOSURES"] = `${url}/uploadClosures`;
            });
        });
        console.log(`Waze Scan Closures: Initialized!`);
    }

    function getTrackedClosures() {
        if (url === "" || wazeEditorName === undefined || wazeEditorName === null) {
            console.error("Waze Scan Closures: URL not set!");
            return;
        }
        let data = {
            userName: wazeEditorName,
            env: sdk.Settings.getRegionCode()
        }
        let details = {
            method: "POST",
            data: JSON.stringify(data),
            url: endpoints["TRACKED_CLOSURES"],
            headers: {
                "Content-Type": "application/json"
            },
            onload: function(response) {
                let trkRes = JSON.parse(response.responseText);
                console.log(`Waze Scan Closures: Retrieved ${trkRes.length} tracked closures!`);
                trackedClosures = trkRes;
            }
        };
        console.log(`Waze Scan Closures: Retriving tracked closures...`);
        GM_xmlhttpRequest(details);
    }

    // Allowed durations (in ms):
    const ALLOWED_DURATIONS = [
        30 * 60 * 1000, // 30 minutes
        1 * 60 * 60 * 1000, // 1 hour
        5 * 60 * 60 * 1000, // 5 hours
        16 * 60 * 60 * 1000, // 16 hours
        72 * 60 * 60 * 1000 // 72 hours
    ];

    // Margin of error around each target (1 minute = 60 000 ms)
    const MARGIN = 60 * 1000;

    function filterUserClosures(closures) {
        return closures.filter(c => {
            // must have no description, valid dates, and not already tracked
            if (
                c.description ||
                !c.startDate ||
                !c.endDate ||
                trackedClosures.includes(c.id)
            ) {
                return false;
            }

            // compute actual duration in ms
            const duration =
                new Date(c.endDate).getTime() -
                new Date(c.startDate).getTime();

            // check if it matches any allowed duration within the margin
            return ALLOWED_DURATIONS.some(
                target => Math.abs(duration - target) <= MARGIN
            );
        });
    }

    function removeObjectProperties(obj, props) {

        for (var i = 0; i < props.length; i++) {
            if (obj.hasOwnProperty(props[i])) {
                delete obj[props[i]];
            }
        }

    };

    function updateRoadClosures() {
        let currentUserReportedClosures = filterUserClosures(
            sdk.DataModel.RoadClosures.getAll()
        );
        if (currentUserReportedClosures.length !== 0) {
            userReportedClosures = currentUserReportedClosures;
            console.log(
                `Waze Scan Closures: Found ${userReportedClosures.length} user reported ` +
                'closures!'
            );

            // helper: convert ms → "Xh Ym"
            const formatDuration = ms => {
                const totalMin = Math.round(ms / 60000);
                const hrs = Math.floor(totalMin / 60);
                const mins = totalMin % 60;
                let str = '';
                if (hrs) str += `${hrs}h`;
                if (mins) str += `${hrs ? ' ' : ''}${mins}m`;
                return str || '0m';
            };

            userReportedClosures.forEach(i => {
                // track
                trackedClosures.push(i.id);

                // fetch segment & geometry
                if (i.segmentId !== null) {
                    i.segment = sdk.DataModel.Segments.getById({
                        segmentId: i.segmentId
                    });
                }
                if (i.segment) {
                    i.roadType =
                        I18n.t('segment.road_types')[i.segment.roadType];
                    i.roadTypeEnum = i.segment.roadType;
                    i.lon = i.segment.geometry.coordinates
                        .reduce((s, c) => s + c[0], 0) /
                        i.segment.geometry.coordinates.length;
                    i.lat = i.segment.geometry.coordinates
                        .reduce((s, c) => s + c[1], 0) /
                        i.segment.geometry.coordinates.length;
                    i.primaryStreet = sdk.DataModel.Streets.getById({
                        streetId: i.segment.primaryStreetId
                    });
                }

                // build human-readable location
                const location = [];
                if (i.primaryStreet) {
                    i.city = sdk.DataModel.Cities.getById({
                        cityId: i.primaryStreet.cityId
                    });
                    location.push(
                        i.primaryStreet.englishName || i.primaryStreet.name
                    );
                }
                if (i.city) {
                    i.state = sdk.DataModel.States.getById({
                        stateId: i.city.stateId
                    });
                    i.country = sdk.DataModel.Countries.getById({
                        countryId: i.city.countryId
                    });
                    location.push(i.city.name);
                }
                if (i.state) {
                    delete i.state.geometry;
                    location.push(i.state.name);
                }
                if (i.country) {
                    removeObjectProperties(i.country, [
                        'restrictionSubscriptions',
                        'defaultLaneWidthPerRoadType'
                    ]);
                    location.push(i.country.name);
                }
                i.location = location.join(', ');

                // metadata
                i.createdBy = i.modificationData.createdBy;
                i.createdOn = i.modificationData.createdOn;
                i.direction = i.isForward ? 'A➜B' : 'B➜A';

                // ← NEW: compute duration
                const durationMs =
                    new Date(i.endDate).getTime() -
                    new Date(i.startDate).getTime();
                i.durationMs = durationMs;
                i.duration = formatDuration(durationMs);

                // strip out unneeded props before upload
                removeObjectProperties(i, [
                    'city',
                    'state',
                    'country',
                    'primaryStreet',
                    'isPermanent',
                    'description',
                    'endDate',
                    'modificationData',
                    'startDate',
                    'isForward',
                    'segment',
                    'status',
                    'trafficEventId'
                ]);
            });

            const uploadData = {
                // bbox: sdk.Map.getMapExtent(),
                userName: wazeEditorName,
                closures: userReportedClosures
            };
            sendClosures(uploadData);
        } else {
            console.log('Waze Scan Closures: No new closures found...');
        }
    }

    function sendClosures(uploadData) {
        if (url === "" || wazeEditorName === undefined || wazeEditorName === null) {
            console.error("Waze Scan Closures: URL not set!");
            return;
        }
        // use GM_xmlhttpRequest(details)
        let details = {
            method: "POST",
            url: endpoints["UPLOAD_CLOSURES"],
            data: JSON.stringify(uploadData),
            headers: {
                "Content-type": "application/json; charset=UTF-8"
            }
        };
        GM_xmlhttpRequest(details);
        getTrackedClosures();
    }
})();