From 58f2a3ad1fd64f9fe4cf51f7c4cb6cd09f876dd7 Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Tue, 2 Aug 2016 18:20:15 -0700 Subject: [PATCH 01/11] rename team- and player-stats related functions --- ...ats.test.js => updateProjectStats.test.js} | 22 ++++++++-------- ...eTeamECCStats.js => updateProjectStats.js} | 12 ++++----- server/db/__tests__/player.test.js | 26 +++++++++---------- server/db/player.js | 2 +- server/workers/cycleCompleted.js | 4 +-- 5 files changed, 33 insertions(+), 33 deletions(-) rename server/actions/__tests__/{updateTeamECCStats.test.js => updateProjectStats.test.js} (70%) rename server/actions/{updateTeamECCStats.js => updateProjectStats.js} (77%) diff --git a/server/actions/__tests__/updateTeamECCStats.test.js b/server/actions/__tests__/updateProjectStats.test.js similarity index 70% rename from server/actions/__tests__/updateTeamECCStats.test.js rename to server/actions/__tests__/updateProjectStats.test.js index abe739e1..4e0dbdfe 100644 --- a/server/actions/__tests__/updateTeamECCStats.test.js +++ b/server/actions/__tests__/updateProjectStats.test.js @@ -7,35 +7,35 @@ import {withDBCleanup, useFixture} from '../../../test/helpers' import {getPlayerById} from '../../../server/db/player' import { - calculateProjectECCStatsForPlayer, - updateTeamECCStats, -} from '../updateTeamECCStats' + calculatePlayerProjectStats, + updateProjectStats, +} from '../updateProjectStats' describe(testContext(__filename), function () { - describe('calculateProjectECCStatsForPlayer()', function () { + describe('calculatePlayerProjectStats()', function () { specify('when there are scores from all team members', function () { - expect(calculateProjectECCStatsForPlayer({teamSize: 4, relativeContributionScores: [10, 20, 20, 30]})) + expect(calculatePlayerProjectStats({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]})) + expect(calculatePlayerProjectStats({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]})) + expect(calculatePlayerProjectStats({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})) + expect(calculatePlayerProjectStats({teamSize: 4, relativeContributionScores: [50, 50, 50, 50], buildCycles: 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]})) + expect(calculatePlayerProjectStats({teamSize: 5, relativeContributionScores: [10, 10, 21, 21]})) .to.deep.eq({ecc: 80, abc: 5, rc: 16}) }) }) - describe('updateTeamECCStats', function () { + describe('updateProjectStats', function () { withDBCleanup() useFixture.buildSurvey() @@ -64,7 +64,7 @@ describe(testContext(__filename), function () { it('updates the players ECC based on the survey responses', async function() { const eccChange = 20 * this.teamPlayerIds.length - await updateTeamECCStats(this.project, this.cycleId) + await updateProjectStats(this.project, this.cycleId) const updatedPlayer = await getPlayerById(this.teamPlayerIds[0]) expect(updatedPlayer.ecc).to.eq(eccChange) diff --git a/server/actions/updateTeamECCStats.js b/server/actions/updateProjectStats.js similarity index 77% rename from server/actions/updateTeamECCStats.js rename to server/actions/updateProjectStats.js index 1111102a..ab2ad3c9 100644 --- a/server/actions/updateTeamECCStats.js +++ b/server/actions/updateProjectStats.js @@ -1,12 +1,12 @@ 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 {savePlayerProjectStats} from '../../server/db/player' import { getProjectHistoryForCycle, } from '../../server/db/project' -export async function updateTeamECCStats(project, cycleId) { +export async function updateProjectStats(project, cycleId) { const projectCycle = getProjectHistoryForCycle(project, cycleId) const teamSize = projectCycle.playerIds.length const surveyId = projectCycle.retrospectiveSurveyId @@ -17,16 +17,16 @@ export async function updateTeamECCStats(project, cycleId) { 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)) + const subjectPlayerStats = calculatePlayerProjectStats({teamSize, relativeContributionScores}) + promises.push(savePlayerProjectStats(subjectPlayerId, project.id, cycleId, subjectPlayerStats)) }) await Promise.all(promises) } -export function calculateProjectECCStatsForPlayer({teamSize, relativeContributionScores, projectLength}) { +export function calculatePlayerProjectStats({buildCycles, teamSize, relativeContributionScores}) { // Calculate ABC - const aggregateBuildCycles = (projectLength || 1) * teamSize + const aggregateBuildCycles = (buildCycles || 1) * teamSize // Calculate RC const sum = relativeContributionScores.reduce((sum, next) => sum + next, 0) diff --git a/server/db/__tests__/player.test.js b/server/db/__tests__/player.test.js index 0ccdb194..9dc1df09 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,7 +91,7 @@ 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()] @@ -102,7 +102,7 @@ describe(testContext(__filename), function () { it('creates the ecc attribute if missing', async function() { await getPlayerById(this.player.id).replace(p => p.without('ecc')) - await updatePlayerECCStats(this.player.id, {ecc: 40, abc: 4, rc: 10}, this.cycleIds[0], this.projectIds[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], {ecc: 40, abc: 4, rc: 10}) expect(await this.fetchPlayer()).to.have.property('ecc', 40) }) @@ -111,7 +111,7 @@ describe(testContext(__filename), function () { expect(this.player).to.have.property('ecc') await getPlayerById(this.player.id).update({ecc: 10}) - await updatePlayerECCStats(this.player.id, {ecc: 20, abc: 4, rc: 5}, this.cycleIds[1], this.projectIds[1]) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], {ecc: 20, abc: 4, rc: 5}) expect(await this.fetchPlayer()).to.have.property('ecc', 30) }) @@ -120,7 +120,7 @@ describe(testContext(__filename), function () { expect(this.player).to.not.have.property('cycleProjectECC') const stats = {ecc: 20, abc: 4, rc: 5} - await updatePlayerECCStats(this.player.id, stats, this.cycleIds[0], this.projectIds[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats) expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ [this.cycleIds[0]]: {[this.projectIds[0]]: stats} @@ -134,8 +134,8 @@ describe(testContext(__filename), function () { {ecc: 20, abc: 4, rc: 5}, {ecc: 18, abc: 3, rc: 6}, ] - 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], stats[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], stats[1]) expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ [this.cycleIds[0]]: {[this.projectIds[0]]: stats[0]}, @@ -150,8 +150,8 @@ describe(testContext(__filename), function () { {ecc: 20, abc: 4, rc: 5}, {ecc: 18, abc: 3, rc: 6}, ] - 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]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats[0]) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[0], stats[1]) expect(await this.fetchPlayer()).to.have.property('cycleProjectECC').and.deep.eq({ [this.cycleIds[0]]: { @@ -163,17 +163,17 @@ describe(testContext(__filename), function () { 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]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], {ecc: 10, abc: 2, rc: 5}) // Add 20 for a project - await updatePlayerECCStats(this.player.id, {ecc: 20, abc: 4, rc: 5}, this.cycleIds[1], this.projectIds[1]) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], {ecc: 20, abc: 4, rc: 5}) expect(await this.fetchPlayer()).to.have.property('ecc', 30) expect(await this.fetchPlayer()).to.have.deep .property(`cycleProjectECC.${this.cycleIds[1]}.${this.projectIds[1]}.ecc`, 20) // 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]) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], stats) expect(await this.fetchPlayer()).to.have.property('ecc', 20) expect(await this.fetchPlayer()).to.have.deep .property(`cycleProjectECC.${this.cycleIds[1]}.${this.projectIds[1]}`).deep.eq(stats) diff --git a/server/db/player.js b/server/db/player.js index afc9c653..50e58e08 100644 --- a/server/db/player.js +++ b/server/db/player.js @@ -23,7 +23,7 @@ export function findPlayersByIds(playerIds) { return r.table('players').getAll(...playerIds) } -export function updatePlayerECCStats(playerId, stats, cycleId, projectId) { +export function savePlayerProjectStats(playerId, projectId, cycleId, stats) { const deltaECC = stats.ecc const cycleProjectECC = r.row('cycleProjectECC').default({}) const eccAlreadyRecordedForProject = cycleProjectECC(cycleId).default({}).hasFields(projectId) diff --git a/server/workers/cycleCompleted.js b/server/workers/cycleCompleted.js index be3e6f79..d1b8b6bd 100644 --- a/server/workers/cycleCompleted.js +++ b/server/workers/cycleCompleted.js @@ -1,7 +1,7 @@ import {getQueue} from '../util' import ChatClient from '../../server/clients/ChatClient' import r from '../../db/connect' -import {updateTeamECCStats} from '../../server/actions/updateTeamECCStats' +import {updateProjectStats} from '../../server/actions/updateProjectStats' import {getProjectsForChapterInCycle} from '../../server/db/project' export function start() { @@ -22,7 +22,7 @@ function updateECC(cycle) { return getProjectsForChapterInCycle(cycle.chapterId, cycle.id) .then(projects => Promise.all( - projects.map(project => updateTeamECCStats(project, cycle.id)) + projects.map(project => updateProjectStats(project, cycle.id)) ) ) } From a433f5e4427dbca486f31980aa9258d1018593e0 Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Tue, 2 Aug 2016 22:02:38 -0700 Subject: [PATCH 02/11] project stats data model update --- .../__tests__/updateProjectStats.test.js | 2 +- server/db/__tests__/player.test.js | 76 ++++++++++++------- server/db/player.js | 36 ++++----- 3 files changed, 68 insertions(+), 46 deletions(-) diff --git a/server/actions/__tests__/updateProjectStats.test.js b/server/actions/__tests__/updateProjectStats.test.js index 4e0dbdfe..4820ca2c 100644 --- a/server/actions/__tests__/updateProjectStats.test.js +++ b/server/actions/__tests__/updateProjectStats.test.js @@ -67,7 +67,7 @@ describe(testContext(__filename), function () { await updateProjectStats(this.project, this.cycleId) const updatedPlayer = await getPlayerById(this.teamPlayerIds[0]) - expect(updatedPlayer.ecc).to.eq(eccChange) + expect(updatedPlayer.stats.ecc).to.eq(eccChange) }) }) }) diff --git a/server/db/__tests__/player.test.js b/server/db/__tests__/player.test.js index 9dc1df09..bdc0bf12 100644 --- a/server/db/__tests__/player.test.js +++ b/server/db/__tests__/player.test.js @@ -95,40 +95,48 @@ describe(testContext(__filename), 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')) - + await getPlayerById(this.player.id).replace(p => p.without('stats')) await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], {ecc: 40, abc: 4, rc: 10}) - expect(await this.fetchPlayer()).to.have.property('ecc', 40) + const player = await this.fetchPlayer() + + expect(player.stats.ecc).to.eq(40) }) - 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 ECC', async function() { + expect(this.player).to.have.deep.property('stats.ecc') + await getPlayerById(this.player.id).update({stats: {ecc: 10}}) await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], {ecc: 20, abc: 4, rc: 5}) - expect(await this.fetchPlayer()).to.have.property('ecc', 30) + const player = await this.fetchPlayer() + + expect(player.stats.ecc).to.eq(30) }) - 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 savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats) - 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]]: stats} + }, }) }) - 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}, @@ -137,26 +145,38 @@ describe(testContext(__filename), function () { await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats[0]) await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], stats[1]) - 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]}, + 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]]: stats[0]} + }, + [this.projectIds[1]]: { + cycles: {[this.cycleIds[1]]: stats[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}, ] await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats[0]) - await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[0], stats[1]) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[1], stats[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.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]]: stats[0], + [this.cycleIds[1]]: stats[1], + }, }, }) }) @@ -167,16 +187,16 @@ describe(testContext(__filename), function () { // Add 20 for a project await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], {ecc: 20, abc: 4, rc: 5}) - expect(await this.fetchPlayer()).to.have.property('ecc', 30) + 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]}.ecc`, 20) // Change the ECC for that project to 10 const stats = {ecc: 10, abc: 2, rc: 5} await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], stats) - expect(await this.fetchPlayer()).to.have.property('ecc', 20) + 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(stats) }) }) }) diff --git a/server/db/player.js b/server/db/player.js index 50e58e08..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 savePlayerProjectStats(playerId, projectId, cycleId, stats) { - 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) } From 16f5a6ef7f4182c375d49c794c8b1a23d2f7087c Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Thu, 4 Aug 2016 13:29:11 -0700 Subject: [PATCH 03/11] add project stats data migration --- db/migrations/20160804122354-player-stats.js | 84 ++++++++++++++++++++ package.json | 8 +- server/workers/cycleCompleted.js | 4 +- 3 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 db/migrations/20160804122354-player-stats.js 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/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/workers/cycleCompleted.js b/server/workers/cycleCompleted.js index d1b8b6bd..cf8e4992 100644 --- a/server/workers/cycleCompleted.js +++ b/server/workers/cycleCompleted.js @@ -14,11 +14,11 @@ export function start() { export async function processCompletedCycle(cycle, chatClient = new ChatClient()) { console.log(`Completing cycle ${cycle.cycleNumber} of chapter ${cycle.chapterId}`) - await updateECC(cycle) + await updateStats(cycle) await sendCompletionAnnouncement(cycle, chatClient) } -function updateECC(cycle) { +function updateStats(cycle) { return getProjectsForChapterInCycle(cycle.chapterId, cycle.id) .then(projects => Promise.all( From f56f99311c6d507676a2d17d162d917f09a7f80b Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Thu, 4 Aug 2016 13:36:05 -0700 Subject: [PATCH 04/11] update project formation and test data generators to use new player stats structure --- server/actions/__tests__/formProjects.test.js | 2 +- server/actions/formProjects.js | 4 +++- test/factories/player.js | 2 +- test/generatePlaytestData.js | 4 +++- test/generateTestData.js | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) 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/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/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}, } }) From 5097b6db4d744de6db20f5aca41a9d26acc8ea54 Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Thu, 4 Aug 2016 19:59:17 -0700 Subject: [PATCH 05/11] calculate and store additional player project cycle stats --- .../20160804151238-responsesIndexSurveyId.js | 11 + .../__tests__/updateProjectStats.test.js | 109 ++++++---- server/actions/updateProjectStats.js | 192 ++++++++++++++---- server/db/__tests__/player.test.js | 30 +-- server/db/question.js | 4 + server/db/response.js | 4 + server/util/__tests__/stats.test.js | 107 ++++++++++ server/util/stats.js | 54 +++++ 8 files changed, 428 insertions(+), 83 deletions(-) create mode 100644 db/migrations/20160804151238-responsesIndexSurveyId.js create mode 100644 server/util/__tests__/stats.test.js create mode 100644 server/util/stats.js 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/server/actions/__tests__/updateProjectStats.test.js b/server/actions/__tests__/updateProjectStats.test.js index 4820ca2c..13c9aa33 100644 --- a/server/actions/__tests__/updateProjectStats.test.js +++ b/server/actions/__tests__/updateProjectStats.test.js @@ -6,68 +6,109 @@ import factory from '../../../test/factories' import {withDBCleanup, useFixture} from '../../../test/helpers' import {getPlayerById} from '../../../server/db/player' -import { - calculatePlayerProjectStats, - updateProjectStats, -} from '../updateProjectStats' +import {updateProjectStats} from '../updateProjectStats' describe(testContext(__filename), function () { - describe('calculatePlayerProjectStats()', function () { - specify('when there are scores from all team members', function () { - expect(calculatePlayerProjectStats({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(calculatePlayerProjectStats({teamSize: 4, relativeContributionScores: [20, 25, 30]})) - .to.deep.eq({ecc: 100, abc: 4, rc: 25}) - }) - specify('when the result is over 100', function () { - expect(calculatePlayerProjectStats({teamSize: 4, relativeContributionScores: [50, 50, 50, 50]})) - .to.deep.eq({ecc: 200, abc: 4, rc: 50}) - }) - specify('when project length is > 1', function () { - expect(calculatePlayerProjectStats({teamSize: 4, relativeContributionScores: [50, 50, 50, 50], buildCycles: 3})) - .to.deep.eq({ecc: 600, abc: 12, rc: 50}) - }) - specify('when RC is a decimal, round', function () { - expect(calculatePlayerProjectStats({teamSize: 5, relativeContributionScores: [10, 10, 21, 21]})) - .to.deep.eq({ecc: 80, abc: 5, rc: 16}) - }) - }) - describe('updateProjectStats', function () { withDBCleanup() useFixture.buildSurvey() beforeEach('Setup Survey Data', async function () { - const teamQuestion = await factory.create('question', { + 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: teamQuestion.id, subjectIds: () => this.teamPlayerIds}, + {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: teamQuestion.id, + 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: 20 + 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 ECC based on the survey responses', async function() { - const eccChange = 20 * this.teamPlayerIds.length + 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.ecc).to.eq(eccChange) + + 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, + ecc: expectedECC, + hours: 35, + }, + }, + }, + }, + }) }) }) }) diff --git a/server/actions/updateProjectStats.js b/server/actions/updateProjectStats.js index ab2ad3c9..b36f50c2 100644 --- a/server/actions/updateProjectStats.js +++ b/server/actions/updateProjectStats.js @@ -1,55 +1,179 @@ import {getSurveyById} from '../../server/db/survey' -import {getRelativeContributionQuestionForSurvey} from '../../server/db/question' -import {getSurveyResponses} from '../../server/db/response' +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 { - getProjectHistoryForCycle, -} from '../../server/db/project' + aggregateBuildCycles, + relativeContribution, + expectedContribution, + expectedContributionDelta, + effectiveContributionCycles, + learningSupport, + cultureContrbution, +} from '../../server/util/stats' + +const QUESTION_TYPES = { + RELATIVE_CONTRIBUTION: 'RELATIVE_CONTRIBUTION', + LEARNING_SUPPORT: 'LEARNING_SUPPORT', + CULTURE_CONTRIBUTION: 'CULTURE_CONTRIBUTION', + PROJECT_HOURS: 'PROJECT_HOURS', +} export async function updateProjectStats(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 subjectPlayerStats = calculatePlayerProjectStats({teamSize, relativeContributionScores}) - promises.push(savePlayerProjectStats(subjectPlayerId, project.id, cycleId, subjectPlayerStats)) + 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) + const retroQuestionMap = _mapById(retroQuestions) + + // hacky, brittle way of mapping stat types to questions + // FIXME (ASAP): see https://github.com/LearnersGuild/game/issues/370 + const questionLS = _findQuestionByType(retroQuestions, QUESTION_TYPES.LEARNING_SUPPORT) + const questionCC = _findQuestionByType(retroQuestions, QUESTION_TYPES.CULTURE_CONTRIBUTION) + const questionRC = _findQuestionByType(retroQuestions, QUESTION_TYPES.RELATIVE_CONTRIBUTION) + const questionHours = _findQuestionByType(retroQuestions, QUESTION_TYPES.PROJECT_HOURS) + + const projectResponses = [] + const playerResponses = [] + + // separate responses about projects from responses about players + 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) + } + }) }) - await Promise.all(promises) + // dig out values needed for stats from question responses about each player + const playerStatsUpdates = [] + playerResponseGroups.forEach((responseGroup, playerSubjectId) => { + const lsScores = [] + const ccScores = [] + const rcScores = [] + + responseGroup.forEach(response => { + const { + questionId: responseQuestionId, + value: responseValue, + } = response + + switch (responseQuestionId) { + case questionLS.id: + lsScores.push(parseInt(responseValue, 10) || 0) + break + case questionCC.id: + ccScores.push(parseInt(responseValue, 10) || 0) + break + case questionRC.id: + rcScores.push(parseInt(responseValue, 10) || 0) + 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) + + playerStatsUpdates.push( + savePlayerProjectStats(playerSubjectId, project.id, cycleId, {abc, rc, ec, ecd, ecc, ls, cc, hours}) + ) + }) + + await Promise.all(playerStatsUpdates) +} + +function _mapById(arr) { + return arr.reduce((result, el) => { + result.set(el.id, el) + return result + }, new Map()) } -export function calculatePlayerProjectStats({buildCycles, teamSize, relativeContributionScores}) { - // Calculate ABC - const aggregateBuildCycles = (buildCycles || 1) * teamSize +function _findQuestionByType(questions, questionType) { + // see see https://github.com/LearnersGuild/game/issues/370 + switch (questionType) { + case QUESTION_TYPES.RELATIVE_CONTRIBUTION: + return questions.find(q => { + return q.responseType === 'relativeContribution' + }) || {} - // Calculate RC - const sum = relativeContributionScores.reduce((sum, next) => sum + next, 0) - const relativeContribution = Math.round(sum / relativeContributionScores.length) + case QUESTION_TYPES.LEARNING_SUPPORT: + return questions.find(q => { + return q.subjectType === 'player' && + q.responseType === 'likert7Agreement' && + q.body.includes('supported me in learning my craft') + }) || {} - // Calculate ECC - const effectiveContributionCycles = relativeContribution * aggregateBuildCycles + case QUESTION_TYPES.CULTURE_CONTRIBUTION: + return questions.find(q => { + return q.subjectType === 'player' && + q.responseType === 'likert7Agreement' && + q.body.includes('contributed positively to our team culture') + }) || {} - return { - ecc: effectiveContributionCycles, - abc: aggregateBuildCycles, - rc: relativeContribution, + case QUESTION_TYPES.PROJECT_HOURS: + return questions.find(q => { + return q.subjectType === 'project' && + q.responseType === 'text' && + q.body.includes('how many hours') + }) || {} + + default: + return {} } } -async function getResponsesBySubjectId(surveyId, questionId) { - const responses = await getSurveyResponses(surveyId, questionId) +function _groupResponsesBySubject(surveyResponses) { + return surveyResponses.reduce((result, response) => { + const {subjectId} = response + + if (!result.has(subjectId)) { + result.set(subjectId, []) + } + result.get(subjectId).push(response) - 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 bdc0bf12..2c9288f4 100644 --- a/server/db/__tests__/player.test.js +++ b/server/db/__tests__/player.test.js @@ -99,7 +99,7 @@ describe(testContext(__filename), function () { this.fetchPlayer = () => getPlayerById(this.player.id) }) - it('creates the ecc attribute if missing', async function() { + it('creates the stats.ecc attribute if missing', async function() { await getPlayerById(this.player.id).replace(p => p.without('stats')) await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], {ecc: 40, abc: 4, rc: 10}) @@ -108,7 +108,7 @@ describe(testContext(__filename), function () { expect(player.stats.ecc).to.eq(40) }) - it('adds to the existing cumulative ECC', async function() { + it('adds to the existing cumulative stats.ecc', async function() { expect(this.player).to.have.deep.property('stats.ecc') await getPlayerById(this.player.id).update({stats: {ecc: 10}}) @@ -122,15 +122,15 @@ describe(testContext(__filename), function () { 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 savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats) + const projectCycleStats = {ecc: 20, abc: 4, rc: 5, ec: 10, ecd: 20, ls: 80, cc: 85, hours: 30} + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], projectCycleStats) 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]]: stats} + cycles: {[this.cycleIds[0]]: projectCycleStats} }, }) }) @@ -138,22 +138,22 @@ describe(testContext(__filename), function () { it('adds a project entry to the stats if neccessary', async function () { expect(this.player).to.not.have.deep.property('stats.projects') - const stats = [ + const projectCycleStats = [ {ecc: 20, abc: 4, rc: 5}, {ecc: 18, abc: 3, rc: 6}, ] - await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats[0]) - await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], stats[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(player.stats.ecc).to.eq(38) expect(player.stats.projects).to.deep.eq({ [this.projectIds[0]]: { - cycles: {[this.cycleIds[0]]: stats[0]} + cycles: {[this.cycleIds[0]]: projectCycleStats[0]} }, [this.projectIds[1]]: { - cycles: {[this.cycleIds[1]]: stats[1]} + cycles: {[this.cycleIds[1]]: projectCycleStats[1]} }, }) }) @@ -161,12 +161,12 @@ describe(testContext(__filename), function () { 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 = [ + const projectCycleStats = [ {ecc: 20, abc: 4, rc: 5}, {ecc: 18, abc: 3, rc: 6}, ] - await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], stats[0]) - await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[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() @@ -174,8 +174,8 @@ describe(testContext(__filename), function () { expect(player.stats.projects).to.deep.eq({ [this.projectIds[0]]: { cycles: { - [this.cycleIds[0]]: stats[0], - [this.cycleIds[1]]: stats[1], + [this.cycleIds[0]]: projectCycleStats[0], + [this.cycleIds[1]]: projectCycleStats[1], }, }, }) 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..2d6a2455 --- /dev/null +++ b/server/util/__tests__/stats.test.js @@ -0,0 +1,107 @@ +/* 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('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 () { + const playerHours = 20 + const teamHours = 100 + const ec = expectedContribution(playerHours, teamHours) + expect(ec).to.eq(20) + }) + + describe('expectedContributionDelta()', function () { + 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('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('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/stats.js b/server/util/stats.js new file mode 100644 index 00000000..cc5eda8c --- /dev/null +++ b/server/util/stats.js @@ -0,0 +1,54 @@ +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) || !rcScores.length) { + return null + } + const sum = rcScores.reduce((sum, n) => sum + n, 0) + return Math.round(sum / rcScores.length) +} + +export function expectedContribution(playerHours, teamHours) { + if (playerHours === null || isNaN(playerHours) || !teamHours) { + return null + } + 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 function averageScore(scores) { + if (!Array.isArray(scores) || !scores.length) { + return null + } + const adjustedScores = scores.filter(n => (n >= SCORE_MIN && n <= SCORE_MAX)) + const sum = adjustedScores.map(n => ((n - 1) / 6)).reduce((sum, n) => sum + n, 0) + return Math.round((sum / adjustedScores.length) * 100) +} From 622d11e0ae627a6b852924c8ecf1fd7efd14b1ec Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Thu, 4 Aug 2016 23:34:28 -0700 Subject: [PATCH 06/11] send markdown-formatted project stats DM to players after cycle complete --- .../__tests__/updateProjectStats.test.js | 3 + server/actions/updateProjectStats.js | 99 +++++++------------ server/util/index.js | 7 ++ server/util/stats.js | 10 +- server/util/survey.js | 59 +++++++++++ server/workers/cycleCompleted.js | 99 +++++++++++++++++-- 6 files changed, 202 insertions(+), 75 deletions(-) create mode 100644 server/util/survey.js diff --git a/server/actions/__tests__/updateProjectStats.test.js b/server/actions/__tests__/updateProjectStats.test.js index 13c9aa33..534f4eb7 100644 --- a/server/actions/__tests__/updateProjectStats.test.js +++ b/server/actions/__tests__/updateProjectStats.test.js @@ -102,8 +102,11 @@ describe(testContext(__filename), function () { ecd: -5, abc: 4, rc: 20, + rcSelf: 20, + rcOther: 20, ecc: expectedECC, hours: 35, + teamHours: 140, }, }, }, diff --git a/server/actions/updateProjectStats.js b/server/actions/updateProjectStats.js index b36f50c2..55eeb495 100644 --- a/server/actions/updateProjectStats.js +++ b/server/actions/updateProjectStats.js @@ -3,6 +3,7 @@ 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, @@ -12,13 +13,11 @@ import { learningSupport, cultureContrbution, } from '../../server/util/stats' - -const QUESTION_TYPES = { - RELATIVE_CONTRIBUTION: 'RELATIVE_CONTRIBUTION', - LEARNING_SUPPORT: 'LEARNING_SUPPORT', - CULTURE_CONTRIBUTION: 'CULTURE_CONTRIBUTION', - PROJECT_HOURS: 'PROJECT_HOURS', -} +import { + STATS_QUESTION_TYPES, + groupResponsesBySubject, + filterQuestionsByType, +} from '../../server/util/survey' export async function updateProjectStats(project, cycleId) { const projectCycle = getProjectHistoryForCycle(project, cycleId) @@ -32,19 +31,19 @@ export async function updateProjectStats(project, cycleId) { const retroQuestionIds = retroSurvey.questionRefs.map(qref => qref.questionId) const retroQuestions = await findQuestionsByIds(retroQuestionIds) - const retroQuestionMap = _mapById(retroQuestions) // hacky, brittle way of mapping stat types to questions // FIXME (ASAP): see https://github.com/LearnersGuild/game/issues/370 - const questionLS = _findQuestionByType(retroQuestions, QUESTION_TYPES.LEARNING_SUPPORT) - const questionCC = _findQuestionByType(retroQuestions, QUESTION_TYPES.CULTURE_CONTRIBUTION) - const questionRC = _findQuestionByType(retroQuestions, QUESTION_TYPES.RELATIVE_CONTRIBUTION) - const questionHours = _findQuestionByType(retroQuestions, QUESTION_TYPES.PROJECT_HOURS) + const questionLS = filterQuestionsByType(retroQuestions, STATS_QUESTION_TYPES.LEARNING_SUPPORT) + const questionCC = filterQuestionsByType(retroQuestions, STATS_QUESTION_TYPES.CULTURE_CONTRIBUTION) + const questionRC = filterQuestionsByType(retroQuestions, STATS_QUESTION_TYPES.RELATIVE_CONTRIBUTION) + const questionHours = filterQuestionsByType(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 || {} @@ -62,8 +61,8 @@ export async function updateProjectStats(project, cycleId) { } }) - const projectResponseGroups = _groupResponsesBySubject(projectResponses) - const playerResponseGroups = _groupResponsesBySubject(playerResponses) + const projectResponseGroups = groupResponsesBySubject(projectResponses) + const playerResponseGroups = groupResponsesBySubject(playerResponses) // calculate total hours worked by all team members let teamHours = 0 @@ -84,6 +83,8 @@ export async function updateProjectStats(project, cycleId) { const lsScores = [] const ccScores = [] const rcScores = [] + const rcScoresSelf = [] + const rcScoresOther = [] responseGroup.forEach(response => { const { @@ -95,12 +96,22 @@ export async function updateProjectStats(project, cycleId) { case questionLS.id: lsScores.push(parseInt(responseValue, 10) || 0) break + case questionCC.id: ccScores.push(parseInt(responseValue, 10) || 0) break - case questionRC.id: - rcScores.push(parseInt(responseValue, 10) || 0) + + case questionRC.id: { + const value = parseInt(responseValue, 10) || 0 + rcScores.push(value) + if (response.respondentId === playerSubjectId) { + rcScoresSelf.push(value) + } else { + rcScoresOther.push(value) + } + } break + default: return } @@ -116,8 +127,15 @@ export async function updateProjectStats(project, cycleId) { const ecd = expectedContributionDelta(ec, rc) const ecc = effectiveContributionCycles(abc, rc) + const stats = { + ec, ecd, abc, ecc, ls, + cc, hours, teamHours, rc, + rcSelf: Math.round(sum(rcScoresSelf) / rcScoresSelf.length) || 0, + rcOther: Math.round(sum(rcScoresOther) / rcScoresOther.length) || 0, + } + playerStatsUpdates.push( - savePlayerProjectStats(playerSubjectId, project.id, cycleId, {abc, rc, ec, ecd, ecc, ls, cc, hours}) + savePlayerProjectStats(playerSubjectId, project.id, cycleId, stats) ) }) @@ -130,50 +148,3 @@ function _mapById(arr) { return result }, new Map()) } - -function _findQuestionByType(questions, questionType) { - // see see https://github.com/LearnersGuild/game/issues/370 - switch (questionType) { - case QUESTION_TYPES.RELATIVE_CONTRIBUTION: - return questions.find(q => { - return q.responseType === 'relativeContribution' - }) || {} - - case QUESTION_TYPES.LEARNING_SUPPORT: - return questions.find(q => { - return q.subjectType === 'player' && - q.responseType === 'likert7Agreement' && - q.body.includes('supported me in learning my craft') - }) || {} - - case QUESTION_TYPES.CULTURE_CONTRIBUTION: - return questions.find(q => { - return q.subjectType === 'player' && - q.responseType === 'likert7Agreement' && - q.body.includes('contributed positively to our team culture') - }) || {} - - case QUESTION_TYPES.PROJECT_HOURS: - return questions.find(q => { - return q.subjectType === 'project' && - q.responseType === 'text' && - q.body.includes('how many hours') - }) || {} - - default: - return {} - } -} - -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()) -} 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 index cc5eda8c..db21f74d 100644 --- a/server/util/stats.js +++ b/server/util/stats.js @@ -1,3 +1,5 @@ +import {sum} from './index' + export function aggregateBuildCycles(numPlayers, numBuildCycles = 1) { if (numPlayers === null || numBuildCycles === null || isNaN(numPlayers) || isNaN(numBuildCycles)) { return null @@ -9,8 +11,8 @@ export function relativeContribution(rcScores) { if (!Array.isArray(rcScores) || !rcScores.length) { return null } - const sum = rcScores.reduce((sum, n) => sum + n, 0) - return Math.round(sum / rcScores.length) + const rcScoresSum = sum(rcScores) + return Math.round(rcScoresSum / rcScores.length) } export function expectedContribution(playerHours, teamHours) { @@ -49,6 +51,6 @@ export function averageScore(scores) { return null } const adjustedScores = scores.filter(n => (n >= SCORE_MIN && n <= SCORE_MAX)) - const sum = adjustedScores.map(n => ((n - 1) / 6)).reduce((sum, n) => sum + n, 0) - return Math.round((sum / adjustedScores.length) * 100) + const adjustedScoresSum = sum(adjustedScores.map(n => ((n - 1) / 6))) + return Math.round((adjustedScoresSum / adjustedScores.length) * 100) } diff --git a/server/util/survey.js b/server/util/survey.js new file mode 100644 index 00000000..2bf46dc4 --- /dev/null +++ b/server/util/survey.js @@ -0,0 +1,59 @@ +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 filterQuestionsByType(questions, questionType) { + // eek. see https://github.com/LearnersGuild/game/issues/370 + switch (questionType) { + case STATS_QUESTION_TYPES.RELATIVE_CONTRIBUTION: + return questions.find(q => { + return q.responseType === 'relativeContribution' + }) || {} + + case STATS_QUESTION_TYPES.LEARNING_SUPPORT: + return questions.find(q => { + return q.subjectType === 'player' && + q.responseType === 'likert7Agreement' && + q.body.includes('supported me in learning my craft') + }) || {} + + case STATS_QUESTION_TYPES.CULTURE_CONTRIBUTION: + return questions.find(q => { + return q.subjectType === 'player' && + q.responseType === 'likert7Agreement' && + q.body.includes('contributed positively to our team culture') + }) || {} + + case STATS_QUESTION_TYPES.PROJECT_HOURS: + return questions.find(q => { + return q.subjectType === 'project' && + q.responseType === 'text' && + q.body.includes('how many hours') + }) || {} + + case STATS_QUESTION_TYPES.GENERAL_FEEDBACK: + return questions.find(q => { + return q.subjectType === 'player' && q.responseType === 'text' + }) || {} + + default: + return {} + } +} diff --git a/server/workers/cycleCompleted.js b/server/workers/cycleCompleted.js index 1517a596..cec4f64c 100644 --- a/server/workers/cycleCompleted.js +++ b/server/workers/cycleCompleted.js @@ -1,8 +1,17 @@ -import {getQueue} from '../util' import ChatClient from '../../server/clients/ChatClient' import r from '../../db/connect' 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') @@ -14,19 +23,95 @@ export function start() { export async function processCompletedCycle(cycle, chatClient = new ChatClient()) { console.log(`Completing cycle ${cycle.cycleNumber} of chapter ${cycle.chapterId}`) - await updateStats(cycle) + await updateStats(cycle, chatClient) await sendCompletionAnnouncement(cycle, chatClient) } -function updateStats(cycle) { +function updateStats(cycle, chatClient) { return getProjectsForChapterInCycle(cycle.chapterId, cycle.id) - .then(projects => - Promise.all( - projects.map(project => updateProjectStats(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 [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 = cyclePlayers.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(cyclePlayers.map(player => { + const feedbackData = { + project, + cycle, + team: cyclePlayers, + 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, playerStats} = feedbackData + + const teamFeedbackList = teamResponses.map(response => `- ${(response.body || '').trim()}`) + const teamHoursList = teamHours.map(item => `@${item.player.handle} (${item.player.name}): ${item.hours}`) + + const playerRetroFeedbackMessage = `Retrospective results for #${project.name} (cycle ${cycle.cycleNumber}): + +**Feedback from your team:** +${teamFeedbackList.join('\n')} + +**Stats earned from this project:** +\`\`\` +Learning Support: ${playerStats.ls || 0}% +Culture Contribution: $${playerStats.cc || 0}% +\`\`\` + +**Hours contributed:** +\`\`\` +Team size: ${team.length} +Your hours: ${playerStats.hours || 0} +All team hours: ${playerStats.teamHours || 0} + +${teamHoursList.join('\n')} +\`\`\` + +**Contribution to the project:** +Self-assessed: ${playerStats.rcSelf || 0}% +Team-assessed: ${playerStats.rcOther || 0}% + +Your estimated contribution to the project: ${playerStats.rc || 0}% +Expected contribution for # of hours: ${playerStats.ec || 0}% +Contribution difference: ${playerStats.ecd || 0}% +` + + chatClient.sendDirectMessage(player.handle, playerRetroFeedbackMessage) +} + function sendCompletionAnnouncement(cycle, chatClient) { return r.table('chapters').get(cycle.chapterId).run() .then(chapter => { From 045ff2fdad1634ba2ff99e86ee8540e76318b675 Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Fri, 5 Aug 2016 08:57:25 -0700 Subject: [PATCH 07/11] include new stats props in all player stats update tests --- server/db/__tests__/player.test.js | 40 ++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/server/db/__tests__/player.test.js b/server/db/__tests__/player.test.js index 2c9288f4..a0c9ae3b 100644 --- a/server/db/__tests__/player.test.js +++ b/server/db/__tests__/player.test.js @@ -100,29 +100,41 @@ describe(testContext(__filename), function () { }) 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], {ecc: 40, abc: 4, rc: 10}) + await savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], projectCycleStats) const player = await this.fetchPlayer() 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 cumulative stats.ecc', async function() { expect(this.player).to.have.deep.property('stats.ecc') + 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], {ecc: 20, abc: 4, rc: 5}) + await savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], projectCycleStats) 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 stats.projects attribute if neccessary', async function () { expect(this.player).to.not.have.deep.property('stats.projects') - const projectCycleStats = {ecc: 20, abc: 4, rc: 5, ec: 10, ecd: 20, ls: 80, cc: 85, hours: 30} + 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) const player = await this.fetchPlayer() @@ -139,8 +151,8 @@ describe(testContext(__filename), function () { expect(this.player).to.not.have.deep.property('stats.projects') const projectCycleStats = [ - {ecc: 20, abc: 4, rc: 5}, - {ecc: 18, abc: 3, rc: 6}, + {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 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]) @@ -162,8 +174,8 @@ describe(testContext(__filename), function () { expect(this.player).to.not.have.deep.property('stats.projects') const projectCycleStats = [ - {ecc: 20, abc: 4, rc: 5}, - {ecc: 18, abc: 3, rc: 6}, + {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 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]) @@ -183,20 +195,22 @@ describe(testContext(__filename), function () { 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 savePlayerProjectStats(this.player.id, this.projectIds[0], this.cycleIds[0], {ecc: 10, abc: 2, rc: 5}) + 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 savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], {ecc: 20, abc: 4, rc: 5}) + 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(`stats.projects.${this.projectIds[1]}.cycles.${this.cycleIds[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 savePlayerProjectStats(this.player.id, this.projectIds[1], this.cycleIds[1], stats) + 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(`stats.projects.${this.projectIds[1]}.cycles.${this.cycleIds[1]}`).deep.eq(stats) + .property(`stats.projects.${this.projectIds[1]}.cycles.${this.cycleIds[1]}`).deep.eq(projectCycleStats3) }) }) }) From 5d9fadabc42ca13be61e86f0d442dee001a03696 Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Fri, 5 Aug 2016 09:03:38 -0700 Subject: [PATCH 08/11] use min/max score constants to adjust/shift survey scores --- server/util/stats.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/util/stats.js b/server/util/stats.js index db21f74d..d76f6b40 100644 --- a/server/util/stats.js +++ b/server/util/stats.js @@ -46,11 +46,13 @@ export function cultureContrbution(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) || !scores.length) { return null } - const adjustedScores = scores.filter(n => (n >= SCORE_MIN && n <= SCORE_MAX)) - const adjustedScoresSum = sum(adjustedScores.map(n => ((n - 1) / 6))) - return Math.round((adjustedScoresSum / adjustedScores.length) * 100) + 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 } From ddd3a99d0aab061439fef653e04995e0e220542e Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Fri, 5 Aug 2016 10:54:02 -0700 Subject: [PATCH 09/11] fixes for stats DMs --- common/models/survey.js | 12 ++-- common/util/survey.js | 2 +- .../__tests__/updateProjectStats.test.js | 2 +- server/actions/getPlayerInfo.js | 8 +++ server/actions/updateProjectStats.js | 12 ++-- server/util/survey.js | 69 ++++++++++++------- server/workers/cycleCompleted.js | 69 +++++++++++-------- server/workers/cycleLaunched.js | 10 +-- 8 files changed, 111 insertions(+), 73 deletions(-) create mode 100644 server/actions/getPlayerInfo.js 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/server/actions/__tests__/updateProjectStats.test.js b/server/actions/__tests__/updateProjectStats.test.js index 534f4eb7..9aa389f6 100644 --- a/server/actions/__tests__/updateProjectStats.test.js +++ b/server/actions/__tests__/updateProjectStats.test.js @@ -6,7 +6,7 @@ import factory from '../../../test/factories' import {withDBCleanup, useFixture} from '../../../test/helpers' import {getPlayerById} from '../../../server/db/player' -import {updateProjectStats} from '../updateProjectStats' +import updateProjectStats from '../updateProjectStats' describe(testContext(__filename), function () { describe('updateProjectStats', function () { 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 index 55eeb495..620d20b3 100644 --- a/server/actions/updateProjectStats.js +++ b/server/actions/updateProjectStats.js @@ -16,10 +16,10 @@ import { import { STATS_QUESTION_TYPES, groupResponsesBySubject, - filterQuestionsByType, + findQuestionByType, } from '../../server/util/survey' -export async function updateProjectStats(project, cycleId) { +export default async function updateProjectStats(project, cycleId) { const projectCycle = getProjectHistoryForCycle(project, cycleId) const teamSize = projectCycle.playerIds.length const retroSurveyId = projectCycle.retrospectiveSurveyId @@ -34,10 +34,10 @@ export async function updateProjectStats(project, cycleId) { // hacky, brittle way of mapping stat types to questions // FIXME (ASAP): see https://github.com/LearnersGuild/game/issues/370 - const questionLS = filterQuestionsByType(retroQuestions, STATS_QUESTION_TYPES.LEARNING_SUPPORT) - const questionCC = filterQuestionsByType(retroQuestions, STATS_QUESTION_TYPES.CULTURE_CONTRIBUTION) - const questionRC = filterQuestionsByType(retroQuestions, STATS_QUESTION_TYPES.RELATIVE_CONTRIBUTION) - const questionHours = filterQuestionsByType(retroQuestions, STATS_QUESTION_TYPES.PROJECT_HOURS) + 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 = [] diff --git a/server/util/survey.js b/server/util/survey.js index 2bf46dc4..bbbe0152 100644 --- a/server/util/survey.js +++ b/server/util/survey.js @@ -19,41 +19,64 @@ export function groupResponsesBySubject(surveyResponses) { }, new Map()) } +export function findQuestionByType(questions, questionType) { + if (!Array.isArray(questions)) { + return null + } + + return filterQuestionsByType(questions, questionType)[0] +} + export function filterQuestionsByType(questions, questionType) { - // eek. see https://github.com/LearnersGuild/game/issues/370 + if (!Array.isArray(questions)) { + return [] + } + switch (questionType) { case STATS_QUESTION_TYPES.RELATIVE_CONTRIBUTION: - return questions.find(q => { - return q.responseType === 'relativeContribution' - }) || {} + return questions.filter(q => _isStatsQuestionRC(q)) case STATS_QUESTION_TYPES.LEARNING_SUPPORT: - return questions.find(q => { - return q.subjectType === 'player' && - q.responseType === 'likert7Agreement' && - q.body.includes('supported me in learning my craft') - }) || {} + return questions.filter(q => _isStatsQuestionLS(q)) case STATS_QUESTION_TYPES.CULTURE_CONTRIBUTION: - return questions.find(q => { - return q.subjectType === 'player' && - q.responseType === 'likert7Agreement' && - q.body.includes('contributed positively to our team culture') - }) || {} + return questions.filter(q => _isStatsQuestionCC(q)) case STATS_QUESTION_TYPES.PROJECT_HOURS: - return questions.find(q => { - return q.subjectType === 'project' && - q.responseType === 'text' && - q.body.includes('how many hours') - }) || {} + return questions.filter(q => _isStatsQuestionHours(q)) case STATS_QUESTION_TYPES.GENERAL_FEEDBACK: - return questions.find(q => { - return q.subjectType === 'player' && q.responseType === 'text' - }) || {} + return questions.filter(q => _isStatsQuestionGeneral(q)) default: - return {} + 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 cec4f64c..90a25d1c 100644 --- a/server/workers/cycleCompleted.js +++ b/server/workers/cycleCompleted.js @@ -1,6 +1,7 @@ import ChatClient from '../../server/clients/ChatClient' import r from '../../db/connect' -import {updateProjectStats} from '../../server/actions/updateProjectStats' +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' @@ -17,7 +18,7 @@ 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)) ) } @@ -36,6 +37,8 @@ function updateStats(cycle, chatClient) { 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), @@ -51,18 +54,18 @@ function updateStats(cycle, chatClient) { })) const playerHours = [] - const statsByPlayer = cyclePlayers.reduce((result, player) => { - const projectCycleStats = ((((player.stats || {}).projects || {})[project.id] || {}).cycles || {})[cycle.id] + 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(cyclePlayers.map(player => { + return Promise.all(players.map(player => { const feedbackData = { project, cycle, - team: cyclePlayers, + team: players, teamResponses: generalFeedbackResponsesBySubject.get(player.id) || [], teamHours: playerHours, stats: statsByPlayer.get(player.id) || {}, @@ -75,41 +78,36 @@ function updateStats(cycle, chatClient) { } function sendPlayerProjectStatsDM(player, feedbackData, chatClient) { - const {project, cycle, team, teamResponses, teamHours, playerStats} = feedbackData + const {project, cycle, team, teamResponses, teamHours, stats} = feedbackData - const teamFeedbackList = teamResponses.map(response => `- ${(response.body || '').trim()}`) + 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 for #${project.name} (cycle ${cycle.cycleNumber}): + const playerRetroFeedbackMessage = `**RETROSPECTIVE RESULTS:** #${project.name} (cycle ${cycle.cycleNumber}) **Feedback from your team:** -${teamFeedbackList.join('\n')} - -**Stats earned from this project:** -\`\`\` -Learning Support: ${playerStats.ls || 0}% -Culture Contribution: $${playerStats.cc || 0}% -\`\`\` +${teamFeedbackList.join(' \n')} **Hours contributed:** -\`\`\` Team size: ${team.length} -Your hours: ${playerStats.hours || 0} -All team hours: ${playerStats.teamHours || 0} +Your hours: ${stats.hours || 0} +All team hours: ${stats.teamHours || 0} -${teamHoursList.join('\n')} -\`\`\` +${teamHoursList.join(' \n')} **Contribution to the project:** -Self-assessed: ${playerStats.rcSelf || 0}% -Team-assessed: ${playerStats.rcOther || 0}% +Self-assessed: ${stats.rcSelf || 0}% +Team-assessed: ${stats.rcOther || 0}% -Your estimated contribution to the project: ${playerStats.rc || 0}% -Expected contribution for # of hours: ${playerStats.ec || 0}% -Contribution difference: ${playerStats.ecd || 0}% -` +Your estimated contribution to the project: ${stats.rc || 0}% +Expected contribution for # of hours: ${stats.ec || 0}% +Contribution difference: ${stats.ecd || 0}% - chatClient.sendDirectMessage(player.handle, playerRetroFeedbackMessage) +**Stats earned from this project:** +Learning Support: ${stats.ls || 0}% +Culture Contribution: ${stats.cc || 0}%` + + return chatClient.sendDirectMessage(player.handle, playerRetroFeedbackMessage) } function sendCompletionAnnouncement(cycle, chatClient) { @@ -119,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!* From e668d7e13086cced1a824bb3b124f1a462f6a6c3 Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Fri, 5 Aug 2016 11:04:01 -0700 Subject: [PATCH 10/11] update stats defaults --- server/actions/updateProjectStats.js | 32 ++++++++++++++--------- server/util/__tests__/stats.test.js | 39 +++++++++++++++++++++++++--- server/util/stats.js | 15 ++++++++--- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/server/actions/updateProjectStats.js b/server/actions/updateProjectStats.js index 620d20b3..2c3b3d89 100644 --- a/server/actions/updateProjectStats.js +++ b/server/actions/updateProjectStats.js @@ -92,24 +92,32 @@ export default async function updateProjectStats(project, cycleId) { value: responseValue, } = response + let value switch (responseQuestionId) { case questionLS.id: - lsScores.push(parseInt(responseValue, 10) || 0) + value = parseInt(responseValue, 10) + if (!isNaN(value)) { + lsScores.push(value) + } break case questionCC.id: - ccScores.push(parseInt(responseValue, 10) || 0) + value = parseInt(responseValue, 10) + if (!isNaN(value)) { + ccScores.push(value) + } break - case questionRC.id: { - const value = parseInt(responseValue, 10) || 0 - rcScores.push(value) - if (response.respondentId === playerSubjectId) { - rcScoresSelf.push(value) - } else { - rcScoresOther.push(value) + 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: @@ -130,8 +138,8 @@ export default async function updateProjectStats(project, cycleId) { const stats = { ec, ecd, abc, ecc, ls, cc, hours, teamHours, rc, - rcSelf: Math.round(sum(rcScoresSelf) / rcScoresSelf.length) || 0, - rcOther: Math.round(sum(rcScoresOther) / rcScoresOther.length) || 0, + rcSelf: rcScoresSelf.length ? Math.round(sum(rcScoresSelf) / rcScoresSelf.length) : 0, + rcOther: rcScoresOther.length ? Math.round(sum(rcScoresOther) / rcScoresOther.length) : 0, } playerStatsUpdates.push( diff --git a/server/util/__tests__/stats.test.js b/server/util/__tests__/stats.test.js index 2d6a2455..2624cee1 100644 --- a/server/util/__tests__/stats.test.js +++ b/server/util/__tests__/stats.test.js @@ -28,6 +28,11 @@ describe(testContext(__filename), function () { }) 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) @@ -45,13 +50,29 @@ describe(testContext(__filename), function () { }) describe('expectedContribution()', function () { - const playerHours = 20 - const teamHours = 100 - const ec = expectedContribution(playerHours, teamHours) - expect(ec).to.eq(20) + 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 @@ -82,6 +103,11 @@ describe(testContext(__filename), function () { }) 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) @@ -94,6 +120,11 @@ describe(testContext(__filename), function () { }) 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) diff --git a/server/util/stats.js b/server/util/stats.js index d76f6b40..68bad0dc 100644 --- a/server/util/stats.js +++ b/server/util/stats.js @@ -8,17 +8,23 @@ export function aggregateBuildCycles(numPlayers, numBuildCycles = 1) { } export function relativeContribution(rcScores) { - if (!Array.isArray(rcScores) || !rcScores.length) { + 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) || !teamHours) { + if (playerHours === null || isNaN(playerHours) || isNaN(teamHours)) { return null } + if (teamHours === 0) { + return 0 + } return Math.round((playerHours / teamHours) * 100) } @@ -48,9 +54,12 @@ 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) || !scores.length) { + 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) From 3743f3ddf5318eedbf8856706af581686741ee1d Mon Sep 17 00:00:00 2001 From: SJ Pratt Date: Fri, 5 Aug 2016 11:19:02 -0700 Subject: [PATCH 11/11] minor stats DM wording tweak --- server/workers/cycleCompleted.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/workers/cycleCompleted.js b/server/workers/cycleCompleted.js index 90a25d1c..e691c055 100644 --- a/server/workers/cycleCompleted.js +++ b/server/workers/cycleCompleted.js @@ -103,7 +103,7 @@ Your estimated contribution to the project: ${stats.rc || 0}% Expected contribution for # of hours: ${stats.ec || 0}% Contribution difference: ${stats.ecd || 0}% -**Stats earned from this project:** +**Stats earned for this project:** Learning Support: ${stats.ls || 0}% Culture Contribution: ${stats.cc || 0}%`