diff --git a/.gitignore b/.gitignore index 63ccaa26d..20de3e0a3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ db/*.sqlite3 *.log tmp/ .rvmrc +.ruby-version .sass-cache/ *.DS_Store config/deploy.rb diff --git a/app/assets/javascripts/actions/actionTypes.js b/app/assets/javascripts/actions/actionTypes.js index 83327b1ce..02d194f54 100644 --- a/app/assets/javascripts/actions/actionTypes.js +++ b/app/assets/javascripts/actions/actionTypes.js @@ -1,4 +1,4 @@ -import keyMirror from 'keymirror'; +import keyMirror from "keymirror"; export default keyMirror({ REQUEST_PROJECT_BOARD: null, @@ -10,5 +10,5 @@ export default keyMirror({ COLUMN_CHILLY_BIN: null, COLUMN_BACKLOG: null, COLUMN_IN_PROGRESS: null, - COLUMN_DONE: null, + COLUMN_DONE: null }); diff --git a/app/assets/javascripts/actions/column.js b/app/assets/javascripts/actions/column.js index 4a1bb0e84..b9b00a0b7 100644 --- a/app/assets/javascripts/actions/column.js +++ b/app/assets/javascripts/actions/column.js @@ -1,41 +1,42 @@ -import actionTypes from './actionTypes'; -import * as iteration from '../models/beta/iteration'; +import actionTypes from "./actionTypes"; +import * as iteration from "../models/beta/iteration"; -const setStoryChillyBin = (payload) => ({ +const setStoryChillyBin = payload => ({ type: actionTypes.COLUMN_CHILLY_BIN, - data: payload, + data: payload }); -const setStoryBacklog = (payload) => ({ +const setStoryBacklog = payload => ({ type: actionTypes.COLUMN_BACKLOG, - data: payload, + data: payload }); -const setStoryDone = (payload) => ({ +const setStoryDone = payload => ({ type: actionTypes.COLUMN_DONE, - data: payload, + data: payload }); export const getColumnType = (story, project) => { - if(story.state === 'unscheduled') { + if (story.state === "unscheduled") { return setStoryChillyBin(story); } - if(isBacklog(story, project)) { + if (isBacklog(story, project)) { return setStoryBacklog(story); } return setStoryDone(story); -} +}; const setColumn = (dispatch, project) => story => { var type = getColumnType(story, project); return dispatch(type); -} +}; const isBacklog = (story, project) => { const currentIteration = iteration.getCurrentIteration(project); const storyIteration = iteration.getIterationForStory(story, project); const isFromCurrentSprint = currentIteration === storyIteration; - return story.state !== 'accepted' || isFromCurrentSprint; -} + return story.state !== "accepted" || isFromCurrentSprint; +}; -export const classifyStories = (dispatch, stories, project) => stories.map(setColumn(dispatch, project)) +export const classifyStories = (dispatch, stories, project) => + stories.map(setColumn(dispatch, project)); diff --git a/app/assets/javascripts/actions/projectBoard.js b/app/assets/javascripts/actions/projectBoard.js index 0b39cb66c..17a8af8af 100644 --- a/app/assets/javascripts/actions/projectBoard.js +++ b/app/assets/javascripts/actions/projectBoard.js @@ -1,30 +1,30 @@ -import * as ProjectBoard from 'models/beta/projectBoard'; -import actionTypes from './actionTypes'; -import { classifyStories } from './column'; -import { receiveUsers } from './user'; -import { receiveStories } from './story'; +import * as ProjectBoard from "models/beta/projectBoard"; +import actionTypes from "./actionTypes"; +import { classifyStories } from "./column"; +import { receiveUsers } from "./user"; +import { receiveStories } from "./story"; const requestProjectBoard = () => ({ type: actionTypes.REQUEST_PROJECT_BOARD }); -const receiveProjectBoard = (projectId) => ({ +const receiveProjectBoard = projectId => ({ type: actionTypes.RECEIVE_PROJECT_BOARD, data: projectId }); -const errorRequestProjectBoard = (error) => ({ +const errorRequestProjectBoard = error => ({ type: actionTypes.ERROR_REQUEST_PROJECT_BOARD, error: error }); -const receiveProject = (project) => ({ +const receiveProject = project => ({ type: actionTypes.RECEIVE_PROJECT, data: project }); -export const fetchProjectBoard = (projectId) => { - return (dispatch) => { +export const fetchProjectBoard = projectId => { + return dispatch => { dispatch(requestProjectBoard()); ProjectBoard.get(projectId) @@ -35,8 +35,6 @@ export const fetchProjectBoard = (projectId) => { dispatch(receiveProjectBoard(projectId)); classifyStories(dispatch, stories, project); }) - .catch((error) => - dispatch(errorRequestProjectBoard(error)) - ); + .catch(error => dispatch(errorRequestProjectBoard(error))); }; -} +}; diff --git a/app/assets/javascripts/components/Columns/ColumnItem.js b/app/assets/javascripts/components/Columns/ColumnItem.js index ddcc5653a..e58b6da51 100644 --- a/app/assets/javascripts/components/Columns/ColumnItem.js +++ b/app/assets/javascripts/components/Columns/ColumnItem.js @@ -1,14 +1,16 @@ -import React from 'react' -import Stories from '../stories/Stories'; +import React from "react"; +import Stories from "../stories/Stories"; -const Column = ({ title, stories }) => ( +const Column = ({ title, children }) => (

{title}

- +
- +
{children}
); -export default Column +export default Column; diff --git a/app/assets/javascripts/components/projects/ProjectBoard.js b/app/assets/javascripts/components/projects/ProjectBoard.js index 18f627496..081e2e014 100644 --- a/app/assets/javascripts/components/projects/ProjectBoard.js +++ b/app/assets/javascripts/components/projects/ProjectBoard.js @@ -1,7 +1,9 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { fetchProjectBoard } from 'actions/projectBoard'; -import Column from '../Columns/ColumnItem'; +import React from "react"; +import { connect } from "react-redux"; +import { fetchProjectBoard } from "actions/projectBoard"; +import Column from "../Columns/ColumnItem"; +import Stories from "../stories/Stories"; +import Sprints from "../stories/Sprints"; class ProjectBoard extends React.Component { componentWillMount() { @@ -9,24 +11,27 @@ class ProjectBoard extends React.Component { } render() { - if(!this.props.projectBoard.isFetched) { + if (!this.props.projectBoard.isFetched) { return Loading; } return (
+ + + - - + title={`${I18n.t("projects.show.backlog")} / + ${I18n.t("projects.show.in_progress")}`}> + + + + + +
); } @@ -43,11 +48,14 @@ const mapStateToProps = ({ project, users, stories, - columns, + columns }); const mapDispatchToProps = { fetchProjectBoard }; -export default connect(mapStateToProps, mapDispatchToProps)(ProjectBoard); +export default connect( + mapStateToProps, + mapDispatchToProps +)(ProjectBoard); diff --git a/app/assets/javascripts/components/stories/Sprint.js b/app/assets/javascripts/components/stories/Sprint.js new file mode 100644 index 000000000..1c077e897 --- /dev/null +++ b/app/assets/javascripts/components/stories/Sprint.js @@ -0,0 +1,54 @@ +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import Stories from "./Stories"; + +const propTypes = { + number: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + startDate: PropTypes.node, + points: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + completedPoints: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + stories: PropTypes.array +}; + +const defaultProps = { + number: 0, + startDate: 0, + points: 0, + stories: [] +}; + +class Sprint extends Component { + constructor(props) { + super(props); + this.state = { isClosed: false }; + this.toggleSprint = this.toggleSprint.bind(this); + } + + toggleSprint() { + this.setState(prevState => ({ isClosed: !prevState.isClosed })); + } + + render() { + const { number, startDate, points, completedPoints, stories } = this.props; + const closedStyle = this.state.isClosed && "Sprint__body--is-collapsed"; + return ( +
+
+ {number} - {startDate} + + {completedPoints > 0 && `${completedPoints} / `} + {points} + +
+
+ {stories && } +
+
+ ); + } +} + +Sprint.propTypes = propTypes; +Sprint.defaultProps = defaultProps; + +export default Sprint; diff --git a/app/assets/javascripts/components/stories/Sprints.js b/app/assets/javascripts/components/stories/Sprints.js new file mode 100644 index 000000000..16b8d945f --- /dev/null +++ b/app/assets/javascripts/components/stories/Sprints.js @@ -0,0 +1,48 @@ +import React from "react"; +import PropTypes from "prop-types"; +import Sprint from "./Sprint"; +import * as Iteration from "models/beta/iteration"; + +const propTypes = { + stories: PropTypes.array, + project: PropTypes.object +}; + +const defaultProps = { + stories: [], + project: {} +}; + +const renderSprints = sprints => { + return sprints.map( + (sprint, index) => + sprint ? ( + + ) : null + ); +}; + +const Sprints = ({ stories, project }) => { + const currentSprintNumber = Iteration.getCurrentIteration(project) || 0; + const sprints = Iteration.groupBySprints( + stories, + project, + currentSprintNumber + ); + + if (!stories.length) return null; + + return
{renderSprints(sprints)}
; +}; + +Sprint.propTypes = propTypes; +Sprint.defaultProps = defaultProps; + +export default Sprints; diff --git a/app/assets/javascripts/components/stories/Stories.js b/app/assets/javascripts/components/stories/Stories.js index 5dfa1a3b7..c39aecccf 100644 --- a/app/assets/javascripts/components/stories/Stories.js +++ b/app/assets/javascripts/components/stories/Stories.js @@ -1,5 +1,5 @@ -import React from 'react'; -import StoryItem from '../story/StoryItem' +import React from "react"; +import StoryItem from "../story/StoryItem"; const Stories = ({ stories }) => { if (!stories.length) { @@ -9,10 +9,7 @@ const Stories = ({ stories }) => { return (
{stories.map(story => ( - + ))}
); diff --git a/app/assets/javascripts/libs/beta/constants.js b/app/assets/javascripts/libs/beta/constants.js new file mode 100644 index 000000000..e0e79e871 --- /dev/null +++ b/app/assets/javascripts/libs/beta/constants.js @@ -0,0 +1,15 @@ +export const status = { + ACCEPTED: "accepted", + DELIVERED: "delivered", + STARTED: "started", + REJECTED: "rejected", + FINISHED: "finished", + UNSTARTED: "unstarted" +}; + +export const storyTypes = { + BUG: "bug", + CHORE: "chore", + FEATURE: "feature", + RELEASE: "release" +}; diff --git a/app/assets/javascripts/models/beta/iteration.js b/app/assets/javascripts/models/beta/iteration.js index 7c4680648..ad7f773e0 100644 --- a/app/assets/javascripts/models/beta/iteration.js +++ b/app/assets/javascripts/models/beta/iteration.js @@ -1,21 +1,160 @@ -import moment from 'moment'; +import moment from "moment"; +import * as Story from "./story"; const weeksBetween = (dateA, dateB) => - moment(new Date(dateA)).diff(moment(new Date(dateB)), 'week'); + moment(new Date(dateA)).diff(moment(new Date(dateB)), "week"); const getIterationForDate = (date, project) => { const weeks = weeksBetween(date, project.startDate); const iterationQuantity = Math.ceil(weeks / project.iterationLength); - return project.iterationLength > weeks ? iterationQuantity : iterationQuantity + 1; + return project.iterationLength > weeks + ? iterationQuantity + : iterationQuantity + 1; }; -export const getCurrentIteration = (project) => ( - getIterationForDate(new Date(), project) -); +export const getDateForIterationNumber = (iterationNumber, project) => { + return moment(new Date(project.startDate)) + .startOf("isoWeek") + .add(iterationNumber, "weeks") + .format("ddd MMM Do Y"); +}; + +export const getCurrentIteration = project => + getIterationForDate(new Date(), project); export const getIterationForStory = (story, project) => { - if (story.state === 'accepted') { + if (story.state === "accepted") { return getIterationForDate(new Date(story.acceptedAt), project); } -} +}; + +const createSprint = (sprintNumber = 0, startDate = 0, isFiller = false, velocity) => ({ + number: sprintNumber + 1, + startDate: startDate, + points: 0, + completedPoints: null, + stories: [], + isFiller: isFiller, + remainingPoints: velocity +}); + +const createFillerSprints = (size, initialNumber, project) => { + return _.times(size, (i) => { + const sprintNumber = initialNumber + i; + const sprint = createSprint( + sprintNumber, + getDateForIterationNumber(sprintNumber, project), + true, + project.defaultVelocity + ); + return sprint; + }) +}; + +const canTakeStory = (sprint, storyPoints) => { + if (!sprint.isFiller) { + return sprint.remainingPoints >= storyPoints; + } + return false; +}; + +const calculateFillerSprintsQuantity = (storyPoints, velocity) => { + return Math.ceil((storyPoints - velocity) / velocity); +}; + +const handleSprintsOverflow = (project, sprints, storyPoints, initialSprintNumber) => { + const overflow = calculateFillerSprintsQuantity(storyPoints, project.defaultVelocity); + const hasOverflown = overflow > 0; + const currentSprintNumber = sprints.length + initialSprintNumber; + if (hasOverflown) { + return [ + ...sprints, + ...createFillerSprints(overflow, currentSprintNumber, project) + ]; + } + return sprints; +}; + +const addStoryToSprint = (project, sprints, index, story) => { + sprints[index].stories.push(story); + sprints[index].points += Story.getPoints(story); + const lastValidSprintIndex = sprints.length - 2; + + let previousFillerSprintsQuantity = 0; + for (let i = lastValidSprintIndex; i >= 0; i--) { + if (sprints[i].isFiller) { + previousFillerSprintsQuantity++; + } else { + break; + } + } + fillRemainingPoints( + sprints, Story.getPoints(story), + index, project.defaultVelocity, + previousFillerSprintsQuantity + ); + + sprints[index].completedPoints += Story.getCompletedPoints(story); + + return sprints; +}; + +const fillRemainingPoints = (sprints, storyPoints, index, velocity, previousFillerSprintsQuantity) => { + if (previousFillerSprintsQuantity === 0) { + sprints[index].remainingPoints -= storyPoints; + } else { + sprints[index].remainingPoints = storyPoints - (previousFillerSprintsQuantity * velocity); + } +}; + +const createFirstSprint = (sprints, project) => { + sprints.push(createSprint( + 0, + getDateForIterationNumber(0, project), + undefined, + project.defaultVelocity, + )); +}; + +const addToSprintFromBacklog = (sprints, project, story) => { + const sprintIndex = sprints.length && sprints.length - 1; + const hasSpace = canTakeStory(sprints[sprintIndex], Story.getPoints(story)); + + if (hasSpace) { + return addStoryToSprint(project, sprints, sprintIndex, story); + } + + sprints[sprintIndex + 1] = createSprint( + sprints.length + getCurrentIteration(project), + getDateForIterationNumber(sprints.length + getCurrentIteration(project), project), + undefined, + project.defaultVelocity + ); + + return addStoryToSprint(project, sprints, sprintIndex + 1, story); +}; + +export const groupBySprints = (stories = [], project, initialSprintNumber) => { + return stories.reduce((sprints, story) => { + const firstSprintIndex = 0; + const isFromSprintInProgress = !Story.isUnstarted(story); + + sprints = handleSprintsOverflow( + project, + sprints, + Story.getPoints(story), + initialSprintNumber + ); + + if (sprints.length === 0) { + createFirstSprint(sprints, project); + } + + if (isFromSprintInProgress) { + return addStoryToSprint(project, sprints, firstSprintIndex, story); + } + + return addToSprintFromBacklog(sprints, project, story); + }, []); +}; diff --git a/app/assets/javascripts/models/beta/story.js b/app/assets/javascripts/models/beta/story.js index 0f9f45fe7..3a768cb2b 100644 --- a/app/assets/javascripts/models/beta/story.js +++ b/app/assets/javascripts/models/beta/story.js @@ -1,31 +1,52 @@ +import { status, storyTypes } from "libs/beta/constants"; -const compareValues = (a ,b) => { +const compareValues = (a, b) => { if (a > b) return 1; if (a < b) return -1; return 0; -} +}; export const comparePosition = (a, b) => { const positionA = parseFloat(a.position); const positionB = parseFloat(b.position); return compareValues(positionA, positionB); -} +}; export const compareAcceptedAt = (a, b) => { return compareValues(a.acceptedAt, b.acceptedAt); -} +}; export const compareDeliveredAt = (a, b) => { return compareValues(a.deliveredAt, b.deliveredAt); -} +}; export const compareStartedAt = (a, b) => { return compareValues(a.startedAt, b.startedAt); -} +}; + +export const isUnestimatedFeature = story => { + return story.estimate === null && story.storyType === storyTypes.FEATURE; +}; + +export const isFeature = story => { + return story.storyType === storyTypes.FEATURE; +}; + +export const isUnstarted = story => { + return story.state === status.UNSTARTED; +}; + +export const isAccepted = story => { + return story.state === status.ACCEPTED; +}; + +export const getPoints = story => { + return isFeature(story) ? story.estimate : 0; +}; -export const isUnestimatedFeature = (story) => { - return story.estimate === null && story.storyType === 'feature' +export const getCompletedPoints = story => { + return isFeature(story) && isAccepted(story) ? story.estimate : 0; }; diff --git a/app/assets/javascripts/reducers/columns/backlog.js b/app/assets/javascripts/reducers/columns/backlog.js index 0c3eedb76..3937b2e5a 100644 --- a/app/assets/javascripts/reducers/columns/backlog.js +++ b/app/assets/javascripts/reducers/columns/backlog.js @@ -1,37 +1,41 @@ -import actionTypes from 'actions/actionTypes'; -import * as Story from 'models/beta/story'; -import _ from 'underscore'; +import actionTypes from "actions/actionTypes"; +import { status } from "libs/beta/constants"; +import * as Story from "models/beta/story"; +import _ from "underscore"; const initialState = { - stories: [], + stories: [] }; const filterByState = state => story => { return story.state === state; -} +}; -const orderByState = (stories) => { +const orderByState = stories => { const ordered = [...stories]; ordered.sort(Story.comparePosition); const acceptedStories = ordered - .filter(filterByState('accepted')) - .sort(Story.compareAcceptedAt); + .filter(filterByState(status.ACCEPTED)) + .sort(Story.compareAcceptedAt); const deliveredStories = ordered - .filter(filterByState('delivered')) - .sort(Story.compareDeliveredAt); + .filter(filterByState(status.DELIVERED)) + .sort(Story.compareDeliveredAt); const startedStories = ordered - .filter(filterByState('started')) - .sort(Story.compareStartedAt); + .filter(filterByState(status.STARTED)) + .sort(Story.compareStartedAt); - const rejectedStories = ordered.filter(filterByState('rejected')); - const finishedStories = ordered.filter(filterByState('finished')); - const unstartedStories = ordered.filter(filterByState('unstarted')); + const rejectedStories = ordered.filter(filterByState(status.REJECTED)); + const finishedStories = ordered.filter(filterByState(status.FINISHED)); + const unstartedStories = ordered.filter(filterByState(status.UNSTARTED)); - const partitionedFeatures = _.partition(unstartedStories, Story.isUnestimatedFeature); + const partitionedFeatures = _.partition( + unstartedStories, + Story.isUnestimatedFeature + ); const unestimatedUnstartedStories = partitionedFeatures[0]; const estimatedUnstartedStories = partitionedFeatures[1]; @@ -44,19 +48,16 @@ const orderByState = (stories) => { ...estimatedUnstartedStories, ...unestimatedUnstartedStories ]; -} +}; const backlog = (state = initialState, action) => { switch (action.type) { case actionTypes.COLUMN_BACKLOG: - const stories = [ - ...state.stories, - action.data - ]; + const stories = [...state.stories, action.data]; return { stories: orderByState(stories) - } + }; default: return state; } diff --git a/app/assets/stylesheets/new_board/_sprint.scss b/app/assets/stylesheets/new_board/_sprint.scss new file mode 100644 index 000000000..44a39f326 --- /dev/null +++ b/app/assets/stylesheets/new_board/_sprint.scss @@ -0,0 +1,21 @@ +.Sprint { + &__header { + padding: 5px 10px; + font-size: 11px; + color: $lightgrey-12; + background-color: $darkgrey-7; + border-top: 1px solid $darkgrey-8; + border-bottom: 1px solid $darkgrey-9; + } + + &__points { + float: right; + } + + &__body { + display: block; + &--is-collapsed { + display: none; + } + } +} diff --git a/spec/javascripts/components/stories/sprint_spec.js b/spec/javascripts/components/stories/sprint_spec.js new file mode 100644 index 000000000..4329a1bab --- /dev/null +++ b/spec/javascripts/components/stories/sprint_spec.js @@ -0,0 +1,65 @@ +import jasmineEnzyme from "jasmine-enzyme"; +import React from "react"; +import { shallow } from "enzyme"; +import Sprint from "components/stories/Sprint"; + +let props = {}; +let wrapper = {}; + +const createProps = () => ({ + number: 1, + startDate: "2018/09/03", + points: 3, + completedPoints: 0, + stories: [ + { + id: 1, + position: "3", + state: "unstarted", + estimate: 1, + storyType: "feature" + }, + { + id: 2, + position: "2", + state: "unstarted", + estimate: 1, + storyType: "feature" + } + ] +}); + +describe("", () => { + beforeEach(() => { + jasmineEnzyme(); + props = createProps(); + wrapper = shallow(); + }); + + it('renders a
with class ".Sprint"', () => { + expect(wrapper.find("div.Sprint")).toHaveLength(1); + }); + + it('renders a div with class ".Sprint__header"', () => { + expect(wrapper.find("div.Sprint__header")).toHaveLength(1); + }); + + it('renders a div with class ".Sprint__body"', () => { + expect(wrapper.find("div.Sprint__body")).toHaveLength(1); + }); + + it("renders a components", () => { + expect(wrapper.find("Stories")).toHaveLength(1); + }); + + describe("when no stories are passed as props", () => { + beforeEach(() => { + props = createProps(); + props.stories = null; + wrapper = shallow(); + }); + it("does not render any component", () => { + expect(wrapper.find("Stories")).toHaveLength(0); + }); + }); +}); diff --git a/spec/javascripts/components/stories/sprints_spec.js b/spec/javascripts/components/stories/sprints_spec.js new file mode 100644 index 000000000..d89f26011 --- /dev/null +++ b/spec/javascripts/components/stories/sprints_spec.js @@ -0,0 +1,54 @@ +import jasmineEnzyme from "jasmine-enzyme"; +import React from "react"; +import { shallow } from "enzyme"; +import Sprints from "components/stories/Sprints"; + +let props = {}; +let wrapper = {}; + +const createProps = () => ({ + stories: [ + { + id: 1, + position: "3", + state: "unstarted", + estimate: 1, + storyType: "feature" + }, + { + id: 2, + position: "2", + state: "unstarted", + estimate: 1, + storyType: "feature" + } + ], + project: { + startDate: "2018-09-03T16:00:00", + iterationLength: 1, + defaultVelocity: 2 + } +}); + +describe("", () => { + beforeEach(() => { + jasmineEnzyme(); + props = createProps(); + wrapper = shallow(); + }); + + it("renders one components", () => { + expect(wrapper.find("Sprint")).toHaveLength(1); + }); + + describe("when no stories are passed as props", () => { + beforeEach(() => { + props = createProps(); + props.stories = []; + wrapper = shallow(); + }); + it("does not render any component", () => { + expect(wrapper.find("Sprint")).toHaveLength(0); + }); + }); +}); diff --git a/spec/javascripts/models/beta/iteration_spec.js b/spec/javascripts/models/beta/iteration_spec.js index ef6659c27..a53907759 100644 --- a/spec/javascripts/models/beta/iteration_spec.js +++ b/spec/javascripts/models/beta/iteration_spec.js @@ -1,53 +1,225 @@ -import moment from 'moment'; -import * as Iteration from 'models/beta/iteration'; +import * as Iteration from "models/beta/iteration"; -describe('iteration', function() { - beforeEach(function() { - this.clock = sinon.useFakeTimers(new Date('2018-05-01T17:00:00').getTime()); - }); +describe("iteration", function() { + describe("time related functions", function() { + beforeEach(function() { + this.clock = sinon.useFakeTimers( + new Date("2018-05-01T17:00:00").getTime() + ); + }); - afterEach(function() { - this.clock.restore(); - }); + afterEach(function() { + this.clock.restore(); + }); - describe('when 1 out of 1 week has passed', function() { - it('should return 2', function() { - const sprintNumber = Iteration.getCurrentIteration({ - iterationLength: 1, - startDate: "2018-04-24T16:00:00" - }); - expect(sprintNumber).toEqual(2); + describe("when 1 out of 1 week has passed", function() { + it("should return 2", function() { + const sprintNumber = Iteration.getCurrentIteration({ + iterationLength: 1, + startDate: "2018-04-24T16:00:00" + }); + expect(sprintNumber).toEqual(2); + }); }); - }); - describe("when 3 out of 3 weeks has passed", function() { - it('should return 2', function() { - const sprintNumber = Iteration.getCurrentIteration({ - iterationLength: 3, - startDate: "2018-04-10T16:00:00" - }); - expect(sprintNumber).toEqual(2); + describe("when 3 out of 3 weeks has passed", function() { + it("should return 2", function() { + const sprintNumber = Iteration.getCurrentIteration({ + iterationLength: 3, + startDate: "2018-04-10T16:00:00" + }); + expect(sprintNumber).toEqual(2); + }); }); - }); - describe("when 1 out of 2 weeks has passed", function() { - it('should return 1', function() { - const sprintNumber = Iteration.getCurrentIteration({ - iterationLength: 2, - startDate: "2018-04-24T16:00:00" - }); - expect(sprintNumber).toEqual(1); + describe("when 1 out of 2 weeks has passed", function() { + it("should return 1", function() { + const sprintNumber = Iteration.getCurrentIteration({ + iterationLength: 2, + startDate: "2018-04-24T16:00:00" + }); + expect(sprintNumber).toEqual(1); + }); }); - }); - describe("when a story was acceped a week ago", function() { - it('should return 1', function() { - const sprintNumber = Iteration.getIterationForStory( - { state: 'accepted', acceptedAt: "2018-04-24T16:00:00" }, - { startDate: "2018-04-10T16:00:00", iterationLength: 2 } - ); - expect(sprintNumber).toEqual(2); + describe("when a story was acceped a week ago", function() { + it("should return 2", function() { + const sprintNumber = Iteration.getIterationForStory( + { state: "accepted", acceptedAt: "2018-04-24T16:00:00" }, + { startDate: "2018-04-10T16:00:00", iterationLength: 2 } + ); + expect(sprintNumber).toEqual(2); + }); }); }); + describe("when reducing stories to sprints", function() { + beforeEach(function() { + this.project = { + startDate: "2018-09-03T16:00:00", + iterationLength: 1, + defaultVelocity: 2 + }; + this.initialSprintNumber = Iteration.getCurrentIteration(this.project); + }); + + describe("with empty array of stories", function() { + it("should return an empty array of sprints", function() { + const stories = []; + sprints = Iteration.groupBySprints( + stories, + this.project, + this.initialSprintNumber + ); + expect(sprints).toEqual([]); + }); + }); + + describe("with 2 unstarted features with 1 point", function() { + it("should return an array with 1 item", function() { + const stories = [ + { + id: 1, + position: "3.2", + state: "unstarted", + estimate: 1, + storyType: "feature" + }, + { + id: 2, + position: "10", + state: "unstarted", + estimate: 1, + storyType: "feature" + } + ]; + + const sprints = Iteration.groupBySprints( + stories, + this.project, + this.initialSprintNumber + ); + + expect(sprints.length).toEqual(1); + }); + }); + + describe("with 3 unstarted features with 2, 5 and 1 points and velocity 3", function() { + it("should return an array with 3 items", function() { + const stories = [ + { + id: 1, + position: "1", + state: "unstarted", + estimate: 2, + storyType: "feature" + }, + { + id: 2, + position: "2", + state: "unstarted", + estimate: 5, + storyType: "feature" + }, + { + id: 3, + position: "3", + state: "unstarted", + estimate: 1, + storyType: "feature" + } + ]; + + this.project.defaultVelocity = 3; + const sprints = Iteration.groupBySprints( + stories, + this.project, + this.initialSprintNumber + ); + + expect(sprints.length).toEqual(3); + }); + }); + + describe("with 1 unstarted features with 8 points and velocity 3", function() { + it("should return an array with 3 items", function() { + const stories = [ + { + id: 1, + position: "1", + state: "unstarted", + estimate: 8, + storyType: "feature" + }, + ]; + this.project.defaultVelocity = 3; + const sprints = Iteration.groupBySprints( + stories, + this.project, + this.initialSprintNumber + ); + + expect(sprints.length).toEqual(3); + }); + }); + + describe("with started, finished and delivered stories", function() { + it("should return an array with 2 items", function() { + const stories = [ + { + id: 1, + position: "1.5", + state: "accepted", + acceptedAt: "2018-09-03T16:36:20.811Z", + estimate: 1, + storyType: "feature" + }, + { + id: 3, + position: "3.2", + state: "unstarted", + estimate: 2, + storyType: "feature" + }, + { + id: 4, + position: "7.5", + state: "started", + startedAt: "2018-09-03T16:36:20.811Z", + estimate: 1, + storyType: "feature" + }, + { + id: 5, + position: "3.7", + state: "finished", + estimate: 1, + storyType: "feature" + }, + { + id: 6, + position: "4.9", + state: "delivered", + deliveredAt: "2018-09-03T16:36:20.811Z", + estimate: 1, + storyType: "feature" + }, + { + id: 7, + position: "10", + state: "unstarted", + estimate: 1, + storyType: "feature" + } + ]; + + const sprints = Iteration.groupBySprints( + stories, + this.project, + this.initialSprintNumber + ); + expect(sprints.length).toEqual(3); + }); + }); + }); });