Tiller.com - Sort Connected Accounts

Add "Sort By" dropdown to connected accounts page

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Tiller.com - Sort Connected Accounts
// @namespace    http://tampermonkey.net/
// @version      2025-06-17
// @description  Add "Sort By" dropdown to connected accounts page
// @author       You
// @match        https://my.tiller.com/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=tiller.com
// @license      MIT
// @grant        none
// ==/UserScript==

(function() {
    'use strict';
    console.log('[TM] Script loaded');
    let currentSortBy = 'lastRefreshedAt';
    let lastRenderedState = '';
    let mutationTimeout = null;

    function computeDomStateHash() {
        const elements = Array.from(document.querySelectorAll('[data-testid="connected-institution"]'));
        return elements.map(el => el.querySelector('[data-testid="institution-name"]')?.textContent?.trim()).join('|');
    }

    function parseStatus(status, lastRefreshedAt) {
        const now = new Date();
        const refreshedDate = new Date(lastRefreshedAt);
        const diff = (now - refreshedDate) / (1000 * 60 * 60);

        const UNKNOWN = 4;
        const RED = 3;
        const YELLOW = 2;
        const GREEN = 1;

        if (status === 'FAILURE') return RED;
        if (status === 'SUCCESS' && diff < 36) return GREEN;
        if (status === 'SUCCESS' && diff >= 36) return YELLOW;
        return UNKNOWN;
    }

    function calculateTotalBalance(accountData) {
        let totalBalance = 0;
        accountData.forEach(account => {
            const balance = account.balance?.amount?.value || 0;
            totalBalance += parseFloat(balance);
        });
        return totalBalance;
    }

    function gatherConnectionData(apiData) {
        return apiData.items.map(item => {
            const totalBalance = calculateTotalBalance(item.accounts);
            const statusValue = parseStatus(item.refreshInfo.status, item.refreshInfo.lastRefreshedAt);

            return {
                id: item.id,
                name: item.name,
                totalBalance: totalBalance,
                statusValue: statusValue,
                lastRefreshedAt: item.refreshInfo.lastRefreshedAt,
                accounts: item.accounts
            };
        });
    }

    function reorderConnections(sortBy) {
        console.log('[TM] Reordering by:', sortBy);

        const domElements = Array.from(document.querySelectorAll('[data-testid="connected-institution"]'));
        console.log(`[TM] Found ${domElements.length} connected institution elements`);

        if (!window.apiData) {
            console.warn('[TM] apiData not loaded');
            return;
        }

        const matched = [];

        domElements.forEach(el => {
            const institutionName = el.querySelector('[data-testid="institution-name"]')?.textContent?.trim();

            let accountKeys = Array.from(el.querySelectorAll('[data-testid="account-number"]'))
                .map(n => n.textContent.trim())
                .filter(n => n.length > 0);

            let matchKeyType = 'number';

            if (accountKeys.length === 0) {
                accountKeys = Array.from(el.querySelectorAll('[data-testid="account-name"]'))
                    .map(n => n.textContent.trim())
                    .filter(n => n.length > 0);
                matchKeyType = 'name';
            }

            if (!institutionName || accountKeys.length === 0) {
                console.warn('[TM] Could not extract identifiers from element', el);
                return;
            }

            const match = window.apiData.find(data => {
                if (data.name !== institutionName) return false;

                const dataKeys = data.accounts.map(a =>
                    matchKeyType === 'number' ? a.number : a.name
                );

                return accountKeys.every(k => dataKeys.includes(k));
            });

            if (match) {
                matched.push({
                    el,
                    ...match
                });
            }
        });

        if (matched.length === 0) {
            console.warn('[TM] No DOM elements matched to API data');
            return;
        }

        const parent = matched[0].el.parentElement;

        matched.sort((a, b) => {
            const aVal = a[sortBy];
            const bVal = b[sortBy];
            if (sortBy === 'lastRefreshedAt') {
                return new Date(aVal) - new Date(bVal);
            }
            return bVal - aVal;
        });

        matched.forEach(d => {
            parent.appendChild(d.el);
            console.log(`[TM] Moved: ${d.name} (${sortBy} = ${d[sortBy]})`);
        });

        lastRenderedState = computeDomStateHash();

        console.log('[TM] Sorting complete');
    }


    function createDropdown() {
        const sel = document.createElement('select');
        sel.id = 'tm-sort-dropdown';
        ['lastRefreshedAt', 'statusValue', 'totalBalance'].forEach(val => {
            const opt = document.createElement('option');
            opt.value = val;
            opt.text = {
                lastRefreshedAt: 'Last Refreshed At',
                statusValue: 'Connection Status',
                totalBalance: 'Total Balance'
            } [val];
            if (val === currentSortBy) opt.selected = true;
            sel.add(opt);
        });
        sel.style.margin = '10px';

        sel.addEventListener('change', () => {
            currentSortBy = sel.value;
            lastRenderedState = '';
            reorderConnections(currentSortBy);
        });

        return sel;
    }

    function insertDropdown() {
        const headers = Array.from(document.querySelectorAll('h3'));
        const targetHeader = headers.find(h => h.textContent.trim().startsWith('Connected Account'));

        if (targetHeader) {
            console.log('[TM] Found target header:', targetHeader.textContent.trim());
        } else {
            console.warn('[TM] Could not find "Connected Account" header');
            return;
        }

        const existingDropdown = document.getElementById('tm-sort-dropdown');
        if (existingDropdown) {
            console.log('[TM] Dropdown already inserted');
            return;
        }

        const parent = targetHeader.closest('.mt-0.mb-3.d-flex.flex-column');
        if (!parent) {
            console.warn('[TM] Parent element not found for sorting');
            return;
        }
        window.tmParentElement = parent;

        const dropdown = createDropdown();
        targetHeader.insertAdjacentElement('afterend', dropdown);
        console.log('[TM] Dropdown inserted after "Connected Account" header');
    }

    const originalFetch = window.fetch;

    window.fetch = async function(...args) {
        const url = args[0];
        try {
            if (url.includes('/api/v2/provider-accounts')) {
                const response = await originalFetch.apply(this, args);
                const clonedResponse = response.clone();
                const data = await clonedResponse.json();
                window.apiData = gatherConnectionData(data);
                return response;
            }
            return originalFetch.apply(this, args);
        } catch (error) {
            console.error('[TM] Error in fetch interception:', error);
            throw error;
        }
    };

    const observer = new MutationObserver(() => {
        if (mutationTimeout) return;

        mutationTimeout = setTimeout(() => {
            mutationTimeout = null;

            const headers = Array.from(document.querySelectorAll('h3'));
            const targetHeader = headers.find(h => h.textContent.trim().startsWith('Connected Account'));

            if (targetHeader && window.apiData) {
                insertDropdown();

                const currentHash = computeDomStateHash();
                if (currentHash !== lastRenderedState) {
                    reorderConnections(currentSortBy);
                }
            }
        }, 200);
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();