8 | Some features are blocked due to browser security settings preventing
9 | access to the browser's local storage. This is used to persist view
10 | settings and selected stories after a browser refresh.
11 |
12 |
13 |
14 | Please create an exception in the "Block third-party cookies and site
15 | data" to allow that feature.
16 |
39 |
40 |
41 |
86 |
87 |
90 |
--------------------------------------------------------------------------------
/lib/report/htmlPlugins/vue/vue.js:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 | var fileUtils = require('../../../file-utils');
3 |
4 | // This html plugin looks through config.components and includes .vue files
5 | // It also adds the element which is Vue's root
6 | module.exports = function(html, config) {
7 | var vueComponentsHtml = '';
8 | config.components.forEach(function(component) {
9 | if (component.vue) {
10 | var html = loadVueComponentFile(component);
11 | vueComponentsHtml += '\n' + html + '\n';
12 | }
13 | if (component.html) {
14 | var html = fileUtils.read(resolvePath(component.html));
15 | vueComponentsHtml += '\n' + html + '\n';
16 | }
17 | if (component.css) {
18 | var css = fileUtils.read(resolvePath(component.css));
19 | vueComponentsHtml += '\n\n';
20 | }
21 | if (component.js) {
22 | var js = fileUtils.read(resolvePath(component.js));
23 | vueComponentsHtml += '\n\n';
24 | }
25 | });
26 | return `
27 |
28 |
29 |
30 |
31 | ${html}
32 |
33 |
34 | ${vueComponentsHtml}
35 | `;
36 | };
37 |
38 | // this is a ghetto compiler for .vue files that contain
39 | // , ');
49 | componentHtml = componentHtml.replace('', `');
51 | componentHtml = componentHtml.replace(/(
114 |
--------------------------------------------------------------------------------
/lib/report/components/display-style/display-style.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
13 |
14 | {{ settingsText() }}
15 |
16 |
17 |
18 |
22 |
23 |
24 |
28 |
29 |
30 |
34 |
35 |
36 |
40 |
41 |
42 |
46 |
47 |
48 |
49 |
50 |
51 |
120 |
121 |
124 |
--------------------------------------------------------------------------------
/lib/report/generate.js:
--------------------------------------------------------------------------------
1 | #! /usr/bin/env node
2 |
3 | // Functions and behavior for compiling the report.
4 |
5 | // RESOURCES:
6 | // - https://www.npmjs.com/package/commander
7 | // - https://developer.atlassian.com/blog/2015/11/scripting-with-node/
8 | // - https://github.com/tj/commander.js/tree/master/examples
9 | // - https://nodejs.org/api/fs.html
10 | // - https://nodejs.org/api/path.html
11 | // - https://github.com/paulmillr/chokidar
12 |
13 | var path = require('path');
14 | var chokidar = require('chokidar');
15 | var _ = require('lodash');
16 | var md5 = require('md5')
17 | var version = require("../version").version;
18 | var fileUtils = require("../file-utils");
19 | var settings = require("../settings");
20 |
21 | /**
22 | * Given a config object, apply defaults supplied in ../config/default.json.
23 | * This function returns a new merged config object.
24 | * @param {Object} config The user-supplied .wbsm-config.json file
25 | */
26 | function applyConfigDefaults(config) {
27 | var defaultConfig = JSON.parse(fileUtils.read(path.join(__dirname, '../config/default.json')));
28 | return _.merge({}, defaultConfig, config);
29 | }
30 |
31 | /**
32 | * Given a config object, apply a computed MD5 hash as a "document_id" so it
33 | * will be accessible to the report at runtime.
34 | * This function returns a new config merged object.
35 | * @param {Object} config The user-supplied .wbsm-config.json file
36 | * @param {String} md5Hash Computed MD5 hash of the full project filename
37 | */
38 | function applyMd5HashToConfigDefaults(config, md5Hash) {
39 | return _.merge({}, config, {document_id: md5Hash})
40 | }
41 |
42 | /**
43 | * Convert markdown to HTML, respecting any markdown-it plugins and html plugins
44 | * @param {String} mdContents The contents of the markdown file
45 | * @param {Object} config The wbsm config file values
46 | * @returns {String}
47 | */
48 | function mdToHtml(mdContents, config) {
49 | // create markdown instance with given options
50 | var md = require('markdown-it')(config.markdownItOptions);
51 | // load any plugins
52 | config.markdownItPlugins.forEach(function(plugin) {
53 | var pluginFunction = require(plugin.require);
54 | md.use(pluginFunction);
55 | });
56 | // render
57 | var html = md.render(mdContents);
58 | // run post-render plugins
59 | config.htmlPlugins.forEach(function(plugin) {
60 | var pluginFunction = require(plugin.require);
61 | html = pluginFunction(html, config);
62 | });
63 | // return final html
64 | return html;
65 | }
66 |
67 | /**
68 | * Get html need to render js and css links and blocks in the document element
69 | * @param {Object} config The wbsm config file values
70 | * @returns {string}
71 | */
72 | function getAssets(config) {
73 | var lines = [];
74 | // gather script, link, and style tags
75 | config.assets.forEach(function(asset) {
76 | if (asset.script) {
77 | lines.push('');
78 | }
79 | else if (asset.stylesheet) {
80 | lines.push('');
81 | }
82 | else if (asset.style) {
83 | var cssPath = asset.style.match(/^\.\/components\//) ? path.join(__dirname, asset.style) : asset.style;
84 | var css = fileUtils.read(cssPath);
85 | lines.push('');
86 | }
87 | else if (asset.js) {
88 | var jsPath = asset.js.match(/^\.\/components\//) ? path.join(__dirname, asset.js) : asset.js;
89 | var js = fileUtils.read(jsPath);
90 | lines.push('');
91 | }
92 | });
93 | return lines.join('\n');
94 | }
95 |
96 | /**
97 | * Take the final html file and populate placeholders
98 | * @param {String} template The base HTML file
99 | * @param {Object} config The wbsm config file values
100 | * @param {Object} data An object containing the replacement values
101 | * @property {String} data.content The content generated by mdToHtml()
102 | * @property {String} data.assets The assets html generated by getAssets()
103 | * @property {String} data.version The wbsm version as stored in version.js
104 | * @property {String} data.dateText The report generation date
105 | * @returns {*}
106 | */
107 | function compileHtml(template, config, data) {
108 | var report = template;
109 | report = report.replace(/{{content}}/, data.content);
110 | report = report.replace(/{{assets}}/, data.assets);
111 | // stamp the wbsm version into the report
112 | report = report.replace(/{{version}}/, data.version);
113 | // replace "{{reportDate}}" with a text string of the date and time when generated
114 | report = report.replace(/{{reportDate}}/, data.dateText);
115 | // replace "{{reportTitle}}" with the value loaded from the config file
116 | report = report.replace(/{{reportTitle}}/, config.reportTitle);
117 | return report;
118 | }
119 |
120 | // Read the contents of a file out synchronously and return the file contents.
121 | function generate(markdownFilename, reportFilename) {
122 | // read markdown file contents
123 | var mdContents = fileUtils.read(markdownFilename);
124 | if (typeof mdContents === 'undefined') {
125 | console.error('No markdown contents found!');
126 | process.exit(1);
127 | }
128 |
129 | // get the full path of the filename. Use to generate an MD5 hash to ID this
130 | // file. Used for detecting when localstorage settings don't apply.
131 | var md5Filename = md5(path.join(__dirname, markdownFilename))
132 |
133 | var renderTemplateFilename = settings.getTemplateFilename();
134 | var dateText = new Date().toLocaleString()
135 | var config = JSON.parse(fileUtils.read("./.wbsm-config.json"));
136 | config = applyConfigDefaults(config);
137 | config = applyMd5HashToConfigDefaults(config, md5Filename)
138 | var content = mdToHtml(mdContents, config);
139 | var assets = getAssets(config);
140 | var template = fileUtils.read(renderTemplateFilename);
141 | var report = compileHtml(template, config, {version, dateText, assets, content});
142 | fileUtils.write(report, reportFilename);
143 | }
144 |
145 | // Perform a single file watch. When the file changes, automatically regenerate
146 | // the output report file.
147 | //
148 | // `markdownFilename` - The filename of the input markdown file.
149 | // `outputReportFilename` - The filename of the output report file.
150 | function watching(markdownFilename, outputReportFilename) {
151 | console.log("watching markdown filename", markdownFilename)
152 | // watch the markdown file for changes and regenerate the
153 | chokidar.watch(markdownFilename).on('all', (event, path) => {
154 | // if the markdown project file was deleted, stop the monitoring
155 | if (event == "unlink") {
156 | console.log("File was moved.")
157 | process.exit(0)
158 | }
159 | // console.log(event, path);
160 |
161 | // Use a short delay before trying to access the file. Otherwise
162 | // it would sometimes try to read the file too quickly and get no contents.
163 | _.delay(function() {
164 | // File event was "add" or "change". Regenerate the report.
165 | generate(markdownFilename, outputReportFilename);
166 | }, 100);
167 | });
168 | }
169 |
170 | module.exports = {
171 | generate: generate,
172 | watching: watching
173 | };
174 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WBS Markdown
2 |
3 | WBS Markdown is an NPM package designed to make it easier for software
4 | developers to create and manage a [Work Breakdown Structure
5 | (WBS)](https://en.wikipedia.org/wiki/Work_breakdown_structure). A WBS can be a
6 | powerful tool in the estimation process. This is fully compatible with an Agile
7 | workflow.
8 |
9 | ## Purpose
10 |
11 | This tool is designed to be used by a software developer and assumes you are
12 | skilled at editing text files (markdown specifically). The breakdown structure
13 | is managed in a [Markdown file](https://en.wikipedia.org/wiki/Markdown) format
14 | which you are probably already familiar with. Using your favorite editor you can
15 | collapse regions, perform mass updates, re-structure, and more.
16 |
17 | The generated report is a static HTML file. It uses [Vue.js](https://vuejs.org/)
18 | components to add some interactive features.
19 |
20 | ## Examples
21 |
22 | Example reports that show different ways of using the WBS Markdown tool. These
23 | are the rendered static HTML reports that are created from a markdown file.
24 |
25 | - [Bicycle Product Sample](https://brainlid.org/assets/static-html/wbs-markdown/wbs.bicycle-product.html) - [Markdown file](./examples/wbs.bicycle-product.md) Example shows the breakdown of a the creation of a new bicycle product. This is an adaptation from an example given on the [WBS Wikipedia page](https://en.wikipedia.org/wiki/Work_breakdown_structure#Example).
26 | - [Rails Commerce Project](https://brainlid.org/assets/static-html/wbs-markdown/wbs.rails-commerce.html) - [Markdown file](./examples/wbs.rails-commerce.md) Example shows a simple/small Rails e-Commerce project with a detailed breakdown of a new "Forgot my password" feature. This is a "high level of detail" breakdown and is an example of what that may look like. Remember, you choose the appropriate Level of Detail for your immediate need.
27 |
28 | ## Background
29 |
30 | How many times have you estimated a new feature and you ended up being *way*
31 | off? Yeah, I've been there too many times myself. Management and Project
32 | Managers need *some* idea of the amount of work something will be. This helps me
33 | give a better estimate so I don't forget parts of the system that are impacted
34 | by a change.
35 |
36 | As we continue to build out the features and the product, the project file is
37 | checked in with our source code so it is shared and expanded to represent the
38 | code being created.
39 |
40 | An additional benefit, I wanted to be able to track the progress of features
41 | being built. This allows for marking off tasks as being completed (which can be
42 | checked in with the implementing code). Since the report output is a static HTML
43 | file, a project build system can generate the report for what is committed on,
44 | say, the master branch and expose feature progress in that way as well.
45 |
46 | I created this tool for myself and my team. I share it in the hopes it can help
47 | others as well.
48 |
49 | ## Installation
50 |
51 | Using `npm`:
52 |
53 | ```
54 | npm install -g wbs-markdown
55 | ```
56 |
57 | Using `yarn`: (NOTE: Installation using yarn currently doesn't work and is a [known issue](https://github.com/brainlid/wbs_markdown/issues/19). The workaround for now is to install using npm.)
58 |
59 | ```
60 | yarn global add wbs-markdown
61 | ```
62 |
63 | Upgrading to a newer version:
64 |
65 | ```
66 | yarn global upgrade wbs-markdown
67 | ```
68 |
69 | ## Getting Started
70 |
71 | ### Quick Start
72 |
73 | These are the commands use:
74 |
75 | - `wbsm init` - One-time setup for a directory. Creates a configuration file.
76 | - `wbsm new` - Creates a new `wbs.project.md` markdown file.
77 | - `wbsm r` - Generates an HTML report from the default `wbs.project.md` file.
78 | - `wbsm w` - Watch for changes to the `wbs.project.md` file and auto-generate the HTML report.
79 | - `wbsm o` - Open the generated report in your default browser.
80 |
81 | ### Initialize a new project configuration
82 |
83 | ```
84 | wbsm init
85 | ```
86 |
87 | This will create a file named `.wbsm-config.json` in your current working
88 | directory.
89 |
90 | This is a sample configuration file:
91 |
92 | ```json
93 | {
94 | "reportTitle": "WBSM Project Report",
95 | "defaultWorkUnit": "d",
96 | "unitConversion": {
97 | "h": 1,
98 | "d": 6,
99 | "w": 30,
100 | "m": 120
101 | },
102 | "avgHoursPerDay": 4.5,
103 | "workUnitConfidencePct": {
104 | "h": 95,
105 | "d": 80,
106 | "w": 60,
107 | "m": 30
108 | }
109 | }
110 | ```
111 |
112 | ### Create New Markdown Project File
113 |
114 | Create a new project markdown file. This file can be checked in with the
115 | sourcecode of your project.
116 |
117 | ```
118 | wbsm new
119 | ```
120 |
121 | Optionally specify the name of the new file to create. Defaults to
122 | `wbs.project.md`.
123 |
124 | ```
125 | wbsm new wbs.my-project.md
126 | ```
127 |
128 | This is helpful for generating the report explicitly when you want. For instance,
129 | a CI server could generate the report file based on a git hook commit to master.
130 |
131 | ### Generate a Report
132 |
133 | Basic version. Defaults to look for a markdown file titled `wbs.project.md`.
134 |
135 | ```
136 | wbsm report
137 | wbsm r
138 | ```
139 |
140 | You can generate a report from a specifically named file using the `-m` flag and
141 | the filename.
142 |
143 | ```
144 | wbsm report -m wbs.my-project.md
145 | wbsm r -m wbs.my-project.md
146 | ```
147 |
148 | You can override the generated HTML report output filename using the `-r` flag
149 | and the filename.
150 |
151 | ```
152 | wbsm report -r custom-report-name.html
153 | wbsm r -r custom-report-name.html
154 | ```
155 |
156 | After generating the report, open it in the default system browser using the `-o` flag.
157 |
158 | ```
159 | wbsm report --open
160 | wbsm r -o
161 | ```
162 |
163 | ### Watch for Changes and Auto-Generate Report
164 |
165 | Basic version. Defaults to look for a markdown file titled `wbs.project.md`.
166 |
167 | ```
168 | wbsm watch
169 | wbsm w
170 | ```
171 |
172 | This uses the same command options as `wbsm report`. You can override the
173 | markdown file to use and the output file to generate.
174 |
175 | This is helpful when you are working on your project file and keep switching
176 | back to the report.
177 |
178 | ### Get CLI Help
179 |
180 | ```
181 | wbsm --help
182 | wbsm new --help
183 | wbsm report --help
184 | wbsm watch --help
185 | wbsm open --help
186 | ```
187 |
188 | ## Upgrade Notes
189 |
190 | If upgrading from a pre-1.0 version, you will want to add the "filter" component
191 | to your project file. This became a component which lets you customize the
192 | default display mode and the placement of the filter selection in your report.
193 |
194 | ## Usage
195 |
196 | The document uses a Markdown style. Anything you can create in Markdown is
197 | valid. This makes it easy to customize and create something that works and makes
198 | sense for your project and organization.
199 |
200 | ### Components
201 |
202 | There are a number of "components" to use for helping to get the most out of
203 | building a Work Breakdown Structure in Markdown.
204 |
205 | #### Story Item
206 |
207 | There are several valid ways to define a story item.
208 |
209 | ```markdown
210 | - Story description {story=StoryId}
211 | - **StoryId**: Story description {story=StoryId}
212 | - **StoryId**: Story description {story=StoryId group="Group Name"}
213 | ```
214 |
215 | The first one is minimum for a Story. A story can optionally be linked to a
216 | group. The bold StoryId is just to help with usability in reading and
217 | interacting with the document.
218 |
219 | **Examples**
220 |
221 | ```markdown
222 | - **ISSUE-123**: New Billing Integration Service {story="ISSUE-123"}
223 | - **[ISSUE-123](https://example.com/issue-link/ISSUE-123)**: New Billing Integration Service {story="ISSUE-123"}
224 | ```
225 |
226 | Remember that it is just Markdown, so it can contain links to external issue
227 | trackers or anything relevant.
228 |
229 | #### Work Item
230 |
231 | The work item is the heart of the document.
232 |
233 | ```markdown
234 | - [ ] Item description {work=1d link=987}
235 | - [x] Item description {work=1h link=987 actual=1.5h note="note to self"}
236 | ```
237 |
238 | Attributes:
239 |
240 | - `[ ]` - work item is incomplete
241 | - `[x]` - work item is complete
242 | - `{link=(story)}` - links a work item to a specific story
243 | - `{work=(duration)}` - estimated duration to complete. The more specific the estimate, the higher the confidence. There is higher confidence in `5d` than in `1w`.
244 | - Expressed as `unit` and `time`. Examples: `1d`, `2.5h`, `0.5w`
245 | - Supported values:
246 | - `h` - hours
247 | - `d` - days
248 | - `w` - weeks
249 | - `m` - months
250 | - `{actual=(duration)}` - (optional) actual time required to complete (for personal documentation)
251 | - `{confidence=(value)}` - (optional) explicitly set the confidence for the work estimate. A default confidence percent is used based on the time used. An hour long estimate has a higher confidence value than a week long one.
252 | - `{note="Text"}` - (optional) note to associate with the work item. A note is visible on the rendered report on a work item through a "note" icon. It is also exposed in the "table" component's display of work items. Ex: `{note="forgotten"}`
253 | - `{new=true}` - (optional) explicitly signal that something in the project structure should be treated as "new" when filtering, even though it isn't a work item that is directly estimated. It can be added at the top-level "new" item and all contained child items will be hidden when switched to the "Existing Structure Only" filter view.
254 |
255 |
256 | **NOTE:** Must be nested under a non work item.
257 |
258 | **Example:**
259 |
260 | ```markdown
261 | - BillingSystem
262 | - Integrations
263 | - [ ] Quickbooks Online {work=1m link=987}
264 | - Email Templates
265 | - [x] Quickbooks Integration communication problem {work=2h link=987}
266 | ```
267 |
268 | #### Filter Display
269 |
270 | Displays a Filter radio group for changing the current filter or mode of the display.
271 |
272 | The selection for the filter is written to the browsers local storage so it will
273 | remain the through a browser refresh.
274 |
275 | ```markdown
276 | filter {#display-filter}
277 | ```
278 |
279 | #### Style Display Options
280 |
281 | Displays a button that toggles the options that affect the style of the Work
282 | Breakdown Structure.
283 |
284 | ```markdown
285 | style {#display-style}
286 | ```
287 |
288 | ##### Options
289 |
290 | - **Numbering** - Uses the traditional WBS numbering style for the list. Traditional numbering uses an outline style like "1.1.1.2"
291 | - **Bullets** - Uses a bullet list for the WBS list.
292 | - **Show colored deliverable checks** - Work items appear with a "checkbox". This option determines if they are colored or not.
293 | - **Show progress** - Shows progress bars at the parent level for work items. It is cumulative for all work items nested under it.
294 | - **Show totals** - Display the computed work totals on the WBS or not.
295 |
296 | #### Level of Detail Display
297 |
298 | Displays a list of buttons for toggling the "Level of Detail" shown in the WBS.
299 | Helpful for "zooming out" to a higher level, then drilling down into a specific
300 | area to explore.
301 |
302 | ```markdown
303 | level {#detail-level}
304 | ````
305 |
306 | #### Story Chart
307 |
308 | Creates a chart that shows each story's work size, amount completed and
309 | optionally actual time spent.
310 |
311 | ```markdown
312 | chart {#stories-chart}
313 | ```
314 |
315 | When stories are toggled on/off, they are included or removed from the chart.
316 |
317 | #### Story Toggle
318 |
319 | Creates a toggle link. Helpful for flipping the inclusion of a story. If you
320 | want to focus on 1 or 2 stories, you can toggle all of them off and just turn on
321 | the ones you wish to focus on.
322 |
323 | ```markdown
324 | toggle {#stories-toggle}
325 | ```
326 |
327 | #### Story Totals
328 |
329 | Creates a component that totals all the selected stories. Optionally it can be
330 | linked to a specific group. This is effectively a sub-total then. When no group
331 | link is set, it gives the total for all the checked stories.
332 |
333 | ```markdown
334 | totals {#stories-total}
335 | totals {#stories-total group="Phase 1"}
336 | ```
337 |
338 | #### Story Table
339 |
340 | Generates a table with all the work items' details in an easy to access way.
341 |
342 | ```markdown
343 | table {#stories-table}
344 | ```
345 |
346 | This helps get data out and easily copied into a spreadsheet or other system.
347 | When a developer is working on measuring their ability to improve at estimate
348 | accuracy over time, they need data. This helps collect that data for personal
349 | use.
350 |
351 | This also exposes any "notes" on a work-item that otherwise aren't displayed.
352 |
353 | ## Troubleshooting
354 |
355 | ### Error notice about local storage
356 |
357 | **Google Chrome and Chromium**
358 |
359 | Settings > Search "content settings" > Content Settings > Cookies.
360 |
361 | If you want to continue to "Block third-party cookies", then you can add an exception to allow access for local storage to specific files or all local HTML files. The following is a sample allow filter for all HTML pages that are loaded from your local machine.
362 |
363 | ```
364 | file:///*
365 | ```
366 |
367 | ## Features
368 |
369 | - [ ] Multiple chart options. A total/initial chart (where confidence represents what it was initially) and one that is "remaining".
370 | - [ ] Compute the confidence in the work that remains.
371 | - [ ] Give estimate on when the work might be completed based on previous estimates that are marked done.
372 | - [ ] Report more on estimated vs actual (where recorded)
373 | - [ ] For work marked done where no "actual" was entered, use the work value? Helps keep the "actual" line moving more correctly.
374 |
--------------------------------------------------------------------------------
/lib/report/components/vue-main/vue-main.js:
--------------------------------------------------------------------------------
1 | // create a root instance
2 | new Vue({
3 | el: '#vue-root',
4 | data: {
5 | // the template being used may not include a filter display component,
6 | // provide a good default show_mode value.
7 | show_mode: null,
8 | style_display: {wbs: "bullets", progress: true, totals: true, colored: true},
9 | active_stories: [],
10 | story_work: {},
11 | total_work: 0,
12 | stories: [],
13 | workItems: [],
14 | story_groups: {},
15 | invalid_story_links: [],
16 | can_use_local_storage: false,
17 | root_nodes: [],
18 | positions: {},
19 | max_level: 0,
20 | selected_level: 0
21 | },
22 | mounted: function() {
23 | this.can_use_local_storage = testLocalStorageAccess('lastDocId')
24 | // Load lastDocId from localstorage. If something was loaded (failure to
25 | // read returns a null). If found a previous ID and it is different from
26 | // this report's document.
27 | var lastDocId = safeGetLocalStorageItem("lastDocId", null)
28 | if (lastDocId && reportConfig.document_id && lastDocId != reportConfig.document_id) {
29 | deleteLocalStorageKeys(["stories", "selected_level", "filter_mode"])
30 | }
31 | this.saveToLocalStorage('lastDocId', reportConfig.document_id)
32 |
33 | // get a list of stories
34 | var totalStories = _.map(this.stories, 'story')
35 |
36 | // load the persisted localStorage settings and try to apply them now.
37 | //
38 | // The root node is the last one to be mounted. Set the active_stories
39 | // to all the stories once mounted if we can't load from previous.
40 | // Triggers the items to evaluate their totals based on story inclusion.
41 | //
42 | // Intersect the loaded with what's valid for the report. Don't want invalid
43 | // "active" stories.
44 | var loadedStories = safeGetLocalStorageItem('stories', totalStories)
45 | this.active_stories = _.intersection(totalStories, loadedStories)
46 |
47 | // restore the saved "filter_mode" or "show_mode" if it exists. Otherwise
48 | // the default is used.
49 | this.show_mode = safeGetLocalStorageItem('filter_mode', "new-tracking")
50 |
51 | // restore the saved "style_display" settings if it exists. Otherwise the
52 | // default is used.
53 | this.style_display = _.merge({}, safeGetLocalStorageItem('style_display', this.style_display))
54 |
55 | // Now that the children are all setup, look for work items that link to
56 | // a story that doesn't exist. If found, activate the display of an a
57 | // warning at at the root Vue level.
58 |
59 | // get a list of total work-item links
60 | var totalLinks = _.compact(_.uniq(_.map(this.workItems, 'link')))
61 | var missingStories = []
62 | totalLinks.forEach(function(link) {
63 | if (!_.includes(totalStories, link)) {
64 | missingStories.push(link)
65 | }
66 | })
67 | this.invalid_story_links = missingStories
68 |
69 | // All items are mounted and registered.
70 | // Start the numbering for the structure.
71 | // Ensure "positions" is a new object
72 | var positions = {}
73 | this.root_nodes.forEach(function(child, index) {
74 | computePositions(child, [index + 1], positions)
75 | })
76 | // set the positions to the newly computed object
77 | this.positions = positions
78 | // computed the greatest depth
79 | var maxFound = computeMaxLevel(positions)
80 | this.max_level = maxFound
81 | // load the last saved "selected_level". File changes could make that invalid.
82 | // If maxFound is less, use that.
83 | var loadedSelectedLevel = safeGetLocalStorageItem('selected_level', maxFound)
84 | if (maxFound < loadedSelectedLevel) {
85 | this.selected_level = maxFound
86 | }
87 | else {
88 | this.selected_level = loadedSelectedLevel
89 | }
90 | },
91 | computed: {
92 | classObject: function() {
93 | return {
94 | "mode-new-tracking": this.show_mode == "new-tracking",
95 | "mode-existing": this.show_mode == "existing-only",
96 | "mode-all": this.show_mode == "all",
97 | "style-numbered": this.style_display["wbs"] == "numbered",
98 | "style-bullets": this.style_display["wbs"] == "bullets",
99 | "style-progress": this.style_display["progress"],
100 | "style-totals": this.style_display["totals"],
101 | "style-colored-checks": this.style_display["colored"]
102 | }
103 | }
104 | },
105 | methods: {
106 | registered: function(child) {
107 | // track stories
108 | if (child.mode == "story") {
109 | this.stories.push(child)
110 | var storyList = this.story_groups[child.group] || []
111 | this.story_groups[child.group] = _.concat(storyList, child.story)
112 | }
113 | // track work-items
114 | if (child.mode == "work-item") {
115 | this.workItems = _.concat(this.workItems, child)
116 | this.addStoryWork(child.user_link, child.user_work, child.done)
117 | }
118 | // track top-level non-work items (new or existing structure) but not
119 | // terminal elements
120 | if (child.mode == "none") {
121 | // if the parent vue element is this (the root) then it is a
122 | // top-level "none" item.
123 | if (child.$parent == this) {
124 | this.root_nodes.push(child)
125 | }
126 | }
127 | },
128 | filterChanged: function(showMode) {
129 | // user changed the active filter using the filter component.
130 | // update the mode for display.
131 | this.show_mode = showMode;
132 | // record the "show_mode" under "filter_mode" in the localStorage.
133 | this.saveToLocalStorage('filter_mode', this.show_mode)
134 | },
135 | styleChanged: function(options) {
136 | // user changed the style options using the style component.
137 | // update the options for display.
138 | // create a new object to ensure no reference issues
139 | this.style_display = _.merge({}, options)
140 | // record the styles under "style_display" in the localStorage.
141 | this.saveToLocalStorage('style_display', JSON.stringify(this.style_display))
142 | },
143 | levelChanged: function(newLevel) {
144 | this.saveToLocalStorage('selected_level', newLevel)
145 | this.selected_level = newLevel
146 | },
147 | addStoryWork: function(story, userWork, isDone) {
148 | var storyData = this.story_work[story] || []
149 | this.story_work[story] = _.concat(storyData, userWork)
150 | },
151 | toggleStory: function(story) {
152 | // toggle a single story's active status (presence in the array)
153 | var index = this.active_stories.indexOf(story)
154 | if (index >= 0) {
155 | // return new array excluding the removed item
156 | // doing it this way to help Vue detect the array change
157 | this.active_stories.splice(index, 1)
158 | this.active_stories = this.active_stories
159 | }
160 | else {
161 | // toggled but not included, find the index in the full set and
162 | // insert at that position.
163 | var storyIndex = _.findIndex(this.stories, function(s) {
164 | return s.story == story
165 | })
166 | this.active_stories.splice(storyIndex, 0, story)
167 | this.active_stories = this.active_stories
168 | }
169 | // store the story selection to localStorage so it persists for user.
170 | this.saveToLocalStorage('stories', JSON.stringify(this.active_stories))
171 | },
172 | toggleAllStories: function() {
173 | // loop through all the stories and toggle their active/checked state
174 | var allNames = _.map(this.stories, "story")
175 | var _this = this
176 | _.forEach(this.stories, function(s) {
177 | _this.toggleStory(s.story)
178 | })
179 | },
180 | saveToLocalStorage: function(key, value) {
181 | if (this.can_use_local_storage) {
182 | localStorage.setItem(key, value)
183 | }
184 | else {
185 | console.warn("Unable to write " + key + " to local storage. Value:", value)
186 | }
187 | }
188 | }
189 | });
190 |
191 | // Convert a work estimate like "1w" to an object structure where the value
192 | // is converted to the lowest unit (hours) and the confidence is assigned.
193 | // "confidence" is an explicit confidence value if set by the user.
194 | // The confidence value is used if provided, otherwise a default value is
195 | // computed.
196 | function workEstimate(value, defaultAmount, confidence) {
197 | // regex supports fractional hours "5", "3h", "2.5d"
198 | var matches = value.match(/(\d+\.?\d*)([h|d|w|m]?)/i)
199 | var workAmount = defaultAmount
200 | var workUnit = reportConfig.defaultWorkUnit
201 | // if found the 2 parts (original text plus the 2 captures)
202 | if (matches && matches.length == 3) {
203 | workAmount = _.toNumber(matches[1]);
204 | if (!_.isEmpty(matches[2])) {
205 | workUnit = matches[2]
206 | }
207 | }
208 | return {
209 | display: workAmount.toString() + workUnit,
210 | user_unit: workUnit.toLowerCase(),
211 | amount: workAmount * reportConfig.unitConversion[workUnit],
212 | confidence: confidence || reportConfig.workUnitConfidencePct[workUnit]
213 | }
214 | }
215 |
216 | // Convert an "actual" estimate like "3.5h" to an object structure where the
217 | // value is converted to the lowest unit (hours). Same as `workEstimate`
218 | // but without the confidence percent.
219 | function workActual(value) {
220 | // use workEstimate, default missing amount to 0
221 | var est = workEstimate(value, 0)
222 | return _.omit(est, ['confidence'])
223 | }
224 |
225 | // Display the amount in hours to a "best fit" unit for showing total
226 | // estimated work time
227 | function workDisplayBest(amountHours) {
228 | // Cycle through the unitConversions and stop at the "best" fit.
229 | // Best is when it is >= 1
230 | // Start with what we were given. Could be a fractional hour like 0.25h which
231 | // would already be the best.
232 | var bestFit = {amount: amountHours, unit: "h"}
233 |
234 | // Convert the object in arrays. Ex: [["d", 6], ["h", 1]]
235 | var pairs = _.toPairs(reportConfig.unitConversion)
236 | // sort by the numeric value. Order of the keys can be alphabetical.
237 | var unitConversions = _.sortedUniqBy(pairs, function(a) { return a[1]})
238 |
239 | _.forEach(unitConversions, function(pair) {
240 | var [unit, conversionAmount] = pair
241 | var testAmount = (amountHours / conversionAmount).toPrecision(2)
242 | if (testAmount >= 1.0) {
243 | bestFit["amount"] = testAmount
244 | bestFit["unit"] = unit
245 | }
246 | })
247 | return bestFit.amount.toString() + bestFit.unit.toString()
248 | }
249 |
250 | // Compute the weighted average for the confidence values for a story.
251 | function weightedConfidence(storyWork) {
252 | // computed the weighted average for the confidence value.
253 | // takes the amount of work at it's level of confidence compared to total work.
254 | // https://en.wikipedia.org/wiki/Weighted_arithmetic_mean
255 | // (item1.amount * item1.confidence) + (item2.amount * item2.confidence) / (item1.amount + item2.amount)
256 |
257 | var numerator = _.sumBy(storyWork, function(work) { return work.estimate.amount * work.estimate.confidence })
258 | var denominator = _.sumBy(storyWork, 'estimate.amount')
259 | if (denominator <= 0)
260 | denominator = 1;
261 | return Math.round(numerator / denominator)
262 | }
263 |
264 | // Get the display text for showing an amount of work
265 | function workDisplay(workAmount) {
266 | if (workAmount == 0) {
267 | return "-"
268 | }
269 | else {
270 | return workDisplayBest(workAmount)
271 | }
272 | }
273 |
274 | // Test if localStorage can be accessed. Returns a boolean.
275 | function testLocalStorageAccess(key) {
276 | try {
277 | localStorage.getItem(key)
278 | return true
279 | } catch (e) {
280 | console.error("Blocked from using localStorage")
281 | return false
282 | }
283 | }
284 |
285 | // Attempt to read from local storage. If blocked or a value isn't present,
286 | // return the fallback. Logs that it was blocked.
287 | function safeGetLocalStorageItem(key, fallback) {
288 | var rawData = null;
289 | try {
290 | // Try to read from local storage.
291 | // Could fail from browser security setting.
292 | rawData = localStorage.getItem(key)
293 | try {
294 | // Can read from local storage, try converting the value from JSON.
295 | // If it fails (ie. wasn't serialized), just use the value we received.
296 | return JSON.parse(rawData) || fallback
297 | } catch {
298 | // Failed to convert from JSON. Just return the value as-is. Could
299 | // just be a string.
300 | return rawData
301 | }
302 | } catch (e) {
303 | console.error(e)
304 | console.error("Blocked from using localStorage")
305 | console.log("returning fallback", fallback)
306 | return fallback
307 | }
308 | }
309 |
310 | /**
311 | * Delete the specified keys from localstorage.
312 | * @param {Array} keys Array of key names to delete
313 | */
314 | function deleteLocalStorageKeys(keys) {
315 | try {
316 | _.forEach(keys, function(key) {
317 | localStorage.removeItem(key)
318 | });
319 | } catch (e) {
320 | console.error("Blocked from using localStorage")
321 | }
322 | }
323 |
324 | // Uses dirty mutable objects to build out the entries on the positions hash
325 | // object.
326 | function computePositions(node, position, positions) {
327 | // record the node's position in the hash
328 |
329 | positions[node.getId()] = position
330 | // NOTE: $children includes non-work-item children. Filter to the list
331 | // of only wbs-item children
332 | var workItemChildren = _.filter(node.$children, function(child) {
333 | return child.$vnode.componentOptions["tag"] == "wbs-item"
334 | })
335 | // recursively compute the positions this node's children
336 | workItemChildren.forEach(function(child, index) {
337 | computePositions(child, position.concat(index + 1), positions)
338 | })
339 | }
340 |
341 | // Given an object with ID's and position arrays, we want to get all the arrays
342 | // and find the longest one. That is the max depth.
343 | function computeMaxLevel(positionsData) {
344 | // We've already computed an array for each work-item that has it's number
345 | // for WBS numbering structure. Now just find the longest of those arrays.
346 | // _.values() returns an array of arrays.
347 | var values = _.values(positionsData)
348 | return _.reduce(values, function(acc, numArray) {
349 | // if this array is longer than the longest we've seen so far, return that
350 | // length
351 | var currLength = numArray.length
352 | if (currLength > acc) {
353 | return currLength
354 | }
355 | else {
356 | return acc
357 | }
358 | }, 0)
359 | }
360 |
--------------------------------------------------------------------------------
/lib/report/components/wbs-item/wbs-item.vue:
--------------------------------------------------------------------------------
1 |
2 |