The Ranker

Objective: Make a ranking tool that using a head to head 'this vs that' style to build a ranked list

Background:  I do so many ranked lists and think a lot about what good qualities a ranking has. One problem to solve about sitting down to do a ranking is starting with a bias. Taking away as much big picture bias and boiling it down to just individial choices is one way to limit that bias and come up with a more objective list of your subjective feelings.

Tools used: NVIM 

Method:
1) Create an input form
2) Write the ranking logic
3) lots of tuning to make the user experience better
4) bonus: download to csv function
5) integrate into site

Activity image
Activity image

This ranker uses binary insertion. It shuffles all items first, places the first winner automatically, then for each new item, it inserts it into the correct position in the already sorted list. At each step, it compares the new item to the midpoint of the current range, adjusting the range based on whether the new item is smaller or larger. This process repeats until all items are inserted, with about log₂(N) comparisons per item.

The binary insertion method optimizes for O(log n) comparisons per item but relies on strict transitive consistency across user inputs (A > B ∧ B > C → A > C). But in reality, our preferences are often non-transitive and noisy, introducing the risk of local ordering errors. Since elements are positioned based on initial comparisons without backtracking, early misjudgments bleed through the final ranked list with somwhere between limited to no opprotunities to correct. It's a balance between needing to iterate thousands of times and opening yourself for risk of innacuracies. I think this ranker does pretty good at giving an accurate and honest ranking while respecting the users time.

log_activity.gs
let items = [];
let rankingList = [];
let currentItemIndex = 1;
let currentCompareLow = 0;
let currentCompareHigh = 0;

document.getElementById('itemInput').addEventListener('keydown', function(e) {
    if (e.key === 'Enter') {
        e.preventDefault();
        addItems();
    }
});

function addItems() {
    const input = document.getElementById('itemInput');
    const newItems = input.value.split(',').map(item => item.trim()).filter(Boolean);
    newItems.forEach(item => items.push(item));
    input.value = '';
    renderItems();
}

function renderItems() {
    const list = document.getElementById('itemsList');
    list.innerHTML = '';
    items.forEach((item, index) => {
        const div = document.createElement('div');
        div.style.display = 'flex';
        div.style.alignItems = 'center';
        div.style.justifyContent = 'center';
        div.style.marginBottom = '0.5rem';

        const input = document.createElement('input');
        input.value = item;
        input.classList.add('underline-input');
        if (index === items.length - 1) {
            input.classList.add('fade-up');
            input.addEventListener('animationend', () => {
                input.classList.remove('fade-up');
            }, { once: true });
        }
        input.onchange = () => items[index] = input.value;

        const removeBtn = document.createElement('button');
        removeBtn.innerText = 'Remove';
        removeBtn.onclick = () => {
            items.splice(index, 1);
            renderItems();
        };
        removeBtn.style.backgroundColor = '#fc0f38';
        removeBtn.style.border = 'none';
        removeBtn.style.color = 'white';
        removeBtn.style.padding = '0.25rem 0.5rem';
        removeBtn.style.borderRadius = '6px';
        removeBtn.style.marginLeft = '0.25rem';
        removeBtn.style.cursor = 'pointer';

        div.appendChild(input);
        div.appendChild(removeBtn);
        list.appendChild(div);
    });

    document.getElementById('startBtn').style.display = items.length >= 2 ? 'inline-block' : 'none';
}

function initiateRanking() {
    for (let i = items.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [items[i], items[j]] = [items[j], items[i]];
    }

    document.getElementById('title').style.display = 'none';
    rankingList = [items[0]];
    currentItemIndex = 1;
    currentCompareLow = 0;
    currentCompareHigh = 0;
    document.getElementById('inputArea').style.display = 'none';
    document.getElementById('rankingArea').style.display = 'flex';
    startNextComparison();
}

function startNextComparison() {
    if (currentItemIndex >= items.length) {
        showFinalRanking();
        return;
    }

    const item = items[currentItemIndex];
    currentCompareLow = 0;
    currentCompareHigh = rankingList.length - 1;
    showComparison(item, currentCompareLow, currentCompareHigh);
}

function showComparison(item, low, high) {
    if (low > high) {
        rankingList.splice(low, 0, item);
        currentItemIndex++;
        startNextComparison();
        return;
    }
    const mid = Math.floor((low + high) / 2);
    document.getElementById('optionA').innerText = item;
    document.getElementById('optionB').innerText = rankingList[mid];
    currentCompareLow = low;
    currentCompareHigh = high;
}

function vote(choice) {
    const item = items[currentItemIndex];
    const mid = Math.floor((currentCompareLow + currentCompareHigh) / 2);
    if (choice === 'A') {
        showComparison(item, currentCompareLow, mid - 1);
    } else {
        showComparison(item, mid + 1, currentCompareHigh);
    }
}

function showFinalRanking() {
    document.getElementById('rankingArea').style.display = 'none';
    const finalList = document.getElementById('finalRankingList');
    finalList.innerHTML = rankingList.map(i => `
  • ${i}
  • `).join(''); document.getElementById('finalRankingArea').style.display = 'flex'; } function restartRanking() { items = []; rankingList = []; currentItemIndex = 1; currentCompareLow = 0; currentCompareHigh = 0; document.getElementById('title').style.display = 'block'; document.getElementById('inputArea').style.display = 'block'; document.getElementById('finalRankingArea').style.display = 'none'; renderItems(); } function downloadCSV() { const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); const filename = `Ranking - ${timestamp}.csv`; const header = "Rank,Item"; const rows = rankingList.map((item, index) => `${index + 1},${item}`); const csvContent = [header, ...rows].join("\n"); const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); const link = document.createElement("a"); link.setAttribute("href", URL.createObjectURL(blob)); link.setAttribute("download", filename); document.body.appendChild(link); link.click(); document.body.removeChild(link); }

    The addItems() function handles the adding of items, and renderItems() updates the list on screen. During the ranking phase, initiateRanking() shuffles the items (fischer-yates algorithm) and sets up the first comparison. The vote() function adjusts the ranking list based on user choices, narrowing down options. Each comparison moves the item up or down, and once all items are ranked, showFinalRanking() displays the final order. Users can save results with the downloadCSV() function, which exports the ranking as a CSV so it can go directly to your spreadsheet