Tiller.com - Sort Connected Accounts

Add "Sort By" dropdown to connected accounts page

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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
    });
})();