UNPKG

wbs-markdown

Version:

Work Breakdown Structure (WBS) in markdown format for software development projects.

360 lines (338 loc) 13.3 kB
// create a root instance new Vue({ el: '#vue-root', data: { // the template being used may not include a filter display component, // provide a good default show_mode value. show_mode: null, style_display: {wbs: "bullets", progress: true, totals: true, colored: true}, active_stories: [], story_work: {}, total_work: 0, stories: [], workItems: [], story_groups: {}, invalid_story_links: [], can_use_local_storage: false, root_nodes: [], positions: {}, max_level: 0, selected_level: 0 }, mounted: function() { this.can_use_local_storage = testLocalStorageAccess('lastDocId') // Load lastDocId from localstorage. If something was loaded (failure to // read returns a null). If found a previous ID and it is different from // this report's document. var lastDocId = safeGetLocalStorageItem("lastDocId", null) if (lastDocId && reportConfig.document_id && lastDocId != reportConfig.document_id) { deleteLocalStorageKeys(["stories", "selected_level", "filter_mode"]) } this.saveToLocalStorage('lastDocId', reportConfig.document_id) // get a list of stories var totalStories = _.map(this.stories, 'story') // load the persisted localStorage settings and try to apply them now. // // The root node is the last one to be mounted. Set the active_stories // to all the stories once mounted if we can't load from previous. // Triggers the items to evaluate their totals based on story inclusion. // // Intersect the loaded with what's valid for the report. Don't want invalid // "active" stories. var loadedStories = safeGetLocalStorageItem('stories', totalStories) this.active_stories = _.intersection(totalStories, loadedStories) // restore the saved "filter_mode" or "show_mode" if it exists. Otherwise // the default is used. this.show_mode = safeGetLocalStorageItem('filter_mode', "new-tracking") // restore the saved "style_display" settings if it exists. Otherwise the // default is used. this.style_display = _.merge({}, safeGetLocalStorageItem('style_display', this.style_display)) // Now that the children are all setup, look for work items that link to // a story that doesn't exist. If found, activate the display of an a // warning at at the root Vue level. // get a list of total work-item links var totalLinks = _.compact(_.uniq(_.map(this.workItems, 'link'))) var missingStories = [] totalLinks.forEach(function(link) { if (!_.includes(totalStories, link)) { missingStories.push(link) } }) this.invalid_story_links = missingStories // All items are mounted and registered. // Start the numbering for the structure. // Ensure "positions" is a new object var positions = {} this.root_nodes.forEach(function(child, index) { computePositions(child, [index + 1], positions) }) // set the positions to the newly computed object this.positions = positions // computed the greatest depth var maxFound = computeMaxLevel(positions) this.max_level = maxFound // load the last saved "selected_level". File changes could make that invalid. // If maxFound is less, use that. var loadedSelectedLevel = safeGetLocalStorageItem('selected_level', maxFound) if (maxFound < loadedSelectedLevel) { this.selected_level = maxFound } else { this.selected_level = loadedSelectedLevel } }, computed: { classObject: function() { return { "mode-new-tracking": this.show_mode == "new-tracking", "mode-existing": this.show_mode == "existing-only", "mode-all": this.show_mode == "all", "style-numbered": this.style_display["wbs"] == "numbered", "style-bullets": this.style_display["wbs"] == "bullets", "style-progress": this.style_display["progress"], "style-totals": this.style_display["totals"], "style-colored-checks": this.style_display["colored"] } } }, methods: { registered: function(child) { // track stories if (child.mode == "story") { this.stories.push(child) var storyList = this.story_groups[child.group] || [] this.story_groups[child.group] = _.concat(storyList, child.story) } // track work-items if (child.mode == "work-item") { this.workItems = _.concat(this.workItems, child) this.addStoryWork(child.user_link, child.user_work, child.done) } // track top-level non-work items (new or existing structure) but not // terminal elements if (child.mode == "none") { // if the parent vue element is this (the root) then it is a // top-level "none" item. if (child.$parent == this) { this.root_nodes.push(child) } } }, filterChanged: function(showMode) { // user changed the active filter using the filter component. // update the mode for display. this.show_mode = showMode; // record the "show_mode" under "filter_mode" in the localStorage. this.saveToLocalStorage('filter_mode', this.show_mode) }, styleChanged: function(options) { // user changed the style options using the style component. // update the options for display. // create a new object to ensure no reference issues this.style_display = _.merge({}, options) // record the styles under "style_display" in the localStorage. this.saveToLocalStorage('style_display', JSON.stringify(this.style_display)) }, levelChanged: function(newLevel) { this.saveToLocalStorage('selected_level', newLevel) this.selected_level = newLevel }, addStoryWork: function(story, userWork, isDone) { var storyData = this.story_work[story] || [] this.story_work[story] = _.concat(storyData, userWork) }, toggleStory: function(story) { // toggle a single story's active status (presence in the array) var index = this.active_stories.indexOf(story) if (index >= 0) { // return new array excluding the removed item // doing it this way to help Vue detect the array change this.active_stories.splice(index, 1) this.active_stories = this.active_stories } else { // toggled but not included, find the index in the full set and // insert at that position. var storyIndex = _.findIndex(this.stories, function(s) { return s.story == story }) this.active_stories.splice(storyIndex, 0, story) this.active_stories = this.active_stories } // store the story selection to localStorage so it persists for user. this.saveToLocalStorage('stories', JSON.stringify(this.active_stories)) }, toggleAllStories: function() { // loop through all the stories and toggle their active/checked state var allNames = _.map(this.stories, "story") var _this = this _.forEach(this.stories, function(s) { _this.toggleStory(s.story) }) }, saveToLocalStorage: function(key, value) { if (this.can_use_local_storage) { localStorage.setItem(key, value) } else { console.warn("Unable to write " + key + " to local storage. Value:", value) } } } }); // Convert a work estimate like "1w" to an object structure where the value // is converted to the lowest unit (hours) and the confidence is assigned. // "confidence" is an explicit confidence value if set by the user. // The confidence value is used if provided, otherwise a default value is // computed. function workEstimate(value, defaultAmount, confidence) { // regex supports fractional hours "5", "3h", "2.5d" var matches = value.match(/(\d+\.?\d*)([h|d|w|m]?)/i) var workAmount = defaultAmount var workUnit = reportConfig.defaultWorkUnit // if found the 2 parts (original text plus the 2 captures) if (matches && matches.length == 3) { workAmount = _.toNumber(matches[1]); if (!_.isEmpty(matches[2])) { workUnit = matches[2] } } return { display: workAmount.toString() + workUnit, user_unit: workUnit.toLowerCase(), amount: workAmount * reportConfig.unitConversion[workUnit], confidence: confidence || reportConfig.workUnitConfidencePct[workUnit] } } // Convert an "actual" estimate like "3.5h" to an object structure where the // value is converted to the lowest unit (hours). Same as `workEstimate` // but without the confidence percent. function workActual(value) { // use workEstimate, default missing amount to 0 var est = workEstimate(value, 0) return _.omit(est, ['confidence']) } // Display the amount in hours to a "best fit" unit for showing total // estimated work time function workDisplayBest(amountHours) { // Cycle through the unitConversions and stop at the "best" fit. // Best is when it is >= 1 // Start with what we were given. Could be a fractional hour like 0.25h which // would already be the best. var bestFit = {amount: amountHours, unit: "h"} // Convert the object in arrays. Ex: [["d", 6], ["h", 1]] var pairs = _.toPairs(reportConfig.unitConversion) // sort by the numeric value. Order of the keys can be alphabetical. var unitConversions = _.sortedUniqBy(pairs, function(a) { return a[1]}) _.forEach(unitConversions, function(pair) { var [unit, conversionAmount] = pair var testAmount = (amountHours / conversionAmount).toPrecision(2) if (testAmount >= 1.0) { bestFit["amount"] = testAmount bestFit["unit"] = unit } }) return bestFit.amount.toString() + bestFit.unit.toString() } // Compute the weighted average for the confidence values for a story. function weightedConfidence(storyWork) { // computed the weighted average for the confidence value. // takes the amount of work at it's level of confidence compared to total work. // https://en.wikipedia.org/wiki/Weighted_arithmetic_mean // (item1.amount * item1.confidence) + (item2.amount * item2.confidence) / (item1.amount + item2.amount) var numerator = _.sumBy(storyWork, function(work) { return work.estimate.amount * work.estimate.confidence }) var denominator = _.sumBy(storyWork, 'estimate.amount') if (denominator <= 0) denominator = 1; return Math.round(numerator / denominator) } // Get the display text for showing an amount of work function workDisplay(workAmount) { if (workAmount == 0) { return "-" } else { return workDisplayBest(workAmount) } } // Test if localStorage can be accessed. Returns a boolean. function testLocalStorageAccess(key) { try { localStorage.getItem(key) return true } catch (e) { console.error("Blocked from using localStorage") return false } } // Attempt to read from local storage. If blocked or a value isn't present, // return the fallback. Logs that it was blocked. function safeGetLocalStorageItem(key, fallback) { var rawData = null; try { // Try to read from local storage. // Could fail from browser security setting. rawData = localStorage.getItem(key) try { // Can read from local storage, try converting the value from JSON. // If it fails (ie. wasn't serialized), just use the value we received. return JSON.parse(rawData) || fallback } catch { // Failed to convert from JSON. Just return the value as-is. Could // just be a string. return rawData } } catch (e) { console.error(e) console.error("Blocked from using localStorage") console.log("returning fallback", fallback) return fallback } } /** * Delete the specified keys from localstorage. * @param {Array} keys Array of key names to delete */ function deleteLocalStorageKeys(keys) { try { _.forEach(keys, function(key) { localStorage.removeItem(key) }); } catch (e) { console.error("Blocked from using localStorage") } } // Uses dirty mutable objects to build out the entries on the positions hash // object. function computePositions(node, position, positions) { // record the node's position in the hash positions[node.getId()] = position // NOTE: $children includes non-work-item children. Filter to the list // of only wbs-item children var workItemChildren = _.filter(node.$children, function(child) { return child.$vnode.componentOptions["tag"] == "wbs-item" }) // recursively compute the positions this node's children workItemChildren.forEach(function(child, index) { computePositions(child, position.concat(index + 1), positions) }) } // Given an object with ID's and position arrays, we want to get all the arrays // and find the longest one. That is the max depth. function computeMaxLevel(positionsData) { // We've already computed an array for each work-item that has it's number // for WBS numbering structure. Now just find the longest of those arrays. // _.values() returns an array of arrays. var values = _.values(positionsData) return _.reduce(values, function(acc, numArray) { // if this array is longer than the longest we've seen so far, return that // length var currLength = numArray.length if (currLength > acc) { return currLength } else { return acc } }, 0) }