liatrio/gratibot

View on GitHub
service/leaderboard.js

Summary

Maintainability
C
7 hrs
Test Coverage
A
100%
const winston = require("../winston");
const recognition = require("./recognition");

const rank = [
  "1st",
  "2nd",
  "3rd",
  "4th",
  "5th",
  "6th",
  "7th",
  "8th",
  "9th",
  "10th",
];

/*
 * Generates leaderboard message data in Slack's Block Kit style format.
 * @param {number} timeRange A number denoting the number of days of data
 *     the created leaderboard will include.
 * @return {object} A Block Kit style object, storing a Gratibot leaderboard.
 */
async function createLeaderboardBlocks(timeRange) {
  let blocks = [];

  const { giverScores, receiverScores } = await leaderboardScoreData(timeRange);

  blocks.push(leaderboardHeader());
  blocks.push(await goldenFistbumpHolder());
  blocks.push(topGivers(giverScores));
  blocks.push(topReceivers(receiverScores));
  blocks.push(timeRangeInfo(timeRange));
  blocks.push(timeRangeButtons());

  winston.debug("created leaderboad block", {
    func: "service.leaderboard.createLeaderboardBlocks",
    time_range: timeRange,
  });

  return blocks;
}

async function goldenFistbumpHolder() {
  let { goldenFistbumpHolder, message, timestamp } =
    await recognition.getGoldenFistbumpHolder();
  let receivedDate = new Date(timestamp);
  receivedDate = receivedDate.toLocaleDateString().substring(0, 10);

  let markdown = `*Current Golden Fistbump Holder. Received ${receivedDate}*\n\n`;
  markdown += `<@${goldenFistbumpHolder}> - *${message}*`;

  return {
    type: "section",
    block_id: "goldenFistbumpHolder",
    text: {
      type: "mrkdwn",
      text: markdown,
    },
  };
}

/* Block Kit Content */

/*
 * Generates a Block Kit style object, storing a leaderboard header.
 * @return {object} A Block Kit style object, storing a leaderboard header.
 */
function leaderboardHeader() {
  return {
    type: "section",
    block_id: "leaderboardHeader",
    text: {
      type: "mrkdwn",
      text: "*Leaderboard*",
    },
  };
}

/*
 * Generates a Block Kit style object, storing a Top Givers section
 *    header, and leaderboard entries for provided scores.
 * @param {Array<object>} giverScores An array of objects containing a user ID
 *     and a score.
 * @return {object} A Block Kit style object, storing a
 *     section header and leaderboard entries.
 */
function topGivers(giverScores) {
  let markdown = "*Top Givers*\n\n";
  markdown += giverScores.map(leaderboardEntry).join("\n");

  return {
    type: "section",
    block_id: "topGivers",
    text: {
      type: "mrkdwn",
      text: markdown,
    },
  };
}

/*
 * Generates a Block Kit style object, storing a Top Receivers section
 *    header, and leaderboard entries for provided scores.
 * @param {Array<object>} receiverScores An array of objects containing a user
 *     ID and a score.
 * @return {object} A Block Kit style object, storing a
 *     section header and leaderboard entries.
 */
function topReceivers(receiverScores) {
  let markdown = "*Top Receivers*\n\n";
  markdown += receiverScores.map(leaderboardEntry).join("\n");

  return {
    type: "section",
    block_id: "topReceivers",
    text: {
      type: "mrkdwn",
      text: markdown,
    },
  };
}

/*
 * Generates a Block Kit style object, storing information denoting the
 *     timeRange of the generated leaderboard.
 * @param {number} timeRange A number denoting the number of days of data
 *     the created leaderboard includes.
 * @return {object} A Block Kit style objects, storing information denoting
 *     the timeRange of the generated leaderboard.
 */
function timeRangeInfo(timeRange) {
  return {
    type: "context",
    block_id: "timeRange",
    elements: [
      {
        type: "plain_text",
        text: `Last ${timeRange} days`,
        emoji: true,
      },
    ],
  };
}

