package com.metservice.kanban.model; import static com.metservice.kanban.utils.DateUtils.parseIsoDate; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.ListIterator; import java.util.Map; import org.apache.commons.lang.StringUtils; import org.joda.time.LocalDate; import com.google.common.base.Preconditions; import com.metservice.kanban.utils.DateUtils; import com.metservice.kanban.utils.WorkingDayUtils; /** * A work item is a story. It can have a parent. * * */ public class WorkItem { public static final Comparator<WorkItem> LAST_PHASE_DATE_COMPARATOR = new LastPhaseDateComparator(); public static final int ROOT_WORK_ITEM_ID = 0; private static final String NEWLINE = "\n"; private static final String NO_ITEM = "-"; private final int id; private final int parentId; private final WorkItemType type; private String name; private int averageCaseEstimate; private int worstCaseEstimate; private int importance; private String notes; private boolean excluded; private boolean blocked; //Whether a story is allowed to progress regardless of stage private HtmlColour colour; //Keep track of when this item started a phase private final Map<String, LocalDate> datesByPhase = new HashMap<String, LocalDate>(); private String currentPhase; private boolean mustHave; private List<String> workStreams; private List<WorkItemComment> comments = new ArrayList<WorkItemComment>(); public WorkItem(int id, WorkItemType type, String advanceToPhase) { this(id, ROOT_WORK_ITEM_ID, type, advanceToPhase); } public WorkItem(int id, int parentId, WorkItemType type, String advanceToPhase) { this(id, parentId, type); int targetPhaseIndex = type.getPhases().indexOf(advanceToPhase); if (targetPhaseIndex == -1) { throw new IllegalArgumentException("cannot advance; named phase does not exist: " + advanceToPhase); } for (int i = 0; i < targetPhaseIndex + 1; i++) { advance(parseIsoDate("1970-01-01")); } } public WorkItem(int id, WorkItemType workItemType) { this(id, ROOT_WORK_ITEM_ID, workItemType); } /** * Default constructor for WorkItem * @param id - id of the item we are creating * @param parentId - parent item's id * @param type - type of WorkItem */ public WorkItem(int id, int parentId, WorkItemType type) { this.id = id; this.parentId = parentId; this.type = type; this.name = ""; this.averageCaseEstimate = 0; this.importance = 0; this.notes = ""; this.excluded = false; this.blocked = false; this.colour = new HtmlColour("FFFFFF"); this.worstCaseEstimate = 0; this.mustHave = false; } public int getId() { return id; } public boolean isTopLevel() { return parentId == ROOT_WORK_ITEM_ID; } public int getParentId() { return parentId; } public WorkItemType getType() { return type; } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAverageCaseEstimate() { return averageCaseEstimate; } public void setAverageCaseEstimate(int averageCaseEstimate) { this.averageCaseEstimate = averageCaseEstimate; } public int getImportance() { return importance; } public void setImportance(int importance) { this.importance = importance; } public String getNotes() { return notes; } public String getNotesAndBlock() { if (isBlocked()) { return StringUtils.defaultIfEmpty(getNotes(), "") + NEWLINE + getLastBlockedComment(); } else { return getNotes(); } } public String getQuickOverview() { StringBuilder overview = new StringBuilder(); overview.append("Importance : "). append(getImportance()).append(NEWLINE); overview.append("Streams : "); if (getWorkStreams().isEmpty()) { overview.append(NO_ITEM); } else { for (String s : getWorkStreams()) { overview.append(" ").append(s); } } overview.append(NEWLINE); final String notes = getNotes(); overview.append("Notes : ").append( (notes != null ? notes : NO_ITEM) ).append(NEWLINE); final String lastComment = getLastComment(); overview.append("Last comment : ").append( "".equals(lastComment) ? NO_ITEM : lastComment ).append(NEWLINE); return overview.toString(); } public String getLastComment() { WorkItemComment lastComment = null; for (WorkItemComment c : getComments()) { if (lastComment == null || lastComment.getWhenAdded().isBefore(c.getWhenAdded())) { lastComment = c; } } if (lastComment == null) { return ""; } return lastComment.getCommentText() + " [" + lastComment.getAddedBy() + " @ " + lastComment.getWhenAdded().toString(DateUtils.DATE_FORMAT_STR) + "]"; } public String getLastBlockedComment() { WorkItemComment lastComment = null; for (WorkItemComment c : getComments()) { if (isBlockedComment(c) && (lastComment == null || lastComment.getWhenAdded().isBefore(c.getWhenAdded()))) { lastComment = c; } } if (lastComment == null) { return ""; } return lastComment.getCommentText() + " [" + lastComment.getAddedBy() + "]"; } private boolean isBlockedComment(WorkItemComment c) { return c.getCommentText().startsWith("Blocked:"); } public void setNotes(String notes) { this.notes = notes; } public boolean isExcluded() { return excluded; } public void setExcluded(boolean excluded) { this.excluded = excluded; } public void setBlocked(boolean blocked) { this.blocked = blocked; } public String getCurrentPhase() { if (currentPhase == null) { throw new IllegalStateException("work item is not yet in any phase"); } return currentPhase; } public boolean hasDate(String phase) { return datesByPhase.containsKey(phase); } public LocalDate getDate(String phase) { return datesByPhase.get(phase); } public int getWorkingDaysOnCurrentPhase() { return WorkingDayUtils.getWorkingDaysBetween(getDate(getCurrentPhase()), new LocalDate()); } public LocalDate getLastPhaseDate() { return getDate(currentPhase); } public Map<String, LocalDate> getDatesByPhase() { return datesByPhase; } public void setDate(String phase, LocalDate date) { if (date == null) { datesByPhase.remove(phase); } else { datesByPhase.put(phase, date); } currentPhase = determineCurrentPhase(); } public void setDateAsString(String phase, String dateAsString) { setDate(phase, parseIsoDate(dateAsString)); } public String getPhaseOnDate(LocalDate date) { String phaseOnDate = null; for (String phase : type.getPhases()) { if (hasDate(phase) && !this.getDate(phase).isAfter(date)) { phaseOnDate = phase; } } return phaseOnDate; } public Map<String, Integer> getPhaseDurations() { Map<String, Integer> phaseDurations = new HashMap<String, Integer>(); LocalDate previousDate = null; String previousPhase = null; LocalDate today = new LocalDate(); previousDate = fillMissingPhaseDates(previousDate); for (String phase : this.getType().getPhases()) { LocalDate date = this.getDate(phase); if (date == null) { date = today; } if (previousDate != null && previousPhase != null) { int diffInDays = WorkingDayUtils.getWorkingDaysBetween(previousDate, date); phaseDurations.put(previousPhase, diffInDays); } previousDate = date; previousPhase = phase; } //Last item will always have a duration between it's start date and now. if (previousDate != null && previousPhase != null) { int diffInDays = WorkingDayUtils.getWorkingDaysBetween(previousDate, today); phaseDurations.put(previousPhase, diffInDays); } return phaseDurations; } private LocalDate fillMissingPhaseDates(LocalDate previousDate) { // fill missing dates before current phase String newCurrentPhase = determineCurrentPhase(); for (ListIterator<String> i = getType().getPhases().listIterator(getType().getPhases().size()); i.hasPrevious();) { String phase = i.previous(); if (getDate(phase) != null) { previousDate = getDate(phase); } if (this.getType().isPhaseBefore(phase, newCurrentPhase) && getDate(phase) == null) { setDate(phase, previousDate); } } return previousDate; } private String determineCurrentPhase() { String newCurrentPhase = null; for (String phase : type.getPhases()) { if (hasDate(phase)) { newCurrentPhase = phase; } } return newCurrentPhase; } public boolean isCompleted() { return !type.hasPhaseAfter(currentPhase); } public boolean isBlocked() { return blocked; } public void stop() { blocked = !blocked; } /** * Advance the phase of this item to the next phase in the phase list * @param date - the date the next phase has started (e.g. right now) */ public void advance(LocalDate date) { if (!type.hasPhaseAfter(currentPhase)) { throw new IllegalStateException(this + " cannot advance: it is already in its final phase"); } //Set the start date of the next phase to date setDate(type.getPhaseAfter(currentPhase), date); } /** * Returns a copy of this WorkItem, with a new parent. * * @param newParentId * @return */ public WorkItem withNewParent(int newParentId) { WorkItem workItem = new WorkItem(id, newParentId, type); workItem.name = name; workItem.averageCaseEstimate = averageCaseEstimate; workItem.importance = importance; workItem.notes = notes; workItem.datesByPhase.putAll(datesByPhase); workItem.currentPhase = currentPhase; workItem.excluded = excluded; workItem.colour = colour; workItem.blocked = blocked; workItem.comments.addAll(comments); return workItem; } /** * Adds a new comment to the work item. * * @param comment * The comment to add; mandatory. * * @throws NullPointerException * If any of the mandatory parameters are {@code null}. */ public void addComment(WorkItemComment comment) { Preconditions.checkNotNull(comment); comment.setParentId(id); this.comments.add(comment); } public List<WorkItemComment> getComments() { return Collections.unmodifiableList(this.comments); } public List<WorkItemComment> getCommentsInReverseOrder() { List<WorkItemComment> newList = new ArrayList<WorkItemComment>(comments); Collections.reverse(newList); return Collections.unmodifiableList(newList); } /** * Replaces the work items current list of comments with the specified list. * <p> * Not intended for general use. * </p> * * @param newComments * the new comments. */ public void resetCommentsAndReplaceWith(List<WorkItemComment> newComments) { this.comments.clear(); if (newComments != null) { this.comments.addAll(newComments); } } @Override public String toString() { return "work item " + getId() + " (" + getName() + ")"; } @Override public boolean equals(Object object) { if (object == null) { return false; } else if (object instanceof WorkItem) { return ((WorkItem) object).id == id; } else { return false; } } @Override public int hashCode() { return id; } public String getTruncatedName() { if (name == null) { return null; } return name.substring(0, Math.min(name.length(), 40)); } public void setColour(String colour) { if (colour != null && colour.length() > 0) { this.colour = new HtmlColour(colour); } } public HtmlColour getColour() { return colour; } public int getWorstCaseEstimate() { return worstCaseEstimate; } public void setWorstCaseEstimate(int worstCaseEstimate) { this.worstCaseEstimate = worstCaseEstimate; } public boolean isMustHave() { return mustHave; } public void setMustHave(boolean mustHave) { this.mustHave = mustHave; } public int getVariance() { int deviation = getWorstCaseEstimate() - getAverageCaseEstimate(); return deviation * deviation; } public List<String> getWorkStreams() { return workStreams; } public void setWorkStreams(List<String> workStreams) { this.workStreams = workStreams; } public String getWorkStreamsAsString() { return StringUtils.join(workStreams, ','); } public void setWorkStreamsAsString(String workStream) { if (workStream == null) { this.workStreams = new ArrayList<String>(); } else { this.workStreams = new ArrayList<String>(); for (String ws : StringUtils.split(workStream, ',')) { this.workStreams.add(StringUtils.trim(ws)); } } } public boolean isInWorkStream(String workStream) { // TODO this should rather ask the parent for work stream if (!isTopLevel()) { return true; } if (workStream == null || "".equals(workStream)) { return true; } return workStreams.contains(workStream); } static class LastPhaseDateComparator implements Comparator<WorkItem> { @Override public int compare(WorkItem o1, WorkItem o2) { return o2.getLastPhaseDate().compareTo(o1.getLastPhaseDate()); } } }