diff --git a/common/models/survey.js b/common/models/survey.js index e988fb82..cffb778c 100644 --- a/common/models/survey.js +++ b/common/models/survey.js @@ -14,13 +14,13 @@ export const QUESTION_RESPONSE_TYPES = { } export const LIKERT_7_AGREEMENT_OPTIONS = [ - {value: 1, label: 'strongly disagree'}, - {value: 2, label: 'disagree'}, - {value: 3, label: 'somewhat disagree'}, - {value: 4, label: 'neutral'}, - {value: 5, label: 'somewhat agree'}, - {value: 6, label: 'agree'}, {value: 7, label: 'strongly agree'}, + {value: 6, label: 'agree'}, + {value: 5, label: 'somewhat agree'}, + {value: 4, label: 'neutral'}, + {value: 3, label: 'somewhat disagree'}, + {value: 2, label: 'disagree'}, + {value: 1, label: 'strongly disagree'}, {value: 0, label: 'not enough information'}, ] diff --git a/common/util/survey.js b/common/util/survey.js index ca036e26..8332a58c 100644 --- a/common/util/survey.js +++ b/common/util/survey.js @@ -145,7 +145,7 @@ export function formFieldsForQuestionGroup(questionGroup) { case QUESTION_RESPONSE_TYPES.LIKERT_7: field.type = FORM_INPUT_TYPES.RADIO field.options = LIKERT_7_AGREEMENT_OPTIONS - field.value = parseInt(responseValue, 10) || 0 + field.value = parseInt(responseValue, 10) || null break default: throw new Error(`Invalid user question response type: ${question.responseType}`) diff --git a/db/migrations/20160804122354-player-stats.js b/db/migrations/20160804122354-player-stats.js new file mode 100644 index 00000000..44de34a2 --- /dev/null +++ b/db/migrations/20160804122354-player-stats.js @@ -0,0 +1,84 @@ +const config = require('../config') + +config() + +exports.up = async (r, conn) => { + const players = await r.table('players').run(conn) + + const updates = players.map(player => { + const ecc = player.ecc + const projects = {} + + const cycles = player.cycleProjectECC || {} + Object.keys(cycles).forEach(cycleId => { + const cycleProjects = cycles[cycleId] || {} + + Object.keys(cycleProjects).forEach(projectId => { + const cycleProjectStats = cycleProjects[projectId] || {} + + if (!projects[projectId]) { + projects[projectId] = {cycles: {}} + } + if (!projects[projectId].cycles[cycleId]) { + projects[projectId].cycles[cycleId] = {} + } + + projects[projectId].cycles[cycleId].abc = cycleProjectStats.abc || 0 + projects[projectId].cycles[cycleId].rc = cycleProjectStats.rc || 0 + projects[projectId].cycles[cycleId].ecc = cycleProjectStats.ecc || 0 + }) + }) + + return r.table('players') + .get(player.id) + .replace( + r.row + .merge({stats: {ecc, projects}}) + .without('ecc', 'cycleProjectECC') + ) + .run(conn) + }) + + return Promise.all(updates) +} + +exports.down = async (r, conn) => { + const players = await r.table('players').run(conn) + + const updates = players.map(player => { + const stats = player.stats || {} + const ecc = stats.ecc || 0 + const cycleProjectECC = {} + + const projects = stats.projects || {} + Object.keys(projects).forEach(projectId => { + const projectCycles = (projects[projectId] || {}).cycles || {} + + Object.keys(projectCycles).forEach(cycleId => { + const projectCycleStats = projectCycles[cycleId] || {} + + if (!cycleProjectECC[cycleId]) { + cycleProjectECC[cycleId] = {} + } + if (!cycleProjectECC[cycleId][projectId]) { + cycleProjectECC[cycleId][projectId] = {} + } + + cycleProjectECC[cycleId][projectId].abc = projectCycleStats.abc + cycleProjectECC[cycleId][projectId].rc = projectCycleStats.rc + cycleProjectECC[cycleId][projectId].ecc = projectCycleStats.ecc + }) + }) + + return r.table('players') + .get(player.id) + .replace( + r.row + .merge({ecc, cycleProjectECC}) + .without('stats') + ) + .run(conn) + }) + + return Promise.all(updates) +} diff --git a/db/migrations/20160804151238-responsesIndexSurveyId.js b/db/migrations/20160804151238-responsesIndexSurveyId.js new file mode 100644 index 00000000..3df4ecf5 --- /dev/null +++ b/db/migrations/20160804151238-responsesIndexSurveyId.js @@ -0,0 +1,11 @@ +exports.up = function (r, conn) { + return r.table('responses') + .indexCreate('surveyId') + .run(conn) +} + +exports.down = function (r, conn) { + return r.table('responses') + .indexDrop('surveyId') + .run(conn) +} diff --git a/package.json b/package.json index 167e0148..57f278fa 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,10 @@ "data:github-goals": "./node_modules/.bin/babel-node test/generateTestGoals", "db:create": "./node_modules/.bin/babel-node ./db/create.js", "db:drop": "./node_modules/.bin/babel-node ./db/drop.js", - "db:migrate:configure": "node ./db/config.js > ./db/database.json", - "db:migrate": "npm run db:migrate:configure && node ./node_modules/.bin/rethink-migrate -r db", - "db:migrate:up": "npm run db:migrate:configure && node ./node_modules/.bin/rethink-migrate -r db up", - "db:migrate:down": "npm run db:migrate:configure && node ./node_modules/.bin/rethink-migrate -r db down", + "db:migrate:configure": "./node_modules/.bin/babel-node ./db/config.js > ./db/database.json", + "db:migrate": "npm run db:migrate:configure && ./node_modules/.bin/babel-node ./node_modules/.bin/rethink-migrate -r db", + "db:migrate:up": "npm run db:migrate:configure && ./node_modules/.bin/babel-node ./node_modules/.bin/rethink-migrate -r db up", + "db:migrate:down": "npm run db:migrate:configure && ./node_modules/.bin/babel-node ./node_modules/.bin/rethink-migrate -r db down", "start": "npm run icons:fetch && node server", "workers": "./node_modules/.bin/babel-node ./server/workers", "postinstall": "npm run build", diff --git a/server/actions/__tests__/formProjects.test.js b/server/actions/__tests__/formProjects.test.js index b806bb29..07c79e84 100644 --- a/server/actions/__tests__/formProjects.test.js +++ b/server/actions/__tests__/formProjects.test.js @@ -162,7 +162,7 @@ async function _generatePlayers(chapterId, options = {}) { const numAdvanced = options.advanced || 0 return { regular: await factory.createMany('player', {chapterId}, numTotal - numAdvanced), - advanced: await factory.createMany('player', {chapterId, ecc: TEST_ADVANCED_PLAYER_ECC}, numAdvanced) + advanced: await factory.createMany('player', {chapterId, stats: {ecc: TEST_ADVANCED_PLAYER_ECC}}, numAdvanced) } } diff --git a/server/actions/__tests__/updateProjectStats.test.js b/server/actions/__tests__/updateProjectStats.test.js new file mode 100644 index 00000000..9aa389f6 --- /dev/null +++ b/server/actions/__tests__/updateProjectStats.test.js @@ -0,0 +1,117 @@ +/* eslint-env mocha */ +/* global expect, testContext */ +/* eslint-disable prefer-arrow-callback, no-unused-expressions */ + +import factory from '../../../test/factories' +import {withDBCleanup, useFixture} from '../../../test/helpers' +import {getPlayerById} from '../../../server/db/player' + +import updateProjectStats from '../updateProjectStats' + +describe(testContext(__filename), function () { + describe('updateProjectStats', function () { + withDBCleanup() + useFixture.buildSurvey() + + beforeEach('Setup Survey Data', async function () { + const learningSupportQuestion = await factory.create('question', { + responseType: 'likert7Agreement', + subjectType: 'player', + body: 'so-and-so supported me in learning my craft.', + }) + + const cultureContributionQuestion = await factory.create('question', { + responseType: 'likert7Agreement', + subjectType: 'player', + body: 'so-and-so contributed positively to our team culture.', + }) + + const projectHoursQuestion = await factory.create('question', { + responseType: 'text', + subjectType: 'project', + body: 'During this past cycle, how many hours did you dedicate to this project?' + }) + + const relativeContributionQuestion = await factory.create('question', { + responseType: 'relativeContribution', + subjectType: 'team' + }) + + await this.buildSurvey([ + {questionId: learningSupportQuestion.id, subjectIds: () => this.teamPlayerIds}, + {questionId: cultureContributionQuestion.id, subjectIds: () => this.teamPlayerIds}, + {questionId: relativeContributionQuestion.id, subjectIds: () => this.teamPlayerIds}, + {questionId: projectHoursQuestion.id, subjectIds: () => this.project.id}, + ]) + + const responseData = [] + this.teamPlayerIds.forEach(respondentId => { + this.teamPlayerIds.forEach(subjectId => { + responseData.push({ + questionId: relativeContributionQuestion.id, + surveyId: this.survey.id, + respondentId, + subjectId, + value: 20, + }) + + responseData.push({ + questionId: learningSupportQuestion.id, + surveyId: this.survey.id, + respondentId, + subjectId, + value: 5, + }) + + responseData.push({ + questionId: cultureContributionQuestion.id, + surveyId: this.survey.id, + respondentId, + subjectId, + value: 7, + }) + }) + + responseData.push({ + questionId: projectHoursQuestion.id, + surveyId: this.survey.id, + respondentId, + subjectId: this.project.id, + value: '35', + }) + }) + + await factory.createMany('response', responseData) + }) + + it('updates the players\' stats based on the survey responses', async function() { + const expectedECC = 20 * this.teamPlayerIds.length + await updateProjectStats(this.project, this.cycleId) + + const updatedPlayer = await getPlayerById(this.teamPlayerIds[0]) + + expect(updatedPlayer.stats).to.deep.eq({ + ecc: expectedECC, + projects: { + [this.project.id]: { + cycles: { + [this.cycleId]: { + ls: 67, + cc: 100, + ec: 25, + ecd: -5, + abc: 4, + rc: 20, + rcSelf: 20, + rcOther: 20, + ecc: expectedECC, + hours: 35, + teamHours: 140, + }, + }, + }, + }, + }) + }) + }) +}) diff --git a/server/actions/__tests__/updateTeamECCStats.test.js b/server/actions/__tests__/updateTeamECCStats.test.js deleted file mode 100644 index abe739e1..00000000 --- a/server/actions/__tests__/updateTeamECCStats.test.js +++ /dev/null @@ -1,73 +0,0 @@ -/* eslint-env mocha */ -/* global expect, testContext */ -/* eslint-disable prefer-arrow-callback, no-unused-expressions */ - -import factory from '../../../test/factories' -import {withDBCleanup, useFixture} from '../../../test/helpers' -import {getPlayerById} from '../../../server/db/player' - -import { - calculateProjectECCStatsForPlayer, - updateTeamECCStats, -} from '../updateTeamECCStats' - -describe(testContext(__filename), function () { - describe('calculateProjectECCStatsForPlayer()', function () { - specify('when there are scores from all team members', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [10, 20, 20, 30]})) - .to.deep.eq({ecc: 80, abc: 4, rc: 20}) - }) - specify('when there are not scores from all team members', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [20, 25, 30]})) - .to.deep.eq({ecc: 100, abc: 4, rc: 25}) - }) - specify('when the result is over 100', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [50, 50, 50, 50]})) - .to.deep.eq({ecc: 200, abc: 4, rc: 50}) - }) - specify('when project length is > 1', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [50, 50, 50, 50], projectLength: 3})) - .to.deep.eq({ecc: 600, abc: 12, rc: 50}) - }) - specify('when RC is a decimal, round', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 5, relativeContributionScores: [10, 10, 21, 21]})) - .to.deep.eq({ecc: 80, abc: 5, rc: 16}) - }) - }) - - describe('updateTeamECCStats', function () { - withDBCleanup() - useFixture.buildSurvey() - - beforeEach('Setup Survey Data', async function () { - const teamQuestion = await factory.create('question', { - responseType: 'relativeContribution', - subjectType: 'team' - }) - await this.buildSurvey([ - {questionId: teamQuestion.id, subjectIds: () => this.teamPlayerIds}, - ]) - const responseData = [] - this.teamPlayerIds.forEach(respondentId => { - this.teamPlayerIds.forEach(subjectId => { - responseData.push({ - questionId: teamQuestion.id, - surveyId: this.survey.id, - respondentId, - subjectId, - value: 20 - }) - }) - }) - await factory.createMany('response', responseData) - }) - - it('updates the players ECC based on the survey responses', async function() { - const eccChange = 20 * this.teamPlayerIds.length - await updateTeamECCStats(this.project, this.cycleId) - - const updatedPlayer = await getPlayerById(this.teamPlayerIds[0]) - expect(updatedPlayer.ecc).to.eq(eccChange) - }) - }) -}) diff --git a/server/actions/formProjects.js b/server/actions/formProjects.js index db0fcbbd..d2c3930c 100644 --- a/server/actions/formProjects.js +++ b/server/actions/formProjects.js @@ -51,7 +51,9 @@ function _formGoalGroups(players, playerVotes) { const regularPlayers = new Map() players.forEach(player => { - if (parseInt(player.ecc, 10) >= MIN_ADVANCED_PLAYER_ECC) { + const playerECC = parseInt((player.stats || {}).ecc, 10) || 0 + + if (playerECC >= MIN_ADVANCED_PLAYER_ECC) { advancedPlayers.set(player.id, player) } else { regularPlayers.set(player.id, player) diff --git a/server/actions/getPlayerInfo.js b/server/actions/getPlayerInfo.js new file mode 100644 index 00000000..a5c9e6aa --- /dev/null +++ b/server/actions/getPlayerInfo.js @@ -0,0 +1,8 @@ +import {graphQLFetcher} from '../util' + +export default function getPlayerInfo(playerIds) { + return graphQLFetcher(process.env.IDM_BASE_URL)({ + query: 'query ($playerIds: [ID]!) { getUsersByIds(ids: $playerIds) { id handle name } }', + variables: {playerIds}, + }).then(result => result.data.getUsersByIds) +} diff --git a/server/actions/updateProjectStats.js b/server/actions/updateProjectStats.js new file mode 100644 index 00000000..2c3b3d89 --- /dev/null +++ b/server/actions/updateProjectStats.js @@ -0,0 +1,158 @@ +import {getSurveyById} from '../../server/db/survey' +import {findQuestionsByIds} from '../../server/db/question' +import {findResponsesBySurveyId} from '../../server/db/response' +import {savePlayerProjectStats} from '../../server/db/player' +import {getProjectHistoryForCycle} from '../../server/db/project' +import {sum} from '../../server/util' +import { + aggregateBuildCycles, + relativeContribution, + expectedContribution, + expectedContributionDelta, + effectiveContributionCycles, + learningSupport, + cultureContrbution, +} from '../../server/util/stats' +import { + STATS_QUESTION_TYPES, + groupResponsesBySubject, + findQuestionByType, +} from '../../server/util/survey' + +export default async function updateProjectStats(project, cycleId) { + const projectCycle = getProjectHistoryForCycle(project, cycleId) + const teamSize = projectCycle.playerIds.length + const retroSurveyId = projectCycle.retrospectiveSurveyId + + const [retroSurvey, retroResponses] = await Promise.all([ + getSurveyById(retroSurveyId), + findResponsesBySurveyId(retroSurveyId), + ]) + + const retroQuestionIds = retroSurvey.questionRefs.map(qref => qref.questionId) + const retroQuestions = await findQuestionsByIds(retroQuestionIds) + + // hacky, brittle way of mapping stat types to questions + // FIXME (ASAP): see https://github.com/LearnersGuild/game/issues/370 + const questionLS = findQuestionByType(retroQuestions, STATS_QUESTION_TYPES.LEARNING_SUPPORT) || {} + const questionCC = findQuestionByType(retroQuestions, STATS_QUESTION_TYPES.CULTURE_CONTRIBUTION) || {} + const questionRC = findQuestionByType(retroQuestions, STATS_QUESTION_TYPES.RELATIVE_CONTRIBUTION) || {} + const questionHours = findQuestionByType(retroQuestions, STATS_QUESTION_TYPES.PROJECT_HOURS) || {} + + const projectResponses = [] + const playerResponses = [] + + // separate responses about projects from responses about players + const retroQuestionMap = _mapById(retroQuestions) + retroResponses.forEach(response => { + const responseQuestion = retroQuestionMap.get(response.questionId) + const {subjectType} = responseQuestion || {} + + switch (subjectType) { + case 'project': + projectResponses.push(response) + break + case 'team': + case 'player': + playerResponses.push(response) + break + default: + return + } + }) + + const projectResponseGroups = groupResponsesBySubject(projectResponses) + const playerResponseGroups = groupResponsesBySubject(playerResponses) + + // calculate total hours worked by all team members + let teamHours = 0 + const teamPlayerHours = new Map() + projectResponseGroups.forEach(responseGroup => { + responseGroup.forEach(response => { + if (response.questionId === questionHours.id) { + const playerHours = parseInt(response.value, 10) || 0 + teamHours += playerHours + teamPlayerHours.set(response.respondentId, playerHours) + } + }) + }) + + // dig out values needed for stats from question responses about each player + const playerStatsUpdates = [] + playerResponseGroups.forEach((responseGroup, playerSubjectId) => { + const lsScores = [] + const ccScores = [] + const rcScores = [] + const rcScoresSelf = [] + const rcScoresOther = [] + + responseGroup.forEach(response => { + const { + questionId: responseQuestionId, + value: responseValue, + } = response + + let value + switch (responseQuestionId) { + case questionLS.id: + value = parseInt(responseValue, 10) + if (!isNaN(value)) { + lsScores.push(value) + } + break + + case questionCC.id: + value = parseInt(responseValue, 10) + if (!isNaN(value)) { + ccScores.push(value) + } + break + + case questionRC.id: + value = parseInt(responseValue, 10) || 0 + if (!isNaN(value)) { + rcScores.push(value) + if (response.respondentId === playerSubjectId) { + rcScoresSelf.push(value) + } else { + rcScoresOther.push(value) + } + } + break + + default: + return + } + }) + + const hours = teamPlayerHours.get(playerSubjectId) || 0 + + const abc = aggregateBuildCycles(teamSize) + const ls = learningSupport(lsScores) + const cc = cultureContrbution(ccScores) + const rc = relativeContribution(rcScores) + const ec = expectedContribution(hours, teamHours) + const ecd = expectedContributionDelta(ec, rc) + const ecc = effectiveContributionCycles(abc, rc) + + const stats = { + ec, ecd, abc, ecc, ls, + cc, hours, teamHours, rc, + rcSelf: rcScoresSelf.length ? Math.round(sum(rcScoresSelf) / rcScoresSelf.length) : 0, + rcOther: rcScoresOther.length ? Math.round(sum(rcScoresOther) / rcScoresOther.length) : 0, + } + + playerStatsUpdates.push( + savePlayerProjectStats(playerSubjectId, project.id, cycleId, stats) + ) + }) + + await Promise.all(playerStatsUpdates) +} + +function _mapById(arr) { + return arr.reduce((result, el) => { + result.set(el.id, el) + return result + }, new Map()) +} diff --git a/server/actions/updateTeamECCStats.js b/server/actions/updateTeamECCStats.js deleted file mode 100644 index 1111102a..00000000 --- a/server/actions/updateTeamECCStats.js +++ /dev/null @@ -1,55 +0,0 @@ -import {getSurveyById} from '../../server/db/survey' -import {getRelativeContributionQuestionForSurvey} from '../../server/db/question' -import {getSurveyResponses} from '../../server/db/response' -import {updatePlayerECCStats} from '../../server/db/player' -import { - getProjectHistoryForCycle, -} from '../../server/db/project' - -export async function updateTeamECCStats(project, cycleId) { - const projectCycle = getProjectHistoryForCycle(project, cycleId) - const teamSize = projectCycle.playerIds.length - const surveyId = projectCycle.retrospectiveSurveyId - const survey = await getSurveyById(surveyId) - const {id: questionId} = await getRelativeContributionQuestionForSurvey(survey) - const responsesBySubjectId = await getResponsesBySubjectId(surveyId, questionId) - - const promises = [] - responsesBySubjectId.forEach((responses, subjectPlayerId) => { - const relativeContributionScores = responses.map(({value}) => value) - const projectECC = calculateProjectECCStatsForPlayer({teamSize, relativeContributionScores}) - promises.push(updatePlayerECCStats(subjectPlayerId, projectECC, cycleId, project.id)) - }) - - await Promise.all(promises) -} - -export function calculateProjectECCStatsForPlayer({teamSize, relativeContributionScores, projectLength}) { - // Calculate ABC - const aggregateBuildCycles = (projectLength || 1) * teamSize - - // Calculate RC - const sum = relativeContributionScores.reduce((sum, next) => sum + next, 0) - const relativeContribution = Math.round(sum / relativeContributionScores.length) - - // Calculate ECC - const effectiveContributionCycles = relativeContribution * aggregateBuildCycles - - return { - ecc: effectiveContributionCycles, - abc: aggregateBuildCycles, - rc: relativeContribution, - } -} - -async function getResponsesBySubjectId(surveyId, questionId) { - const responses = await getSurveyResponses(surveyId, questionId) - - const responsesBySubjectId = responses.reduce((result, response) => { - const current = result.get(response.subjectId) || [] - result.set(response.subjectId, current.concat(response)) - return result - }, new Map()) - - return responsesBySubjectId -} diff --git a/server/db/__tests__/player.test.js b/server/db/__tests__/player.test.js index 0ccdb194..a0c9ae3b 100644 --- a/server/db/__tests__/player.test.js +++ b/server/db/__tests__/player.test.js @@ -7,9 +7,9 @@ import factory from '../../../test/factories' import {withDBCleanup} from '../../../test/helpers' import { - reassignPlayersToChapter, getPlayerById, - updatePlayerECCStats, + reassignPlayersToChapter, + savePlayerProjectStats, } from '../player' describe(testContext(__filename), function () { @@ -91,92 +91,126 @@ describe(testContext(__filename), function () { }) }) - describe('updatePlayerECCStats', function () { + describe('savePlayerProjectStats', function () { beforeEach(async function () { this.projectIds = [await r.uuid(), await r.uuid()] this.cycleIds = [await r.uuid(), await r.uuid()] - this.player = await factory.create('player', {ecc: 0}) + this.player = await factory.create('player', {stats: {ecc: 0}}) this.fetchPlayer = () => getPlayerById(this.player.id) }) - it('creates the ecc attribute if missing', async function() { - await getPlayerById(this.player.id).replace(p => p.without('ecc')) + it('creates the stats.ecc attribute if missing', async function() { + const projectCycleStats = {ecc: 40, abc: 4, rc: 10, ls: 80, cc: 90, hours: 35, ec: 15, ecd: -5} + await getPlayerById(this.player.id).replace(p => p.without('stats')) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], projectCycleStats) - await updatePlayerECCStats(this.player.id, {ecc: 40, abc: 4, rc: 10}, this.cycleIds[0], this.projectIds[0]) + const player = await this.fetchPlayer() - expect(await this.fetchPlayer()).to.have.property('ecc', 40) + expect(player.stats.ecc).to.eq(40) + expect(player.stats.projects).to.deep.eq({ + [this.projectIds[0]]: { + cycles: {[this.cycleIds[0]]: projectCycleStats} + }, + }) }) - it('adds to the existing ECC', async function() { - expect(this.player).to.have.property('ecc') - await getPlayerById(this.player.id).update({ecc: 10}) + it('adds to the existing cumulative stats.ecc', async function() { + expect(this.player).to.have.deep.property('stats.ecc') - await updatePlayerECCStats(this.player.id, {ecc: 20, abc: 4, rc: 5}, this.cycleIds[1], this.projectIds[1]) + const projectCycleStats = {ecc: 20, abc: 4, rc: 5, ec: 10, ecd: -5, ls: 80, cc: 85, hours: 30} + await getPlayerById(this.player.id).update({stats: {ecc: 10}}) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], projectCycleStats) - expect(await this.fetchPlayer()).to.have.property('ecc', 30) + const player = await this.fetchPlayer() + + expect(player.stats.ecc).to.eq(30) + expect(player.stats.projects).to.deep.eq({ + [this.projectIds[1]]: { + cycles: {[this.cycleIds[1]]: projectCycleStats} + }, + }) }) - it('creates the cycleProjectECC attr if neccessary', async function () { - expect(this.player).to.not.have.property('cycleProjectECC') + it('creates the stats.projects attribute if neccessary', async function () { + expect(this.player).to.not.have.deep.property('stats.projects') - const stats = {ecc: 20, abc: 4, rc: 5} - await updatePlayerECCStats(this.player.id, stats, this.cycleIds[0], this.projectIds[0]) + const projectCycleStats = {ecc: 20, abc: 4, rc: 5, ec: 10, ecd: -5, ls: 80, cc: 85, hours: 30} + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], projectCycleStats) - expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ - [this.cycleIds[0]]: {[this.projectIds[0]]: stats} + const player = await this.fetchPlayer() + + expect(player.stats.ecc).to.eq(20) + expect(player.stats.projects).to.deep.eq({ + [this.projectIds[0]]: { + cycles: {[this.cycleIds[0]]: projectCycleStats} + }, }) }) - it('adds an item to the existing cycleProjectECC if needed', async function () { - expect(this.player).to.not.have.property('cycleProjectECC') + it('adds a project entry to the stats if neccessary', async function () { + expect(this.player).to.not.have.deep.property('stats.projects') - const stats = [ - {ecc: 20, abc: 4, rc: 5}, - {ecc: 18, abc: 3, rc: 6}, + const projectCycleStats = [ + {ecc: 20, abc: 4, rc: 5, ec: 10, ecd: -5, ls: 80, cc: 85, hours: 30}, + {ecc: 18, abc: 3, rc: 6, ec: 20, ecd: -14, ls: 90, cc: 95, hours: 40}, ] - await updatePlayerECCStats(this.player.id, stats[0], this.cycleIds[0], this.projectIds[0]) - await updatePlayerECCStats(this.player.id, stats[1], this.cycleIds[1], this.projectIds[1]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], projectCycleStats[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], projectCycleStats[1]) + + const player = await this.fetchPlayer() - expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ - [this.cycleIds[0]]: {[this.projectIds[0]]: stats[0]}, - [this.cycleIds[1]]: {[this.projectIds[1]]: stats[1]}, + expect(player.stats.ecc).to.eq(38) + expect(player.stats.projects).to.deep.eq({ + [this.projectIds[0]]: { + cycles: {[this.cycleIds[0]]: projectCycleStats[0]} + }, + [this.projectIds[1]]: { + cycles: {[this.cycleIds[1]]: projectCycleStats[1]} + }, }) }) - it('adds project ecc to the existing cycleProjectECC item if present', async function () { - expect(this.player).to.not.have.property('cycleProjectECC') + it('adds a cycle entry to the project stats if needed', async function () { + expect(this.player).to.not.have.deep.property('stats.projects') - const stats = [ - {ecc: 20, abc: 4, rc: 5}, - {ecc: 18, abc: 3, rc: 6}, + const projectCycleStats = [ + {ecc: 20, abc: 4, rc: 5, ec: 10, ecd: -5, ls: 80, cc: 85, hours: 30}, + {ecc: 18, abc: 3, rc: 6, ec: 20, ecd: -14, ls: 90, cc: 95, hours: 40}, ] - await updatePlayerECCStats(this.player.id, stats[0], this.cycleIds[0], this.projectIds[0]) - await updatePlayerECCStats(this.player.id, stats[1], this.cycleIds[0], this.projectIds[1]) - - expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ - [this.cycleIds[0]]: { - [this.projectIds[0]]: stats[0], - [this.projectIds[1]]: stats[1], + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], projectCycleStats[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[1], projectCycleStats[1]) + + const player = await this.fetchPlayer() + + expect(player.stats.ecc).to.eq(38) + expect(player.stats.projects).to.deep.eq({ + [this.projectIds[0]]: { + cycles: { + [this.cycleIds[0]]: projectCycleStats[0], + [this.cycleIds[1]]: projectCycleStats[1], + }, }, }) }) it('when called for the same project/cycle more than once, the result is the same as if only the last call were made', async function () { // Initialize the player with an ECC of 10 - await updatePlayerECCStats(this.player.id, {ecc: 10, abc: 2, rc: 5}, this.cycleIds[0], this.projectIds[0]) + const projectCycleStats1 = {ecc: 10, abc: 2, rc: 5, ec: 10, ecd: -5, ls: 80, cc: 85, hours: 30} + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], projectCycleStats1) // Add 20 for a project - await updatePlayerECCStats(this.player.id, {ecc: 20, abc: 4, rc: 5}, this.cycleIds[1], this.projectIds[1]) - expect(await this.fetchPlayer()).to.have.property('ecc', 30) + const projectCycleStats2 = {ecc: 20, abc: 4, rc: 5, ec: 10, ecd: -5, ls: 90, cc: 95, hours: 30} + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], projectCycleStats2) + expect(await this.fetchPlayer()).to.have.deep.property('stats.ecc', 30) expect(await this.fetchPlayer()).to.have.deep - .property(`cycleProjectECC.${this.cycleIds[1]}.${this.projectIds[1]}.ecc`, 20) + .property(`stats.projects.${this.projectIds[1]}.cycles.${this.cycleIds[1]}`).deep.eq(projectCycleStats2) // Change the ECC for that project to 10 - const stats = {ecc: 10, abc: 2, rc: 5} - await updatePlayerECCStats(this.player.id, stats, this.cycleIds[1], this.projectIds[1]) - expect(await this.fetchPlayer()).to.have.property('ecc', 20) + const projectCycleStats3 = {ecc: 10, abc: 2, rc: 5, ec: 10, ecd: -5, ls: 95, cc: 97, hours: 30} + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], projectCycleStats3) + expect(await this.fetchPlayer()).to.have.deep.property('stats.ecc', 20) expect(await this.fetchPlayer()).to.have.deep - .property(`cycleProjectECC.${this.cycleIds[1]}.${this.projectIds[1]}`).deep.eq(stats) + .property(`stats.projects.${this.projectIds[1]}.cycles.${this.cycleIds[1]}`).deep.eq(projectCycleStats3) }) }) }) diff --git a/server/db/player.js b/server/db/player.js index afc9c653..7036961c 100644 --- a/server/db/player.js +++ b/server/db/player.js @@ -1,8 +1,6 @@ import r from '../../db/connect' import {updateInTable} from '../../server/db/util' -export const playersTable = r.table('players') - export function getPlayerById(id, passedOptions = {}) { const options = Object.assign({ mergeChapter: false, @@ -23,25 +21,29 @@ export function findPlayersByIds(playerIds) { return r.table('players').getAll(...playerIds) } -export function updatePlayerECCStats(playerId, stats, cycleId, projectId) { - const deltaECC = stats.ecc - const cycleProjectECC = r.row('cycleProjectECC').default({}) - const eccAlreadyRecordedForProject = cycleProjectECC(cycleId).default({}).hasFields(projectId) - const previousECCForProject = cycleProjectECC(cycleId)(projectId)('ecc') - const newECC = r.branch( - eccAlreadyRecordedForProject, - r.row('ecc').sub(previousECCForProject).add(deltaECC), - r.row('ecc').add(deltaECC).default(deltaECC), - ) +export function savePlayerProjectStats(playerId, projectId, cycleId, newStats = {}) { + const playerStats = r.row('stats').default({}) + const playerProjectStats = playerStats('projects').default({}) + const playerProjectCycleStats = playerProjectStats(projectId).default({})('cycles').default({}) + const playerProjectCycleECC = playerProjectCycleStats(cycleId).default({})('ecc').default(0) - const newCycleProjectECC = cycleProjectECC.merge(row => ({ - [cycleId]: row(cycleId).default({}).merge({[projectId]: stats}) + const mergedProjectStats = playerProjectStats.merge(projects => ({ + [projectId]: projects(projectId).default({}).merge(project => ({ + cycles: project('cycles').default({}).merge(cycles => ({ + [cycleId]: cycles(cycleId).default({}).merge(newStats) + })) + })) })) + const currentECC = playerStats('ecc').default(0) + const updatedECC = currentECC.sub(playerProjectCycleECC).add(newStats.ecc || 0) + return update({ id: playerId, - ecc: newECC, - cycleProjectECC: newCycleProjectECC, + stats: { + ecc: updatedECC, + projects: mergedProjectStats, + } }) } @@ -82,5 +84,5 @@ export function findPlayersForChapter(chapterId, filters) { } export function update(record, options) { - return updateInTable(record, playersTable, options) + return updateInTable(record, r.table('players'), options) } diff --git a/server/db/question.js b/server/db/question.js index 4a3af7b1..79b83f96 100644 --- a/server/db/question.js +++ b/server/db/question.js @@ -11,6 +11,10 @@ export function getActiveQuestionsByIds(ids) { return questionsTable.getAll(...ids).filter({active: true}) } +export function findQuestionsByIds(ids) { + return questionsTable.getAll(...ids) +} + export function saveQuestions(questions, options) { return Promise.all(questions.map(question => replaceInTable(question, questionsTable, options) diff --git a/server/db/response.js b/server/db/response.js index f3480bc9..b29d73ca 100644 --- a/server/db/response.js +++ b/server/db/response.js @@ -7,6 +7,10 @@ export function getResponseById(id) { return responsesTable.get(id) } +export function findResponsesBySurveyId(surveyId) { + return responsesTable.getAll(surveyId, {index: 'surveyId'}) +} + export function getSurveyResponsesForPlayer(respondentId, surveyId, questionId, subjectIds) { const responseExpr = responsesTable.getAll([ questionId, diff --git a/server/util/__tests__/stats.test.js b/server/util/__tests__/stats.test.js new file mode 100644 index 00000000..2624cee1 --- /dev/null +++ b/server/util/__tests__/stats.test.js @@ -0,0 +1,138 @@ +/* eslint-env mocha */ +/* global expect, testContext */ +/* eslint-disable prefer-arrow-callback, no-unused-expressions */ +import { + aggregateBuildCycles, + relativeContribution, + expectedContribution, + expectedContributionDelta, + effectiveContributionCycles, + learningSupport, + cultureContrbution, +} from '../stats' + +describe(testContext(__filename), function () { + describe('aggregateBuildCycles()', function () { + it('default build cycles (1)', function () { + const numPlayers = 4 + const abc = aggregateBuildCycles(numPlayers) + expect(abc).to.eq(4) + }) + + it('build cycles > 1', function () { + const numPlayers = 4 + const numBuildCycles = 3 + const abc = aggregateBuildCycles(numPlayers, numBuildCycles) + expect(abc).to.eq(12) + }) + }) + + describe('relativeContribution()', function () { + it('none', function () { + const rc = relativeContribution([]) + expect(rc).to.eq(0) + }) + + it('even', function () { + const rc = relativeContribution([10, 20, 20, 30]) + expect(rc).to.eq(20) + }) + + it('round up', function () { + const rc = relativeContribution([10, 10, 21, 21]) + expect(rc).to.eq(16) + }) + + it('round down', function () { + const rc = relativeContribution([10, 10, 21, 20]) + expect(rc).to.eq(15) + }) + }) + + describe('expectedContribution()', function () { + it('none', function () { + const playerHours = 0 + const teamHours = 0 + const ec = expectedContribution(playerHours, teamHours) + expect(ec).to.eq(0) + }) + + it('normal', function () { + const playerHours = 20 + const teamHours = 100 + const ec = expectedContribution(playerHours, teamHours) + expect(ec).to.eq(20) + }) + }) + + describe('expectedContributionDelta()', function () { + it('none', function () { + const rc = 0 + const ec = 0 + const ecd = expectedContributionDelta(ec, rc) + expect(ecd).to.eq(0) + }) + + it('positive', function () { + const rc = 35 + const ec = 30 + const ecd = expectedContributionDelta(ec, rc) + expect(ecd).to.eq(5) + }) + + it('negative', function () { + const rc = 30 + const ec = 35 + const ecd = expectedContributionDelta(ec, rc) + expect(ecd).to.eq(-5) + }) + + it('exact', function () { + const rc = 30 + const ec = 30 + const ecd = expectedContributionDelta(ec, rc) + expect(ecd).to.eq(0) + }) + }) + + describe('effectiveContributionCycles()', function () { + const abc = 4 + const rc = 25 + const ecc = effectiveContributionCycles(abc, rc) + expect(ecc).to.eq(100) + }) + + describe('learningSupport()', function () { + it('none', function () { + const ls = learningSupport([]) + expect(ls).to.eq(0) + }) + + it('round down', function () { + const ls = learningSupport([5, 6, 7]) + expect(ls).to.eq(83) + }) + + it('round up', function () { + const ls = learningSupport([5, 7, 7]) + expect(ls).to.eq(89) + }) + }) + + describe('cultureContrbution()', function () { + it('none', function () { + const cc = cultureContrbution([]) + expect(cc).to.eq(0) + }) + + it('round down', function () { + const cc = cultureContrbution([5, 6, 7]) + expect(cc).to.eq(83) + }) + + it('round up', function () { + const cc = cultureContrbution([5, 7, 7]) + expect(cc).to.eq(89) + }) + }) +}) diff --git a/server/util/index.js b/server/util/index.js index b5596b0e..c50a2ab3 100644 --- a/server/util/index.js +++ b/server/util/index.js @@ -74,6 +74,13 @@ export function getSocket() { return socket } +export function sum(values) { + if (!Array.isArray(values)) { + return null + } + return values.reduce((result, n) => result + n, 0) +} + export function toArray(val) { if (Array.isArray(val)) { return val diff --git a/server/util/stats.js b/server/util/stats.js new file mode 100644 index 00000000..68bad0dc --- /dev/null +++ b/server/util/stats.js @@ -0,0 +1,67 @@ +import {sum} from './index' + +export function aggregateBuildCycles(numPlayers, numBuildCycles = 1) { + if (numPlayers === null || numBuildCycles === null || isNaN(numPlayers) || isNaN(numBuildCycles)) { + return null + } + return numPlayers * numBuildCycles +} + +export function relativeContribution(rcScores) { + if (!Array.isArray(rcScores)) { + return null + } + if (!rcScores.length) { + return 0 + } + const rcScoresSum = sum(rcScores) + return Math.round(rcScoresSum / rcScores.length) +} + +export function expectedContribution(playerHours, teamHours) { + if (playerHours === null || isNaN(playerHours) || isNaN(teamHours)) { + return null + } + if (teamHours === 0) { + return 0 + } + return Math.round((playerHours / teamHours) * 100) +} + +export function expectedContributionDelta(ec, rc) { + if (ec === null || rc === null || isNaN(ec) || isNaN(rc)) { + return null + } + return rc - ec +} + +export function effectiveContributionCycles(abc, rc) { + if (abc === null || rc === null || isNaN(abc) || isNaN(rc)) { + return null + } + return abc * rc +} + +export function learningSupport(lsScores) { + return averageScore(lsScores) +} + +export function cultureContrbution(ccScores) { + return averageScore(ccScores) +} + +export const SCORE_MIN = 1 +export const SCORE_MAX = 7 +export const SCORE_RANGE = SCORE_MAX - SCORE_MIN +export function averageScore(scores) { + if (!Array.isArray(scores)) { + return null + } + if (!scores.length) { + return 0 + } + const adjustedScores = scores.filter(n => (n >= SCORE_MIN && n <= SCORE_MAX)).map(n => n - SCORE_MIN) + const adjustedScoresSum = sum(adjustedScores.map(n => n / SCORE_RANGE)) + const averageScorePercent = Math.round((adjustedScoresSum / adjustedScores.length) * 100) + return averageScorePercent +} diff --git a/server/util/survey.js b/server/util/survey.js new file mode 100644 index 00000000..bbbe0152 --- /dev/null +++ b/server/util/survey.js @@ -0,0 +1,82 @@ +export const STATS_QUESTION_TYPES = { + RELATIVE_CONTRIBUTION: 'RELATIVE_CONTRIBUTION', + LEARNING_SUPPORT: 'LEARNING_SUPPORT', + CULTURE_CONTRIBUTION: 'CULTURE_CONTRIBUTION', + PROJECT_HOURS: 'PROJECT_HOURS', + GENERAL_FEEDBACK: 'GENERAL_FEEDBACK', +} + +export function groupResponsesBySubject(surveyResponses) { + return surveyResponses.reduce((result, response) => { + const {subjectId} = response + + if (!result.has(subjectId)) { + result.set(subjectId, []) + } + result.get(subjectId).push(response) + + return result + }, new Map()) +} + +export function findQuestionByType(questions, questionType) { + if (!Array.isArray(questions)) { + return null + } + + return filterQuestionsByType(questions, questionType)[0] +} + +export function filterQuestionsByType(questions, questionType) { + if (!Array.isArray(questions)) { + return [] + } + + switch (questionType) { + case STATS_QUESTION_TYPES.RELATIVE_CONTRIBUTION: + return questions.filter(q => _isStatsQuestionRC(q)) + + case STATS_QUESTION_TYPES.LEARNING_SUPPORT: + return questions.filter(q => _isStatsQuestionLS(q)) + + case STATS_QUESTION_TYPES.CULTURE_CONTRIBUTION: + return questions.filter(q => _isStatsQuestionCC(q)) + + case STATS_QUESTION_TYPES.PROJECT_HOURS: + return questions.filter(q => _isStatsQuestionHours(q)) + + case STATS_QUESTION_TYPES.GENERAL_FEEDBACK: + return questions.filter(q => _isStatsQuestionGeneral(q)) + + default: + return [] + } +} + +function _isStatsQuestionRC(question) { + return question.responseType === 'relativeContribution' +} + +function _isStatsQuestionLS(question) { + return question.subjectType === 'player' && + question.responseType === 'likert7Agreement' && + (question.body.includes('supported me in learning my craft') || + question.body.includes('better software developer')) +} + +function _isStatsQuestionCC(question) { + return question.subjectType === 'player' && + question.responseType === 'likert7Agreement' && + question.body.includes('contributed positively to our team culture') +} + +function _isStatsQuestionHours(question) { + return question.subjectType === 'project' && + question.responseType === 'text' && + question.body.includes('how many hours') +} + +function _isStatsQuestionGeneral(question) { + return question.subjectType === 'player' && + question.responseType === 'text' +} diff --git a/server/workers/cycleCompleted.js b/server/workers/cycleCompleted.js index 6b36775c..e691c055 100644 --- a/server/workers/cycleCompleted.js +++ b/server/workers/cycleCompleted.js @@ -1,32 +1,115 @@ -import {getQueue} from '../util' import ChatClient from '../../server/clients/ChatClient' import r from '../../db/connect' -import {updateTeamECCStats} from '../../server/actions/updateTeamECCStats' +import getPlayerInfo from '../../server/actions/getPlayerInfo' +import updateProjectStats from '../../server/actions/updateProjectStats' import {getProjectsForChapterInCycle} from '../../server/db/project' +import {findPlayersByIds} from '../../server/db/player' +import {findQuestionsByIds} from '../../server/db/question' +import {findResponsesBySurveyId} from '../../server/db/response' +import {getSurveyById} from '../../server/db/survey' +import {getQueue} from '../util' +import { + STATS_QUESTION_TYPES, + groupResponsesBySubject, + filterQuestionsByType, +} from '../util/survey' export function start() { const cycleCompleted = getQueue('cycleCompleted') cycleCompleted.process(({data: cycle}) => processCompletedCycle(cycle) - .catch(err => console.error(`Error handling cycleCompleted event for ${cycle.id}:`, err)) + .catch(err => console.error(`Error handling cycleCompleted event for ${cycle.id}:`, err, err.stack)) ) } export async function processCompletedCycle(cycle, chatClient = new ChatClient()) { console.log(`Completing cycle ${cycle.cycleNumber} of chapter ${cycle.chapterId}`) - await updateECC(cycle) + await updateStats(cycle, chatClient) await sendCompletionAnnouncement(cycle, chatClient) } -function updateECC(cycle) { +function updateStats(cycle, chatClient) { return getProjectsForChapterInCycle(cycle.chapterId, cycle.id) - .then(projects => - Promise.all( - projects.map(project => updateTeamECCStats(project, cycle.id)) - ) + .then(async projects => + await Promise.all(projects.map(async project => { + await updateProjectStats(project, cycle.id) + + const cycleHistory = (project.cycleHistory || []).find(item => item.cycleId === cycle.id) || {} + const cyclePlayerIds = cycleHistory.playerIds || [] + const cyclePlayers = await findPlayersByIds(cyclePlayerIds) + const cyclePlayerUsers = await getPlayerInfo(cyclePlayerIds) + const players = _mergePlayerUsers(cyclePlayers, cyclePlayerUsers) + + const [retroSurvey, retroResponses] = await Promise.all([ + getSurveyById(cycleHistory.retrospectiveSurveyId), + findResponsesBySurveyId(cycleHistory.retrospectiveSurveyId), + ]) + + const retroQuestionIds = retroSurvey.questionRefs.map(qref => qref.questionId) + const retroQuestions = await findQuestionsByIds(retroQuestionIds) + + const generalFeedbackQuestions = filterQuestionsByType(retroQuestions, STATS_QUESTION_TYPES.GENERAL_FEEDBACK) + const generalFeedbackResponsesBySubject = groupResponsesBySubject(retroResponses.filter(r => { + return generalFeedbackQuestions.find(q => q.id === r.questionId) + })) + + const playerHours = [] + const statsByPlayer = players.reduce((result, player) => { + const projectCycleStats = ((((player.stats || {}).projects || {})[project.id] || {}).cycles || {})[cycle.id] || {} + result.set(player.id, projectCycleStats) + playerHours.push({player, hours: projectCycleStats.hours || 0}) + return result + }, new Map()) + + return Promise.all(players.map(player => { + const feedbackData = { + project, + cycle, + team: players, + teamResponses: generalFeedbackResponsesBySubject.get(player.id) || [], + teamHours: playerHours, + stats: statsByPlayer.get(player.id) || {}, + } + + return sendPlayerProjectStatsDM(player, feedbackData, chatClient) + })) + })) ) } +function sendPlayerProjectStatsDM(player, feedbackData, chatClient) { + const {project, cycle, team, teamResponses, teamHours, stats} = feedbackData + + const teamFeedbackList = teamResponses.map(response => `- ${(response.value || '').trim()}`) + const teamHoursList = teamHours.map(item => `@${item.player.handle} (${item.player.name}): ${item.hours}`) + + const playerRetroFeedbackMessage = `**RETROSPECTIVE RESULTS:** #${project.name} (cycle ${cycle.cycleNumber}) + +**Feedback from your team:** +${teamFeedbackList.join(' \n')} + +**Hours contributed:** +Team size: ${team.length} +Your hours: ${stats.hours || 0} +All team hours: ${stats.teamHours || 0} + +${teamHoursList.join(' \n')} + +**Contribution to the project:** +Self-assessed: ${stats.rcSelf || 0}% +Team-assessed: ${stats.rcOther || 0}% + +Your estimated contribution to the project: ${stats.rc || 0}% +Expected contribution for # of hours: ${stats.ec || 0}% +Contribution difference: ${stats.ecd || 0}% + +**Stats earned for this project:** +Learning Support: ${stats.ls || 0}% +Culture Contribution: ${stats.cc || 0}%` + + return chatClient.sendDirectMessage(player.handle, playerRetroFeedbackMessage) +} + function sendCompletionAnnouncement(cycle, chatClient) { return r.table('chapters').get(cycle.chapterId).run() .then(chapter => { @@ -34,3 +117,18 @@ function sendCompletionAnnouncement(cycle, chatClient) { return chatClient.sendChannelMessage(chapter.channelName, announcement) }) } + +function _mergePlayerUsers(players, users) { + const combined = new Map() + + players.forEach(player => combined.set(player.id, Object.assign({}, player))) + + users.forEach(user => { + const values = combined.get(user.id) + if (values) { + combined.set(user.id, Object.assign({}, values, user)) + } + }) + + return Array.from(combined.values()) +} diff --git a/server/workers/cycleLaunched.js b/server/workers/cycleLaunched.js index c4499bda..029e2eaf 100644 --- a/server/workers/cycleLaunched.js +++ b/server/workers/cycleLaunched.js @@ -1,9 +1,10 @@ import raven from 'raven' import r from '../../db/connect' -import {getQueue, getSocket, graphQLFetcher} from '../util' +import {getQueue, getSocket} from '../util' import ChatClient from '../../server/clients/ChatClient' import formProjects from '../../server/actions/formProjects' +import getPlayerInfo from '../../server/actions/getPlayerInfo' import {findModeratorsForChapter} from '../../server/db/moderator' import {getTeamPlayerIds, getProjectsForChapterInCycle} from '../../server/db/project' import {update as updateCycle} from '../../server/db/cycle' @@ -70,13 +71,6 @@ Run \`/project set-artifact --help\` for more guidance. await chatClient.sendChannelMessage(channelName, projectWelcomeMessage2) } -function getPlayerInfo(playerIds) { - return graphQLFetcher(process.env.IDM_BASE_URL)({ - query: 'query ($playerIds: [ID]!) { getUsersByIds(ids: $playerIds) { handle name } }', - variables: {playerIds}, - }).then(result => result.data.getUsersByIds) -} - function sendCycleLaunchAnnouncement(chatClient, cycle, projects) { const projectListString = projects.map(p => `#${p.name} - _${p.goal.title}_`).join('\n • ') const announcement = `🚀 *The cycle has been launched!* diff --git a/test/factories/player.js b/test/factories/player.js index 5868b8c8..c756362c 100644 --- a/test/factories/player.js +++ b/test/factories/player.js @@ -8,7 +8,7 @@ export default function define(factory) { id: cb => cb(null, faker.random.uuid()), chapterId: factory.assoc('chapter', 'id'), chapterHistory: [], - ecc: 0, + stats: {ecc: 0}, active: true, createdAt: cb => cb(null, now), updatedAt: cb => cb(null, now), diff --git a/test/generatePlaytestData.js b/test/generatePlaytestData.js index 01dfdc1f..3da64972 100644 --- a/test/generatePlaytestData.js +++ b/test/generatePlaytestData.js @@ -116,7 +116,9 @@ function createPlayersOrModerators(table, users, chapter) { if (table === 'players') { data.active = true - data.ecc = i % 5 === 0 ? 50000 : 0 // every 5th player is a "super advanced player" + data.stats = { + ecc: i % 5 === 0 ? 50000 : 0 // every 5th player is a "super advanced player" + } } return data diff --git a/test/generateTestData.js b/test/generateTestData.js index f1a5f2ba..8ad8359e 100644 --- a/test/generateTestData.js +++ b/test/generateTestData.js @@ -24,7 +24,7 @@ function createPlayers(users, chapters) { id: user.id, chapterId: chapterMap[user.inviteCode].id, active: true, - ecc: 0, + stats: {ecc: 0}, } })