Mint.com tags display

Show tags in the transactions listing on Mint.com.

  1. // ==UserScript==
  2. // @name Mint.com tags display
  3. // @match https://mint.intuit.com/transactions
  4. // @connect mint.intuit.com
  5. // @description Show tags in the transactions listing on Mint.com.
  6. // @namespace com.warkmilson.mint.js
  7. // @author Mark Wilson (update by Shaun Williams)
  8. // @version 2.0.1
  9. // @homepage https://github.com/mddub/mint-tags-display
  10. // @grant none
  11. // @noframes
  12. // ==/UserScript==
  13. //
  14.  
  15. (function() {
  16.  
  17. //------------------------------------------------------------------------------
  18. // Logging
  19. //------------------------------------------------------------------------------
  20.  
  21. const logging = false
  22. function log(...args) {
  23. if (logging) console.info('MINT_TAGS', ...args)
  24. }
  25.  
  26. //------------------------------------------------------------------------------
  27. // Track state by watching XHR
  28. //------------------------------------------------------------------------------
  29.  
  30. // State
  31.  
  32. const state = {
  33. txnTags: {}, // txn id -> [tag name]
  34. tagOrder: [], // [tag name]
  35. tagName: {}, // tag id -> tag name
  36. }
  37. window._MINT_TAGS = state
  38.  
  39. // Update state
  40.  
  41. const apiUrl = path => `https://mint.intuit.com/pfm/v1${path}`
  42.  
  43. const apiHooks = {
  44. // when transactions are fetched, save tags belonging to each transaction
  45. [apiUrl('/transactions/search')]: data => {
  46. for (const {id,tagData} of data.Transaction) {
  47. state.txnTags[id] = tagData?.tags.map(tag => tag.name)
  48. }
  49. },
  50. // when the master tag list is fetched, save it
  51. [apiUrl('/tags')]: data => {
  52. state.tagOrder = data.Tag.map(tag => tag.name)
  53. state.tagName = Object.fromEntries(data.Tag.map(tag => [tag.id, tag.name]))
  54. },
  55. }
  56.  
  57. // when transactions are edited, update our tag records
  58. function handleTxnEdits(edits) {
  59. const idsToUpdate = []
  60. for (const {id,tagData} of edits) {
  61. if (tagData) {
  62. state.txnTags[id] = tagData.tags.map(tag => state.tagName[tag.id])
  63. idsToUpdate.push(id)
  64. }
  65. }
  66. setTimeout(() => idsToUpdate.forEach(updateRowTags), 500)
  67. }
  68.  
  69. // hook XHR to intercept api calls
  70. function watchXHR() {
  71. const origOpen = XMLHttpRequest.prototype.open
  72.  
  73. XMLHttpRequest.prototype.open = function(method, url) {
  74. const self = this
  75.  
  76. // save XHR responses when needed
  77. self.addEventListener("readystatechange", function() {
  78. const hook = apiHooks[url]
  79. if (self.readyState === 4 && hook) {
  80. const data = JSON.parse(self.responseText)
  81. log('HOOKING', url, data)
  82. hook(data)
  83. }
  84. }, false)
  85.  
  86. // intercept edits to transactions
  87. const txnsUrl = apiUrl('/transactions')
  88. if (method == 'PUT' && url.startsWith(txnsUrl)) {
  89. const origSend = self.send
  90. self.send = function(body) {
  91. const data = JSON.parse(body)
  92. const edits = url==txnsUrl ? data.Transaction : [{...data, id:url.slice(txnsUrl.length+1)}]
  93. log('HOOKING EDITS', edits)
  94. handleTxnEdits(edits)
  95. origSend.apply(self, arguments)
  96. }
  97. }
  98.  
  99. origOpen.apply(self, arguments)
  100. }
  101. }
  102.  
  103. //------------------------------------------------------------------------------
  104. // Render DOM
  105. //------------------------------------------------------------------------------
  106.  
  107. var TAG_COLORS = [
  108. // source: http://colorbrewer2.org/#type=qualitative&scheme=Paired&n=12
  109. // background, foreground
  110. ['#a6cee3', '#000'],
  111. ['#b2df8a', '#000'],
  112. ['#fb9a99', '#000'],
  113. ['#fdbf6f', '#000'],
  114. ['#cab2d6', '#000'],
  115. ['#ffff99', '#000'],
  116. ['#1f78b4', '#fff'],
  117. ['#33a02c', '#fff'],
  118. ['#e31a1c', '#fff'],
  119. ['#ff7f00', '#fff'],
  120. ['#6a3d9a', '#fff'],
  121. ['#b15928', '#fff']
  122. ];
  123.  
  124. function getTagStyle(tag) {
  125. const i = state.tagOrder.indexOf(tag)
  126. const [bg,fg] = TAG_COLORS[i]
  127. return `background:${bg}; color:${fg}`
  128. }
  129.  
  130. // re-render our custom tag annotations in this row
  131. function updateRowTags(id) {
  132. log('UPDATING ROW', id)
  133. const td = document.querySelector(`tr[data-automation-id$=_${id}] td:nth-child(4)`)
  134. if (!td) return
  135.  
  136. const tags = state.txnTags[id]
  137. const tagsDiv = () => td.querySelector('.gm-tags')
  138. if (tags?.length) {
  139. if (!tagsDiv()) td.innerHTML += '<div class="gm-tags" style="font-size:10px; display:inline-block"></div>'
  140. tagsDiv().innerHTML = tags.map(tag => `<span class="gm-tag" style="${getTagStyle(tag)}; margin-left:4px; padding:0 2px">${tag}</span>`).join('')
  141. } else {
  142. tagsDiv()?.remove()
  143. }
  144. }
  145.  
  146. const rowId = row => row?.dataset?.automationId?.match(/TRANSACTION_TABLE_ROW_(READ|EDIT)_(.*)/)?.[2]
  147.  
  148. function initRender() {
  149. if (!document.querySelector('tr[data-automation-id]')) {
  150. return setTimeout(initRender, 500)
  151. }
  152. log('FOUND TABLE')
  153. for (const row of document.querySelectorAll('tr[data-automation-id]')) {
  154. updateRowTags(rowId(row))
  155. }
  156. renderWhenDomChanges()
  157. }
  158.  
  159. function renderWhenDomChanges() {
  160. log('WATCHING FOR CHANGES')
  161. const observer = new MutationObserver(() => {
  162. observer.disconnect()
  163. document.querySelectorAll('tr[data-automation-id]').forEach(row => updateRowTags(rowId(row)))
  164. log('RELAUNCHING WATCH')
  165. renderWhenDomChanges()
  166. })
  167. observer.observe(document.body, {subtree: true, childList: true, characterData: true})
  168. }
  169.  
  170. //------------------------------------------------------------------------------
  171. // Main
  172. //------------------------------------------------------------------------------
  173.  
  174. watchXHR()
  175. initRender()
  176.  
  177. })();