5
\$\begingroup\$

Among the mod-tools there is a few that are intended to just help get a bird's eye perspective on what happens on the site. These tools generally consist of tables. Tables over tables.

Stuff that would make an executive business analyst happy.

One of these tools reports some quite useful numbers as both absolute numbers and percentages. These percentages are pretty useful, but each of them is in a separate column.

The table columns basically follow this schema:

  • User
  • Metric A
  • Metric B
  • ...
  • Metric F
  • Metric A%
  • Metric B%
  • ...
  • Metric F%
  • Metric G
  • Metric H
  • ...

Not every metric has a corresponding percentage report. None of the percentage reports comes before its corresponding metric. Unfortunately there's a rather large number of these metrics, which results in the table exceeding the horizontal space available on my screen most of the time.

That's not useful, and neither is the separation between the Metrics and their percentage report. So I wrote the following user-script to collapse the percentage reports into the same column as the absolute numbers.

// ==UserScript==
// @name         Advanced Review Stats TableCollapser
// @namespace    http://github.com/Vogel612
// @version      1.0
// @description  Collapse review-stats columns
// @updateURL    https://raw.githubusercontent.com/Vogel612/mini-se-userscripts/master/advanced-review-stats-collapser.user.js
// @downloadURL  https://raw.githubusercontent.com/Vogel612/mini-se-userscripts/master/advanced-review-stats-collapser.user.js
// @author       Vogel612
// @match        *://*.stackexchange.com/admin/review/breakdown*
// @match        *://*.stackoverflow.com/admin/review/breakdown*
// @match        *://*.superuser.com/admin/review/breakdown*
// @match        *://*.serverfault.com/admin/review/breakdown*
// @match        *://*.askubuntu.com/admin/review/breakdown*
// @match        *://*.stackapps.com/admin/review/breakdown*
// @match        *://*.mathoverflow.net/admin/review/breakdown*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';
    let statsTable = document.querySelectorAll("#content .mainbar-full table")[0];
    let headers = Array.from(statsTable.getElementsByTagName("th"));
    let percentageHeaders = filterHeaders(headers);
    let collapsings = buildCollapseSpecs(headers, percentageHeaders);
    collapseTable(statsTable, collapsings);

    function filterHeaders(headers) {
        let percentageHeaders = [];
        for (let head of headers) {
            if (head.innerHTML.endsWith("%")) {
                percentageHeaders.push(head);
            }
        }
        return percentageHeaders;
    }

    function buildCollapseSpecs(headers, percentageHeaders) {
        let collapsings = [];
        for (let percentage of percentageHeaders) {
            let h = percentage.innerHTML;
            // drop trailing %
            let header = h.substr(0, h.length - 1);
            let from = headers.indexOf(percentage);
            // find matching non-percentage header
            var to = -1;
            for (let tableHead of headers) {
                if (tableHead.innerHTML === header) {
                    to = headers.indexOf(tableHead);
                    break;
                }
            }
            // we can't collapse upwards
            if (to !== -1 && to <= from) {
                collapsings.push({from: from, to: to});
            }
        }
        return collapsings;
    }


    function collapseTable(table, collapsings) {
        // sort collapsings by from descending to allow us to drop the column we're done with
        collapsings.sort((a,b) => { return b.from - a.from });

        // collapse the columns
        for (let collapse of collapsings) {
            for (let row of table.getElementsByTagName("tr")) {
                collapseRow(row, collapse);
            }
        }
    }

    function collapseRow(row, collapseSpec) {
        let from = row.children[collapseSpec.from];
        let percentageValue = from.innerHTML.trim();
        if (from.tagName === "TH") {
            row.children[collapseSpec.to].innerHTML += " (%)";
        } else {
            row.children[collapseSpec.to].innerHTML += " (" + percentageValue + ")";
        }
        row.removeChild(from);
    }
})();

How could this script be more extensible and maintainable?

\$\endgroup\$
1
\$\begingroup\$

Algorithm

The algorithm to build the collapse-specs is inefficient:

  1. It makes a pass over all headers to find the percentage headers
  2. Then for each percentage header:
    1. It finds the index using Array.indexOf, which is \$O(n)\$
    2. It loops over the headers to find the one corresponding to the percentage header by name, and again finds the index of that header using Array.indexOf

That is, the multiple Array.indexOf calls to find the index of header columns is inefficient, when the indexes could be recorded in one preparatory pass.

A simpler and more efficient alternative is possible:

  • Build a map of { name: {from: ..., to: ...} }, where name is the trailing % stripped from the column header being visited.
  • For each header:
    • If it's not a percentage header, update in the map the to value for the header's name
    • If it's a percentage header, update in the map the from value for the header's sanitized name
  • Extract from the map the values that have both from and to values -> this should be equivalent to the collapsings in the posted code

Functional programming

Functional writing style can make the code more compact, and potentially more natural and easier to read. For example you could replace filterHeaders with this one-liner:

return headers.filter(head => head.innerHTML.endsWith("%"))

Similarly, the sort can be written simpler too:

collapsings.sort((a, b) => b.from - a.from);

Other parts of the script can be written simpler too, using functional features, but I suggest to change the algorithm first before optimizing the existing code.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.