Trello coloured Scrum Kanban

Colour lists & cards, show WIP and story-point insights

// ==UserScript==
// @name         Trello coloured Scrum Kanban
// @namespace    https://trello.com/
// @version      3.2
// @description  Colour lists & cards, show WIP and story-point insights
// @match        https://trello.com/*
// @require      http://code.jquery.com/jquery-latest.js
// @author       Michael Wan
// ==/UserScript==
(function($) {
  'use strict';

  //── configs ────────────────────────────────────────────────────────────
  const COLORS = {
    list: {
      Development: '#9ec4ff',
      Testing:     '#dbffd1',
      'Ready to Deploy': '#c4c4c4',
      Deployed:    '#898989'
    },
    cardBorder: {
      '!!': '#ff8300',
       '!': '#ffd800',
      '`': '#dbf3ff'
    },
    cardBg: {
      '[P]': '#fdffbf',
      '[Parent]': '#fdffbf',
      Blocked: '#fabaff',
      '[VIP]': '#ff6363',
      '[R]': '#eeffbf',
      '[INFO]': '#e0f8ff'
    },
    idColor:       '#ffd396',
    noEstimate:    '#c1150f',
    whiteText:     '#fff',
    wipHighlight:  'yellow'
  };
  const WIP_LIMIT  = 2;
  const REFRESH_MS = 2000;

  //── list colouring ────────────────────────────────────────────────────
  function colorLists() {
    $('.list-wrapper').each(function() {
      const title = $(this)
        .find('.list-header-name-assist')
        .text()
        .trim();
      const bg = COLORS.list[title];
      if (bg) {
        $(this)
          .css('background', bg)
          .find('h2')
          .css('color', COLORS.whiteText);
      }
    });
  }

  //── card borders & backgrounds ────────────────────────────────────────
  function styleCards() {
    $("[data-testid='trello-card']").each(function() {
      const $card = $(this);
      const text  = $card.text();

      // reset
      $card.css({ border: '', background: '' });

      // borders
      for (const [marker, color] of Object.entries(COLORS.cardBorder)) {
        if (text.includes(marker)) {
          $card.css('border', `5px solid ${color}`);
          break; // only one border
        }
      }

      // backgrounds
      for (const [marker, color] of Object.entries(COLORS.cardBg)) {
        if (text.includes(marker)) {
          $card.css('background', color);
          break;
        }
      }
    });
  }

  //── show card IDs & highlight missing estimates ───────────────────────
  function annotateCardIds() {
    $('.card-short-id')
      .append(' ')
      .removeClass('hide')
      .css('color', COLORS.idColor);

    $('.js-card-name').filter(function() {
      return !/\(\d+\)/.test($(this).text());
    })
    .find('.card-short-id')
    .css('color', COLORS.noEstimate);
  }

  //── compute & display WIP per member ──────────────────────────────────
  function updateWIP() {
    $('#actualWIP').remove();
    const $header = $('.board-header');
    if (!$header.length) return;

    $header.after(
      `<div id="actualWIP" style="color:${COLORS.whiteText}">
         ActualWIP (excl. Blocked):
       </div>`
    );

    // initialize members
    const counts = new Map();
    $('img.member-avatar').each(function() {
      const name = this.alt.replace(/\(.+\)/, '');
      counts.set(name, 0);
    });

    // find "In Progress" list
    const $inProg = $('textarea:contains("In Progress")')
      .closest('.list-wrapper');

    // tally assignments
    const tally = (selector, map) =>
      $inProg.find(selector).each(function() {
        const name = this.alt.replace(/\(.+\)/, '');
        map.set(name, (map.get(name)||0) + 1);
      });

    const normal = new Map(), blocked = new Map();
    tally('img.member-avatar', normal);
    tally(
      "span[title='Blocked'], span[title='Keep Monitoring'], \
       span[title='Pending for Desk Check'], span[title='[Parent]'], span[title='[P]']"
      + ' .closest("a.list-card") img.member-avatar',
      blocked
    );

    // compute actual WIP = normal − blocked
    for (const name of counts.keys()) {
      const n = normal.get(name) || 0;
      const b = blocked.get(name) || 0;
      counts.set(name, n - b);
    }

    // render
    counts.forEach((v, name) => {
      const text = `${name}: ${v}`;
      const html = (v === 0 || v > WIP_LIMIT)
        ? `<b style="color:${COLORS.wipHighlight}">${text}</b>`
        : text;
      $('#actualWIP').append(html + ' , ');
    });
  }

  //── story-point & card-count insights ─────────────────────────────────
  function updateListInsights() {
    $('.StoryPtInsight').remove();

    $('.list-wrapper').each(function() {
      const $list = $(this);
      const count = $list.find('.list-card').length;

      let pts = 0, miss = 0;
      $list.find('.badge-text').each(function() {
        const t = $(this).text();
        const n = parseFloat(t);
        if (t === 'Unestimated') miss++;
        else if (!isNaN(n))        pts += n;
      });

      const insight = `
        <p class="StoryPtInsight">
          <b style="color:green">#Cards:${count}</b>
          <b style="color:blue">#StoryPt:${pts}</b>
          ${miss ? `<b style="color:red">#Miss:${miss}</b>` : ''}
        </p>`;
      $list.find('textarea.list-header-name').after(insight);
    });
  }

  //── orchestrator ──────────────────────────────────────────────────────
  function refreshAll() {
    colorLists();
    styleCards();
    annotateCardIds();
    updateWIP();
    updateListInsights();
  }

  //── kick off on load + every REFRESH_MS ────────────────────────────────
  $(document).ready(() => {
    refreshAll();
    setInterval(refreshAll, REFRESH_MS);
  });

})(jQuery);