/*
 * Generates a Block Kit style object, containing buttons for generating
 *     a leaderboard with different timeRanges.
 * @return {object} A Block Kit style objects, containing buttons for generating
 *     a leaderboard with different timeRanges.
 */
function timeRangeButtons() {
  return {
    type: "actions",
    block_id: "leaderboardButtons",
    elements: [
      {
        type: "button",
        text: {
          type: "plain_text",
          emoji: true,
          text: "Today",
        },
        value: "1",
        action_id: "leaderboard-1",
      },
      {
        type: "button",
        text: {
          type: "plain_text",
          emoji: true,
          text: "Week",
        },
        value: "7",
        action_id: "leaderboard-7",
      },
      {
        type: "button",
        text: {
          type: "plain_text",
          emoji: true,
          text: "Month",
        },
        value: "30",
        action_id: "leaderboard-30",
      },
      {
        type: "button",
        text: {
          type: "plain_text",
          emoji: true,
          text: "Year",
        },
        value: "365",
        action_id: "leaderboard-365",
      },
    ],
  };
}

/*
 * Generates a markdown string, containing a single leaderboard
 *     entry. Used with Array.map() to format score data.
 * @param {object} entry An object containing a userID and a corresponding
 *    score for a leaderboard entry.
 * @param {number} index A number denoting the rank a particular entry should
 *    be marked with in the leaderboard entry. (Ex: 1st, 2nd 3rd, etc)
 * @return {string} A string of markdown, storing a single leaderboard
 *     entry.
 */
function leaderboardEntry(entry, index) {
  return `<@${entry.userID}> *${rank[index]} - Score:* ${entry.score}`;
}

/* Data Processing */

async function leaderboardScoreData(timeRange) {
  const recognitionData = await recognition.getPreviousXDaysOfRecognition(
    "America/Los_Angeles",
    timeRange,
  );
  return aggregateData(recognitionData);
}

function aggregateData(response) {
  /*
   * leaderboard = {
   *     userId: {
   *       totalRecognition: int
   *       uniqueUsers: Set<string>
   *     }
   *   }
   */
  let recognizerLeaderboard = {};
  let recognizeeLeaderboard = {};

  for (let i = 0; i < response.length; i++) {
    let recognizer = response[i].recognizer;
    let recognizee = response[i].recognizee;

    if (!(recognizer in recognizerLeaderboard)) {
      recognizerLeaderboard[recognizer] = {
        totalRecognition: 0,
        uniqueUsers: new Set(),
      };
    }
    if (!(recognizee in recognizeeLeaderboard)) {
      recognizeeLeaderboard[recognizee] = {
        totalRecognition: 0,
        uniqueUsers: new Set(),
      };
    }

    recognizerLeaderboard[recognizer].totalRecognition++;
    recognizerLeaderboard[recognizer].uniqueUsers.add(recognizee);
    recognizeeLeaderboard[recognizee].totalRecognition++;
    recognizeeLeaderboard[recognizee].uniqueUsers.add(recognizer);
  }

  winston.debug("aggregated leaderboard data", {
    func: "service.leaderboard.aggregateData",
  });

  return {
    giverScores: convertToScores(recognizerLeaderboard),
    receiverScores: convertToScores(recognizeeLeaderboard),
  };
}

function convertToScores(leaderboardData) {
  let scores = [];
  for (const user in leaderboardData) {
    let userStats = leaderboardData[user];
    let score =
      1 +
      userStats.totalRecognition -
      userStats.totalRecognition / userStats.uniqueUsers.size;
    score = Math.round(score * 100) / 100;
    scores.push({
      userID: user,
      score: score,
    });
  }
  scores.sort((a, b) => {
    return b.score - a.score;
  });
  return scores.slice(0, 10);
}

module.exports = {
  createLeaderboardBlocks,
};