From 69d7ffe3d12f02ede777bf3cbe06a81cd4647bfb Mon Sep 17 00:00:00 2001 From: immortaleeb Date: Tue, 7 Sep 2021 21:44:42 +0200 Subject: [PATCH 1/6] Pass the item to be worked on to canWorkOn() --- spec/worker.spec.js | 20 ++++++++++++-------- src/board.js | 28 +++++++++++++++++++++------- src/worker.js | 2 +- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/spec/worker.spec.js b/spec/worker.spec.js index a576f87..70c541e 100644 --- a/spec/worker.spec.js +++ b/spec/worker.spec.js @@ -1,14 +1,16 @@ -const {Worker} = require('../src/worker'); +const {Worker, WorkItem} = require('../src/worker'); describe('a specialist', () => { + const item = new WorkItem({'dev': 1}); + it('can work on its skill', () => { const developer = new Worker({'dev': 1}); - expect(developer.canWorkOn('dev')).toBeTruthy(); - expect(developer.canWorkOn('dev')).toBe(1); + expect(developer.canWorkOn({ skill: 'dev', item })).toBeTruthy(); + expect(developer.canWorkOn({ skill: 'dev', item })).toBe(1); }); it('can not work on another skill', () => { const developer = new Worker({'dev': 1}); - expect(developer.canWorkOn('qa')).toBeFalsy(); + expect(developer.canWorkOn({ skill: 'qa', item })).toBeFalsy(); }) it('has a name', () => { const developer = new Worker({'dev': 1}); @@ -17,17 +19,19 @@ describe('a specialist', () => { }); describe('a generalist', () => { + const item = new WorkItem({'ux': 1, 'dev': 1, 'qa': 1}); + it('can work on any skill', () => { const developer = new Worker({'all': 1}); - expect(developer.canWorkOn('ux')).toBe(1); + expect(developer.canWorkOn({ skill: 'ux', item })).toBe(1); }); it('can work on any skill', () => { const developer = new Worker({'rest': 1}); - expect(developer.canWorkOn('ux')).toBe(1); + expect(developer.canWorkOn({ skill: 'ux', item })).toBe(1); }); it('can can have a specialisation', () => { const developer = new Worker({'qa': 1, 'rest': 0.5}); - expect(developer.canWorkOn('qa')).toBe(1); - expect(developer.canWorkOn('ux')).toBe(0.5); + expect(developer.canWorkOn({ skill: 'qa', item })).toBe(1); + expect(developer.canWorkOn({ skill: 'ux', item })).toBe(0.5); }); }); diff --git a/src/board.js b/src/board.js index a489aaa..cb4cc33 100644 --- a/src/board.js +++ b/src/board.js @@ -48,21 +48,23 @@ let Board = function (workColumnNames) { }); function assignNewWorkIfPossible() { - const columnWithWork = workColumns() - .reverse() - .filter(column => column.inbox.hasWork()) - .filter(column => workers.some(worker => worker.canWorkOn(column.necessarySkill)))[0]; + const nextWork = findNextWork(); + + const columnWithWork = nextWork && nextWork.column; + const itemToWorkOn = nextWork && nextWork.item; if (columnWithWork) { if (columnWithWork.inbox === backlogColumn() && !allowNewWork) return; + const workCriteria = { skill: columnWithWork.necessarySkill, item: itemToWorkOn }; + const availableWorker = workers - .filter(worker => worker.canWorkOn(columnWithWork.necessarySkill)) + .filter(worker => worker.canWorkOn(workCriteria)) .reduce((bestCandidate, worker) => { if (!bestCandidate) return worker; - const bestScore = bestCandidate.canWorkOn(columnWithWork.necessarySkill); - const currentScore = worker.canWorkOn(columnWithWork.necessarySkill); + const bestScore = bestCandidate.canWorkOn(workCriteria); + const currentScore = worker.canWorkOn(workCriteria); return bestScore > currentScore ? bestCandidate : worker; }); @@ -72,6 +74,18 @@ let Board = function (workColumnNames) { } } + function findNextWork() { + return workColumns() + .reverse() + .filter(column => column.inbox.hasWork()) + .map(column => ({ column, item: findFirstWorkableItemIn(column) })) + .filter(result => !!result.item)[0]; + } + + function findFirstWorkableItemIn(column) { + return column.inbox.items().filter(item => workers.some(worker => worker.canWorkOn({ skill: column.necessarySkill, item })))[0] + } + PubSub.subscribe('board.denyNewWork', () => allowNewWork = false); PubSub.subscribe('workitem.added', (topic, {item, column}) => { diff --git a/src/worker.js b/src/worker.js index 0774206..6018b22 100644 --- a/src/worker.js +++ b/src/worker.js @@ -7,7 +7,7 @@ function Worker(skills = {dev: 1}) { let idle = true; const id = workerCounter++; - function canWorkOn(skill) { + function canWorkOn({ skill, item }) { if (!idle) return 0; return workSpeedFor(skill); } From f7cd499d3a850f3f8106375cbde083bdbe322efe Mon Sep 17 00:00:00 2001 From: immortaleeb Date: Tue, 7 Sep 2021 21:48:15 +0200 Subject: [PATCH 2/6] Pass item to work on to startWorkingOn() --- src/board.js | 7 ++++++- src/worker.js | 3 +-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/board.js b/src/board.js index cb4cc33..d496b24 100644 --- a/src/board.js +++ b/src/board.js @@ -69,7 +69,12 @@ let Board = function (workColumnNames) { }); if (availableWorker) { - availableWorker.startWorkingOn(columnWithWork.inbox, columnWithWork, columnWithWork.outbox); + availableWorker.startWorkingOn({ + inbox: columnWithWork.inbox, + inProgress: columnWithWork, + outbox: columnWithWork.outbox, + item: itemToWorkOn + }); } } } diff --git a/src/worker.js b/src/worker.js index 6018b22..412fbb5 100644 --- a/src/worker.js +++ b/src/worker.js @@ -35,8 +35,7 @@ function Worker(skills = {dev: 1}) { return 1000 * TimeAdjustments.multiplicator() * workItem.work[skill] / workSpeedFor(skill); } - function startWorkingOn(inbox, inProgress, outbox) { - let item = inbox.peek(); + function startWorkingOn({ inbox, inProgress, outbox, item }) { if (item) { idle = false; PubSub.publish('worker.working', worker); From 522e828369f0a2b43d9bb2497fb9b7aecd7669ab Mon Sep 17 00:00:00 2001 From: immortaleeb Date: Tue, 7 Sep 2021 22:43:58 +0200 Subject: [PATCH 3/6] Extract SimpleSkillStrategy --- src/worker.js | 46 ++++++++++++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/worker.js b/src/worker.js index 412fbb5..0251e56 100644 --- a/src/worker.js +++ b/src/worker.js @@ -3,36 +3,45 @@ const TimeAdjustments = require('./timeAdjustments'); let workerCounter = 1; +function SimpleSkillStrategy(skills) { + function workSpeedFor({ skill }) { + return skills[skill] || skills['all'] || skills['rest'] || skills['fs'] || skills['fullstack']; + } + + function renderName() { + function renderSkills() { + return Object.keys(skills).map(skill => `${skill}`) + } + + return `${renderSkills()}`; + } + + return { + workSpeedFor, + renderName + }; +} + function Worker(skills = {dev: 1}) { let idle = true; const id = workerCounter++; + const strategy = new SimpleSkillStrategy(skills); - function canWorkOn({ skill, item }) { + function canWorkOn(workCriteria) { if (!idle) return 0; - return workSpeedFor(skill); + return strategy.workSpeedFor(workCriteria); } let worker = { canWorkOn, startWorkingOn, - name: renderName, + name: strategy.renderName, id }; - function renderName() { - function renderSkills() { - return Object.keys(skills).map(skill => `${skill}`) - } - - return `${renderSkills()}`; - } - - function workSpeedFor(skill) { - return skills[skill] || skills['all'] || skills['rest'] || skills['fs'] || skills['fullstack']; - } - - function calculateTimeoutFor(workItem, skill) { - return 1000 * TimeAdjustments.multiplicator() * workItem.work[skill] / workSpeedFor(skill); + function calculateTimeoutFor(workCriteria) { + const { item, skill } = workCriteria; + return 1000 * TimeAdjustments.multiplicator() * item.work[skill] / strategy.workSpeedFor(workCriteria); } function startWorkingOn({ inbox, inProgress, outbox, item }) { @@ -41,7 +50,8 @@ function Worker(skills = {dev: 1}) { PubSub.publish('worker.working', worker); let skill = inProgress.necessarySkill; inbox.move(inProgress, item); - let timeout = calculateTimeoutFor(item, skill); + const workCriteria = { item, skill }; + let timeout = calculateTimeoutFor(workCriteria); setTimeout(() => { idle = true; inProgress.move(outbox, item); From 760f3f20d821b1b3061df183000c7c4f36a2cd7f Mon Sep 17 00:00:00 2001 From: immortaleeb Date: Thu, 9 Sep 2021 12:50:50 +0200 Subject: [PATCH 4/6] Change Worker constructor to take a strategy --- spec/animation.spec.js | 10 +++++----- spec/board.spec.js | 18 +++++++++--------- spec/limit-wip.spec.js | 4 ++-- spec/worker-stats.spec.js | 16 ++++++++-------- spec/worker.spec.js | 14 +++++++------- src/scenario.js | 4 ++-- src/worker.js | 5 ++--- 7 files changed, 35 insertions(+), 36 deletions(-) diff --git a/spec/animation.spec.js b/spec/animation.spec.js index 4c1c7ad..5d9db15 100644 --- a/spec/animation.spec.js +++ b/spec/animation.spec.js @@ -1,7 +1,7 @@ const PubSub = require('pubsub-js'); const animation = require('../src/animation'); -const {Worker, WorkItem} = require('../src/worker'); +const {Worker, WorkItem, SimpleSkillStrategy} = require('../src/worker'); const Board = require('../src/board'); const find = selector => document.querySelector(selector); @@ -63,7 +63,7 @@ describe('animation', () => { }); it('a worker picks up an item', () => { - board.addWorkers(new Worker({dev: 1})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 1}))); let workItem = new WorkItem({dev: 1}); board.addWorkItems(workItem); jest.advanceTimersByTime(0); @@ -78,7 +78,7 @@ describe('animation', () => { }) it('shows card amount', () => { - board.addWorkers(new Worker({dev: 1})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 1}))); board.addWorkItems(new WorkItem({dev: 1})); jest.advanceTimersByTime(0); @@ -131,7 +131,7 @@ describe('animation', () => { }); it('adds new workers', () => { - const worker = new Worker({dev: 0}); + const worker = new Worker(new SimpleSkillStrategy({dev: 0})); jest.runAllTimers(); let $worker = find(`.workers [data-worker-id="${worker.id}"]`); expect($worker.querySelector('.name').innerHTML).toEqual(`${worker.name()}: `); @@ -139,7 +139,7 @@ describe('animation', () => { }); it('stat update', () => { - const worker = new Worker({dev: 0}); + const worker = new Worker(new SimpleSkillStrategy({dev: 0})); const newStats = {workerId: worker.id, stats: {efficiency: 0.9500111}}; PubSub.publish('worker.stats.updated', newStats); jest.runAllTimers(); diff --git a/spec/board.spec.js b/spec/board.spec.js index d6e9ac0..f65e9dd 100644 --- a/spec/board.spec.js +++ b/spec/board.spec.js @@ -1,4 +1,4 @@ -const {Worker, WorkItem, WorkList} = require('../src/worker'); +const {Worker, WorkItem, WorkList, SimpleSkillStrategy} = require('../src/worker'); const Board = require('../src/board'); const PubSub = require('pubsub-js'); @@ -15,7 +15,7 @@ describe('a worker', () => { describe('without tasks', () => { it('doesnt crash', () => { - board.addWorkers(new Worker({dev: 1})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 1}))); jest.runAllTimers(); expect(board.items()).toEqual([[], [], []]); }); @@ -23,7 +23,7 @@ describe('a worker', () => { describe('with one task', () => { beforeEach(() => { - board.addWorkers(new Worker({dev: 1})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 1}))); workItem = new WorkItem({dev: 1}); board.addWorkItems(workItem); }); @@ -66,7 +66,7 @@ describe('a worker', () => { describe('works on an item added after simulation started', () => { it('works', () => { - board.addWorkers(new Worker({dev: 1})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 1}))); let workItem = new WorkItem({dev: 1}); workItem.foo = 'bar'; board.addWorkItems(workItem); @@ -78,7 +78,7 @@ describe('a worker', () => { describe('with two tasks', () => { beforeEach(() => { - board.addWorkers(new Worker({dev: 1})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 1}))); workItem1 = new WorkItem({dev: 1}); workItem2 = new WorkItem({dev: 1}); board.addWorkItems(workItem1, workItem2); @@ -135,7 +135,7 @@ describe('workers work at their own speed', () => { beforeEach(() => { workItem = new WorkItem({dev: 1}); board.addWorkItems(workItem); - board.addWorkers(new Worker({dev: 1})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 1}))); }); it('starts instantly', () => { jest.advanceTimersByTime(0); @@ -155,7 +155,7 @@ describe('workers work at their own speed', () => { beforeEach(() => { workItem = new WorkItem({dev: 1}); board.addWorkItems(workItem); - board.addWorkers(new Worker({dev: 0.5})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 0.5}))); }); it('starts instantly', () => { jest.advanceTimersByTime(0); @@ -183,8 +183,8 @@ describe('a typical workflow', () => { beforeEach(() => { board = new Board(['dev', 'qa']) board.addWorkers( - new Worker({dev: 1}), - new Worker({qa: 1}), + new Worker(new SimpleSkillStrategy({dev: 1})), + new Worker(new SimpleSkillStrategy({qa: 1})), ); workItem1 = new WorkItem({dev: 1, qa: 2}); workItem2 = new WorkItem({dev: 2, qa: 1}); diff --git a/spec/limit-wip.spec.js b/spec/limit-wip.spec.js index 2eb9c1c..27fd15b 100644 --- a/spec/limit-wip.spec.js +++ b/spec/limit-wip.spec.js @@ -1,6 +1,6 @@ const PubSub = require('pubsub-js'); const Board = require('../src/board'); -const {WorkList, Worker, WorkItem} = require('../src/worker'); +const {WorkList, Worker, WorkItem, SimpleSkillStrategy} = require('../src/worker'); const {LimitBoardWip} = require('../src/strategies'); describe('limiting overall wip', () => { @@ -17,7 +17,7 @@ describe('limiting overall wip', () => { beforeEach(() => { board = new Board(['dev', 'qa']); new LimitBoardWip().initialize(1); - board.addWorkers(new Worker({dev: 1}), new Worker({qa: 1})); + board.addWorkers(new Worker(new SimpleSkillStrategy({dev: 1})), new Worker(new SimpleSkillStrategy({qa: 1}))); item1 = new WorkItem({dev: 1, qa: 1}); item2 = new WorkItem({dev: 1, qa: 1}); diff --git a/spec/worker-stats.spec.js b/spec/worker-stats.spec.js index 44b19b9..839767e 100644 --- a/spec/worker-stats.spec.js +++ b/spec/worker-stats.spec.js @@ -1,5 +1,5 @@ const PubSub = require('pubsub-js'); -const {Worker} = require('../src/worker'); +const {Worker, SimpleSkillStrategy} = require('../src/worker'); const WorkerStats = require('../src/worker-stats'); describe('worker stats', () => { @@ -15,19 +15,19 @@ describe('worker stats', () => { }); it('when worker is idle', () => { - const worker = new Worker({dev: 1}); + const worker = new Worker(new SimpleSkillStrategy({dev: 1})); publishAt(0, 'worker.idle', worker); expect(latestStats[worker.id]).toMatchObject({efficiency: 0}); }); it('when worker is working', () => { - const worker = new Worker({dev: 1}); + const worker = new Worker(new SimpleSkillStrategy({dev: 1})); publishAt(0, 'worker.working', worker); expect(latestStats[worker.id]).toMatchObject({efficiency: 1}); }); it('when worker is working 50%', () => { - const worker = new Worker({dev: 1}); + const worker = new Worker(new SimpleSkillStrategy({dev: 1})); publishAt(0, 'worker.working', worker); publishAt(1, 'worker.idle', worker); publishAt(2, 'worker.working', worker); @@ -36,7 +36,7 @@ describe('worker stats', () => { }); it('when worker is working 10%', () => { - const worker = new Worker({dev: 1}); + const worker = new Worker(new SimpleSkillStrategy({dev: 1})); publishAt(0, 'worker.working', worker); publishAt(1, 'worker.idle', worker); publishAt(10, 'worker.working', worker); @@ -45,7 +45,7 @@ describe('worker stats', () => { }); it('when worker is working 90%', () => { - const worker = new Worker({dev: 1}); + const worker = new Worker(new SimpleSkillStrategy({dev: 1})); publishAt(0, 'worker.working', worker); publishAt(9, 'worker.idle', worker); publishAt(10, 'worker.working', worker); @@ -54,8 +54,8 @@ describe('worker stats', () => { }); it('with 2 workers', () => { - const worker1 = new Worker({dev: 1}); - const worker2 = new Worker({dev: 1}); + const worker1 = new Worker(new SimpleSkillStrategy({dev: 1})); + const worker2 = new Worker(new SimpleSkillStrategy({dev: 1})); publishAt(0, 'worker.working', worker1); expect(latestStats[worker1.id]).toMatchObject({efficiency: 1}); diff --git a/spec/worker.spec.js b/spec/worker.spec.js index 70c541e..43a0bf9 100644 --- a/spec/worker.spec.js +++ b/spec/worker.spec.js @@ -1,19 +1,19 @@ -const {Worker, WorkItem} = require('../src/worker'); +const {Worker, WorkItem, SimpleSkillStrategy} = require('../src/worker'); describe('a specialist', () => { const item = new WorkItem({'dev': 1}); it('can work on its skill', () => { - const developer = new Worker({'dev': 1}); + const developer = new Worker(new SimpleSkillStrategy({'dev': 1})); expect(developer.canWorkOn({ skill: 'dev', item })).toBeTruthy(); expect(developer.canWorkOn({ skill: 'dev', item })).toBe(1); }); it('can not work on another skill', () => { - const developer = new Worker({'dev': 1}); + const developer = new Worker(new SimpleSkillStrategy({'dev': 1})); expect(developer.canWorkOn({ skill: 'qa', item })).toBeFalsy(); }) it('has a name', () => { - const developer = new Worker({'dev': 1}); + const developer = new Worker(new SimpleSkillStrategy({'dev': 1})); expect(developer.name()).toMatch('dev') }) }); @@ -22,15 +22,15 @@ describe('a generalist', () => { const item = new WorkItem({'ux': 1, 'dev': 1, 'qa': 1}); it('can work on any skill', () => { - const developer = new Worker({'all': 1}); + const developer = new Worker(new SimpleSkillStrategy({'all': 1})); expect(developer.canWorkOn({ skill: 'ux', item })).toBe(1); }); it('can work on any skill', () => { - const developer = new Worker({'rest': 1}); + const developer = new Worker(new SimpleSkillStrategy({'rest': 1})); expect(developer.canWorkOn({ skill: 'ux', item })).toBe(1); }); it('can can have a specialisation', () => { - const developer = new Worker({'qa': 1, 'rest': 0.5}); + const developer = new Worker(new SimpleSkillStrategy({'qa': 1, 'rest': 0.5})); expect(developer.canWorkOn({ skill: 'qa', item })).toBe(1); expect(developer.canWorkOn({ skill: 'ux', item })).toBe(0.5); }); diff --git a/src/scenario.js b/src/scenario.js index 3416b4e..1723ffb 100644 --- a/src/scenario.js +++ b/src/scenario.js @@ -1,6 +1,6 @@ const Board = require("./board"); const {generateWorkItems} = require("./generator"); -const {Worker} = require('./worker') +const {Worker, SimpleSkillStrategy} = require('./worker') let counter = 1; @@ -11,7 +11,7 @@ const Scenario = scenario => { const createWorker = ({ skills: skillNames }, speed = 1) => { let skills = {}; skillNames.forEach(skillName => skills[skillName] = speed); - return new Worker(skills); + return new Worker(new SimpleSkillStrategy(skills)); }; const columnNames = () => Object.keys(scenario.stories.work); diff --git a/src/worker.js b/src/worker.js index 0251e56..921655f 100644 --- a/src/worker.js +++ b/src/worker.js @@ -22,10 +22,9 @@ function SimpleSkillStrategy(skills) { }; } -function Worker(skills = {dev: 1}) { +function Worker(strategy) { let idle = true; const id = workerCounter++; - const strategy = new SimpleSkillStrategy(skills); function canWorkOn(workCriteria) { if (!idle) return 0; @@ -116,4 +115,4 @@ function WorkList(skill = "dev") { return column; } -module.exports = {Worker, WorkItem, WorkList}; +module.exports = {Worker, WorkItem, WorkList, SimpleSkillStrategy}; From 7239cd753e2200f257ea6df32a907b3b2ecde406 Mon Sep 17 00:00:00 2001 From: immortaleeb Date: Thu, 9 Sep 2021 13:47:07 +0200 Subject: [PATCH 5/6] Implement DontWorkOnSameItemStrategy This strategy makes it so a worker cannot work on the same item multiple times in different skills. It is implemented as a decorator so that it can be combined with other strategies such as the SimpleSkillBasedStrategy. --- spec/worker.spec.js | 102 +++++++++++++++++++++++++++++++++++++++++++- src/worker.js | 29 ++++++++++++- 2 files changed, 129 insertions(+), 2 deletions(-) diff --git a/spec/worker.spec.js b/spec/worker.spec.js index 43a0bf9..bb90b08 100644 --- a/spec/worker.spec.js +++ b/spec/worker.spec.js @@ -1,4 +1,34 @@ -const {Worker, WorkItem, SimpleSkillStrategy} = require('../src/worker'); +const {Worker, WorkItem, WorkList, SimpleSkillStrategy, DontWorkOnSameItemStrategy} = require('../src/worker'); + +const createWorkItem = () => new WorkItem({'ux': 1, 'dev': 1, 'qa': 1}); +const createDeveloper = () => new Worker(new DontWorkOnSameItemStrategy(new SimpleSkillStrategy({'all': 1}))); +const createColumns = (itemsPerColumn = {}) => { + const columns = { + backlog: new WorkList('Backlog'), + ux: new WorkList('ux'), + devQueue: new WorkList('-'), + dev: new WorkList('dev'), + }; + columns.ordered = () => [ columns.backlog, columns.ux, columns.devQueue, columns.dev ]; + + Object.keys(itemsPerColumn) + .forEach(columnName => itemsPerColumn[columnName] + .forEach(item => columns[columnName].add(item))); + + return columns; +}; +const startWorkingOn = (item, columnName, developer, columns) => { + const column = columns[columnName]; + const columnList = columns.ordered(); + const columnIndex = columnList.indexOf(column); + + developer.startWorkingOn({ + inbox: columnList[columnIndex - 1], + inProgress: column, + outbox: columnList[columnIndex + 1], + item: item + }); +}; describe('a specialist', () => { const item = new WorkItem({'dev': 1}); @@ -35,3 +65,73 @@ describe('a generalist', () => { expect(developer.canWorkOn({ skill: 'ux', item })).toBe(0.5); }); }); + +describe('a generalist with a dont-work-on-same-item strategy', () => { + const item1 = createWorkItem(); + const item2 = createWorkItem(); + + let developer; + + describe('who has not worked on item 1', () => { + beforeEach(() => { + developer = createDeveloper(); + }); + + it('can work item 1 in any skill', () => { + expect(developer.canWorkOn({ skill: 'ux', item: item1 })).toBe(1); + expect(developer.canWorkOn({ skill: 'dev', item: item1 })).toBe(1); + expect(developer.canWorkOn({ skill: 'qa', item: item1 })).toBe(1); + }); + }); + + describe('who has worked on item 1 in ux', () => { + beforeEach(jest.useFakeTimers); + afterEach(jest.runAllTimers); + + beforeEach(() => { + const columns = createColumns({ backlog: [item1], dev: [item2] }); + + developer = createDeveloper(); + startWorkingOn(item1, 'ux', developer, columns); + + jest.advanceTimersByTime(1000); + }); + + it('cannot work on the same item in dev', () => { + expect(developer.canWorkOn({ skill: 'dev', item: item1 })).toBe(0); + }); + + it('can work on item 2 in dev', () => { + expect(developer.canWorkOn({ skill: 'dev', item: item2 })).toBe(1); + }); + }); +}); + +describe('two generalists with a dont-work-on-same-item strategy', () => { + const item1 = new WorkItem({'ux': 1, 'dev': 1, 'qa': 1}); + + const createDeveloper = () => new Worker(new DontWorkOnSameItemStrategy(new SimpleSkillStrategy({'all': 1}))); + + let developer1; + let developer2; + + describe('when developer 1 has worked on an item in ux', () => { + beforeEach(jest.useFakeTimers); + afterEach(jest.runAllTimers); + + beforeEach(() => { + const columns = createColumns({ backlog: [item1] }); + + developer1 = createDeveloper(); + developer2 = createDeveloper(); + + startWorkingOn(item1, 'ux', developer1, columns); + + jest.advanceTimersByTime(1000); + }); + + it('developer 2 can work on the same item in dev', () => { + expect(developer2.canWorkOn({ skill: 'dev', item: item1 })).toBe(1); + }); + }); +}); diff --git a/src/worker.js b/src/worker.js index 921655f..4896b40 100644 --- a/src/worker.js +++ b/src/worker.js @@ -8,6 +8,10 @@ function SimpleSkillStrategy(skills) { return skills[skill] || skills['all'] || skills['rest'] || skills['fs'] || skills['fullstack']; } + function startWorkOn(workCriteria) { + // nothing to do + } + function renderName() { function renderSkills() { return Object.keys(skills).map(skill => `${skill}`) @@ -18,10 +22,32 @@ function SimpleSkillStrategy(skills) { return { workSpeedFor, + startWorkOn, renderName }; } +function DontWorkOnSameItemStrategy(strategy) { + const itemsWorkedOn = []; + + function workSpeedFor(workCriteria) { + if (itemsWorkedOn.includes(workCriteria.item)) return 0; + return strategy.workSpeedFor(workCriteria); + } + + function startWorkOn(workCriteria) { + itemsWorkedOn.push(workCriteria.item); + strategy.startWorkOn(workCriteria); + } + + return { + workSpeedFor, + startWorkOn, + renderName: strategy.renderName + }; + +} + function Worker(strategy) { let idle = true; const id = workerCounter++; @@ -51,6 +77,7 @@ function Worker(strategy) { inbox.move(inProgress, item); const workCriteria = { item, skill }; let timeout = calculateTimeoutFor(workCriteria); + strategy.startWorkOn(workCriteria); setTimeout(() => { idle = true; inProgress.move(outbox, item); @@ -115,4 +142,4 @@ function WorkList(skill = "dev") { return column; } -module.exports = {Worker, WorkItem, WorkList, SimpleSkillStrategy}; +module.exports = {Worker, WorkItem, WorkList, SimpleSkillStrategy, DontWorkOnSameItemStrategy}; From 61c9e7e5a16097c7e44350ff6cf3c86423cbe43d Mon Sep 17 00:00:00 2001 From: immortaleeb Date: Thu, 9 Sep 2021 14:17:25 +0200 Subject: [PATCH 6/6] Support "Work on unique stories" in UI This adds a checkbox to the UI and all related parsing logic to force each worker to have a `DontWorkOnSameItemStrategy`. --- index.html | 4 ++++ spec/parsing.spec.js | 17 +++++++++++------ spec/scenario.spec.js | 9 ++++++--- src/parsing.js | 6 +++--- src/scenario.js | 14 +++++++++++--- src/setup.js | 15 +++++++++------ 6 files changed, 44 insertions(+), 21 deletions(-) diff --git a/index.html b/index.html index 2135f04..de02646 100644 --- a/index.html +++ b/index.html @@ -25,6 +25,10 @@ Average work distribution +
diff --git a/spec/parsing.spec.js b/spec/parsing.spec.js index 36aab47..c73a772 100644 --- a/spec/parsing.spec.js +++ b/spec/parsing.spec.js @@ -18,16 +18,16 @@ describe('parseWorkload', () => { describe('parseWorkers', () => { it('parses a single worker with a single skill', () => { - expect(parseWorkers('dev')).toEqual([{skills: ['dev']}]); - expect(parseWorkers('qa')).toEqual([{skills: ['qa']}]); + expect(parseWorkers('dev', {workOnUniqueItems: false})).toEqual([{skills: ['dev'], workOnUniqueItems: false}]); + expect(parseWorkers('qa', {workOnUniqueItems: true})).toEqual([{skills: ['qa'], workOnUniqueItems: true}]); }); it('parses multiple workers with single skills', () => { - expect(parseWorkers('dev, qa')).toEqual([{skills: ['dev']}, {skills: ['qa']}]); + expect(parseWorkers('dev, qa', {workOnUniqueItems: false})).toEqual([{skills: ['dev'], workOnUniqueItems: false}, {skills: ['qa'], workOnUniqueItems: false}]); }); it('parses a single worker with multiple skills', () => { - expect(parseWorkers('dev+qa')).toEqual([{skills: ['dev', 'qa']}]); + expect(parseWorkers('dev+qa', {workOnUniqueItems: false})).toEqual([{skills: ['dev', 'qa'], workOnUniqueItems: false}]); }); }) @@ -37,12 +37,17 @@ describe('parseInput', () => { workload: 'dev: 3, qa: 1', workers: 'dev, qa, qa', wipLimit: '3', - random: true + random: true, + workOnUniqueItems: true }; const exampleParsedInput = { title: 'dev: 3, qa: 1', - workers: [{skills: ['dev']}, {skills: ['qa']}, {skills: ['qa']}], + workers: [ + {skills: ['dev'], workOnUniqueItems: true}, + {skills: ['qa'], workOnUniqueItems: true}, + {skills: ['qa'], workOnUniqueItems: true} + ], wipLimit: "3", distribution: average, speed: 20, diff --git a/spec/scenario.spec.js b/spec/scenario.spec.js index 4deb29d..2cc4c5d 100644 --- a/spec/scenario.spec.js +++ b/spec/scenario.spec.js @@ -51,7 +51,7 @@ describe('scenario', () => { beforeEach(() => { run({ id: 1, - workers: [{ skills: ['dev'] }], + workers: [{ skills: ['dev'], workOnUniqueItems: false }], stories: { amount: 10, work: {'dev': 1} @@ -72,7 +72,10 @@ describe('scenario', () => { beforeEach(() => { run({ id: 1, - workers: [{ skills: ['dev'] }, { skills: ['qa'] }], + workers: [ + { skills: ['dev'], workOnUniqueItems: false }, + { skills: ['qa'], workOnUniqueItems: false } + ], stories: { amount: 10, work: {'dev': 1, 'qa': 1} @@ -93,7 +96,7 @@ describe('scenario', () => { beforeEach(() => { run({ id: 1, - workers: [{ skills: ['all'] }], + workers: [{ skills: ['all'], workOnUniqueItems: false }], stories: { amount: 10, work: {'dev': 1, 'qa': 1} diff --git a/src/parsing.js b/src/parsing.js index f19909b..9950931 100644 --- a/src/parsing.js +++ b/src/parsing.js @@ -2,7 +2,7 @@ const {average} = require("./generator"); function parseInput(rawInput) { const title = rawInput.title; - const workers = parseWorkers(rawInput.workers); + const workers = parseWorkers(rawInput.workers, {workOnUniqueItems: rawInput.workOnUniqueItems}); const work = parseWorkload(rawInput.workload); const wipLimit = rawInput.wipLimit; const speed = (workers.length > 2) ? 20 : 1; @@ -21,10 +21,10 @@ function parseInput(rawInput) { return input; } -function parseWorkers(input) { +function parseWorkers(input, params) { return input .split(',') - .map(skillsInput => ({skills: parseSkills(skillsInput)})); + .map(skillsInput => ({skills: parseSkills(skillsInput), ...params})); } function parseSkills(input) { diff --git a/src/scenario.js b/src/scenario.js index 1723ffb..54209b7 100644 --- a/src/scenario.js +++ b/src/scenario.js @@ -1,6 +1,6 @@ const Board = require("./board"); const {generateWorkItems} = require("./generator"); -const {Worker, SimpleSkillStrategy} = require('./worker') +const {Worker, SimpleSkillStrategy, DontWorkOnSameItemStrategy} = require('./worker') let counter = 1; @@ -8,10 +8,18 @@ const Scenario = scenario => { const id = counter++; const wipLimit = scenario.wipLimit || scenario.stories.amount - const createWorker = ({ skills: skillNames }, speed = 1) => { + const createWorker = ({ skills: skillNames, workOnUniqueItems }, speed = 1) => { let skills = {}; skillNames.forEach(skillName => skills[skillName] = speed); - return new Worker(new SimpleSkillStrategy(skills)); + return new Worker(createWorkerStrategy(skills, workOnUniqueItems)); + }; + + const createWorkerStrategy = (skills, workOnUniqueItems) => { + const skillStrategy = new SimpleSkillStrategy(skills); + + return workOnUniqueItems + ? new DontWorkOnSameItemStrategy(skillStrategy) + : skillStrategy; }; const columnNames = () => Object.keys(scenario.stories.work); diff --git a/src/setup.js b/src/setup.js index 65e9eaa..71a8633 100644 --- a/src/setup.js +++ b/src/setup.js @@ -19,14 +19,17 @@ function createScenarioContainer(scenario) { } function parse(form) { - const field = fieldName => form.querySelector(`[name="${fieldName}"]`).value; + const field = fieldName => form.querySelector(`[name="${fieldName}"]`); + const fieldValue = fieldName => field(fieldName).value; + const isChecked = fieldName => field(fieldName).checked; return parseInput({ - title: field('workload'), - workers: field('workers'), - workload: field('workload'), - wipLimit: field('wip-limit'), - random: form.querySelector('[name="random"]').checked + title: fieldValue('workload'), + workers: fieldValue('workers'), + workload: fieldValue('workload'), + wipLimit: fieldValue('wip-limit'), + workOnUniqueItems: isChecked('unique-items'), + random: isChecked('random'), }); }