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) +}