Mint.com tags display

Show tags in the transactions listing on Mint.com.

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        Mint.com tags display
// @match       https://mint.intuit.com/transactions
// @connect     mint.intuit.com
// @description Show tags in the transactions listing on Mint.com.
// @namespace   com.warkmilson.mint.js
// @author      Mark Wilson (update by Shaun Williams)
// @version     2.0.1
// @homepage    https://github.com/mddub/mint-tags-display
// @grant       none
// @noframes
// ==/UserScript==
//

(function() {

  //------------------------------------------------------------------------------
  // Logging
  //------------------------------------------------------------------------------

  const logging = false
  function log(...args) {
    if (logging) console.info('MINT_TAGS', ...args)
  }

  //------------------------------------------------------------------------------
  // Track state by watching XHR
  //------------------------------------------------------------------------------

  // State

  const state = {
    txnTags: {}, // txn id -> [tag name]
    tagOrder: [], // [tag name]
    tagName: {}, // tag id -> tag name
  }
  window._MINT_TAGS = state

  // Update state

  const apiUrl = path => `https://mint.intuit.com/pfm/v1${path}`

  const apiHooks = {
    // when transactions are fetched, save tags belonging to each transaction
    [apiUrl('/transactions/search')]: data => {
      for (const {id,tagData} of data.Transaction) {
        state.txnTags[id] = tagData?.tags.map(tag => tag.name)
      }
    },
    // when the master tag list is fetched, save it
    [apiUrl('/tags')]: data => {
      state.tagOrder = data.Tag.map(tag => tag.name)
      state.tagName = Object.fromEntries(data.Tag.map(tag => [tag.id, tag.name]))
    },
  }

  // when transactions are edited, update our tag records
  function handleTxnEdits(edits) {
    const idsToUpdate = []
    for (const {id,tagData} of edits) {
      if (tagData) {
        state.txnTags[id] = tagData.tags.map(tag => state.tagName[tag.id])
        idsToUpdate.push(id)
      }
    }
    setTimeout(() => idsToUpdate.forEach(updateRowTags), 500)
  }

  // hook XHR to intercept api calls
  function watchXHR() {
    const origOpen = XMLHttpRequest.prototype.open

    XMLHttpRequest.prototype.open = function(method, url) {
      const self = this

      // save XHR responses when needed
      self.addEventListener("readystatechange", function() {
        const hook = apiHooks[url]
        if (self.readyState === 4 && hook) {
          const data = JSON.parse(self.responseText)
          log('HOOKING', url, data)
          hook(data)
        }
      }, false)

      // intercept edits to transactions
      const txnsUrl = apiUrl('/transactions')
      if (method == 'PUT' && url.startsWith(txnsUrl)) {
        const origSend = self.send
        self.send = function(body) {
          const data = JSON.parse(body)
          const edits = url==txnsUrl ? data.Transaction : [{...data, id:url.slice(txnsUrl.length+1)}]
          log('HOOKING EDITS', edits)
          handleTxnEdits(edits)
          origSend.apply(self, arguments)
        }
      }

      origOpen.apply(self, arguments)
    }
  }

  //------------------------------------------------------------------------------
  // Render DOM
  //------------------------------------------------------------------------------

  var TAG_COLORS = [
    // source: http://colorbrewer2.org/#type=qualitative&scheme=Paired&n=12
    // background, foreground
    ['#a6cee3', '#000'],
    ['#b2df8a', '#000'],
    ['#fb9a99', '#000'],
    ['#fdbf6f', '#000'],
    ['#cab2d6', '#000'],
    ['#ffff99', '#000'],
    ['#1f78b4', '#fff'],
    ['#33a02c', '#fff'],
    ['#e31a1c', '#fff'],
    ['#ff7f00', '#fff'],
    ['#6a3d9a', '#fff'],
    ['#b15928', '#fff']
  ];

  function getTagStyle(tag) {
    const i = state.tagOrder.indexOf(tag)
    const [bg,fg] = TAG_COLORS[i]
    return `background:${bg}; color:${fg}`
  }

  // re-render our custom tag annotations in this row
  function updateRowTags(id) {
    log('UPDATING ROW', id)
    const td = document.querySelector(`tr[data-automation-id$=_${id}] td:nth-child(4)`)
    if (!td) return

    const tags = state.txnTags[id]
    const tagsDiv = () => td.querySelector('.gm-tags')
    if (tags?.length) {
      if (!tagsDiv()) td.innerHTML += '<div class="gm-tags" style="font-size:10px; display:inline-block"></div>'
      tagsDiv().innerHTML = tags.map(tag => `<span class="gm-tag" style="${getTagStyle(tag)}; margin-left:4px; padding:0 2px">${tag}</span>`).join('')
    } else {
      tagsDiv()?.remove()
    }
  }

  const rowId = row => row?.dataset?.automationId?.match(/TRANSACTION_TABLE_ROW_(READ|EDIT)_(.*)/)?.[2]

  function initRender() {
    if (!document.querySelector('tr[data-automation-id]')) {
      return setTimeout(initRender, 500)
    }
    log('FOUND TABLE')
    for (const row of document.querySelectorAll('tr[data-automation-id]')) {
      updateRowTags(rowId(row))
    }
    renderWhenDomChanges()
  }

  function renderWhenDomChanges() {
    log('WATCHING FOR CHANGES')
    const observer = new MutationObserver(() => {
      observer.disconnect()
      document.querySelectorAll('tr[data-automation-id]').forEach(row => updateRowTags(rowId(row)))
      log('RELAUNCHING WATCH')
      renderWhenDomChanges()
    })
    observer.observe(document.body, {subtree: true, childList: true, characterData: true})
  }

  //------------------------------------------------------------------------------
  // Main
  //------------------------------------------------------------------------------

  watchXHR()
  initRender()

})();