Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions db/migrations/20160804151238-responsesIndexSurveyId.js
Original file line number Diff line number Diff line change
@@ -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)
}
109 changes: 75 additions & 34 deletions server/actions/__tests__/updateProjectStats.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
},
},
})
})
})
})
192 changes: 158 additions & 34 deletions server/actions/updateProjectStats.js
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah -- terrible, but at least it's isolated in one place.

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
}
Loading