From 81c631fd48bf42fe851ed6e9ba63ecdcd175227d Mon Sep 17 00:00:00 2001 From: VladyslavKurmaz Date: Sun, 17 Nov 2024 23:39:31 +0200 Subject: [PATCH 1/4] feat: v0.11.0 --- .tpm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.tpm b/.tpm index 7352305..1644ff9 100644 --- a/.tpm +++ b/.tpm @@ -11,7 +11,7 @@ timeline: v0.11.0: deadline: 2024-11-21 21:00:00 GMT+0200 v0.10.0: - deadline: 2024-11-17 21:00:00 GMT+0200 + deadline: 2024-11-17 23:40:00 GMT+0200 v0.9.0: deadline: 2024-11-10 23:00:00 GMT+0200 v0.8.0: From 47b17d2421cb5178f90376ae65013989ec3816d7 Mon Sep 17 00:00:00 2001 From: VladyslavKurmaz Date: Sun, 17 Nov 2024 23:39:39 +0200 Subject: [PATCH 2/4] 0.11.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07b74f7..5ed0314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tln-pm", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tln-pm", - "version": "0.10.0", + "version": "0.11.0", "license": "MIT", "dependencies": { "assign-deep": "^1.0.1", diff --git a/package.json b/package.json index 1e5fc10..a2d3a2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tln-pm", - "version": "0.10.0", + "version": "0.11.0", "description": "Project Management as Code", "main": "cli.js", "scripts": { From b6f473bbabd01b10853ef0bef4bf0bdb30649e76 Mon Sep 17 00:00:00 2001 From: VladyslavKurmaz Date: Sun, 24 Nov 2024 21:21:38 +0200 Subject: [PATCH 3/4] feat: display hierarchy of tasks --- .tpm | 19 ++-- cli.js | 67 +++++++++++++- src/app.js | 4 +- src/component.js | 47 ++++------ src/server.js | 8 ++ src/task.js | 13 ++- web/index.html | 12 +-- web/main.js | 226 ++++++++++++++++++++++++++++++++++------------- web/styles.css | 4 + 9 files changed, 287 insertions(+), 113 deletions(-) diff --git a/.tpm b/.tpm index 1644ff9..5385658 100644 --- a/.tpm +++ b/.tpm @@ -9,7 +9,7 @@ team: fte: 1 timeline: v0.11.0: - deadline: 2024-11-21 21:00:00 GMT+0200 + deadline: 2024-11-25 12:00:00 GMT+0200 v0.10.0: deadline: 2024-11-17 23:40:00 GMT+0200 v0.9.0: @@ -29,33 +29,34 @@ timeline: v0.2.0: deadline: 2024-09-04 17:30:00 GMT+0200 tasks: | + [-:031:v0.12.0] Fix task output on hierarchy mode @vlad.k [+:030:v0.10.0] Implement deadline class @vlad.k - [-:029:v0.11.0] Implement config command via source class @vlad.k - [-:028:v0.11.0] Tasks spill over batch mode @vlad.k + [-:029:v0.12.0] Implement config command via source class @vlad.k + [-:028:v0.12.0] Tasks spill over batch mode @vlad.k [+:027:v0.10.0] Implement team/member section as classes @vlad.k [+:026:v0.9.0] Implement srs section as classes @vlad.k [+:025:v0.9.0] Show SRS in UI @vlad.k - [-:024:v0.11.0] Tweak task status based on child tasks (normalise) @vlad.k + [-:024:v0.12.0] Tweak task status based on child tasks (normalise) @vlad.k [+:023:v0.9.0] Implement project, team, srs section as classes @vlad.k [+:022:v0.7.0] Describe command - project @vlad.k [+:021:v0.6.0] Add project description section @vlad.k [+:020:v0.5.0] Add option to display tasks with different statuses: --backlog, --dev, --done @vlad.k [+:019:v0.5.0] Add component as optional parameter for ls command @vlad.k - [-:018:v0.11.0] Generate snapshot @vlad.k + [-:018:v0.12.0] Generate snapshot @vlad.k [+:017:v0.5.0] Add SRS section @vlad.k - [-:016:v0.11.0] Add describe command with resolved links @vlad.k + [-:016:v0.12.0] Add describe command with resolved links @vlad.k [x:015:v0.5.0] Integrate .tpm folder at repository root level to store snapshots @vlad.k - [-:014:v0.11.0] Update README.md with Hello World section using repository @vlad.k + [-:014:v0.12.0] Update README.md with Hello World section using repository @vlad.k [+:013:v0.6.0] Add search @vlad.k [+:012:v0.6.0] Add CI/CD @vlad.k [+:011:v0.6.0] Add unit test framework @vlad.k [+:010:v0.6.0] Extract tags, timeline from task description @vlad.k [+:009:v0.5.0] Add command to generate .todo template --team, --timeline, --tasks, --force @vlad.k [+:008:v0.6.0] Merge multiple descriptions from one file @vlad.k - [-:007:v0.11.0] Server command: express server + fs watch @vlad.k + [-:007:v0.12.0] Server command: express server + fs watch @vlad.k [+] Create API using express [+] Implement web part: Dashboard - [-] Implement web part: Timeline + [>] Implement web part: Timeline [-] Implement web part: Team [-] Add --watch option to watch for changes in .todo file and refresh tpm internal state [+:006:v0.4.0] Extract status, id, assingees from task description @vlad.k diff --git a/cli.js b/cli.js index 3cac4c7..9f197b1 100755 --- a/cli.js +++ b/cli.js @@ -10,6 +10,7 @@ const findUp = require('find-up') const yargs = require('yargs/yargs') const { hideBin } = require('yargs/helpers'); const yaml = require('yaml'); +const { dump } = require('js-yaml'); const getApp = async (argv, load, fn) => { const verbose = argv.verbose; @@ -53,6 +54,8 @@ yargs(hideBin(process.argv)) .option('srs', { describe: 'Include SRS section', default: false, type: 'boolean' }) .option('force', { describe: 'Force command execution', default: false, type: 'boolean' }) + .option('json', { describe: 'Output in json format', default: false, type: 'boolean' }) + .option('yaml', { describe: 'Output in yaml format', default: false, type: 'boolean' }) .option('hierarchy', { describe: 'Output nested components as hierarchy', default: false, type: 'boolean' }) // // ls command aims to work exclusively with tasks @@ -65,13 +68,71 @@ yargs(hideBin(process.argv)) }, async (argv) => { getApp(argv, true, async (a) => { // console.log(argv); - await a.ls({ + const component = await a.ls({ component: argv.component, depth: argv.depth, who: { assignees: argv.assignee, all: argv.all }, - filter: { tag: argv.tag, search: argv.search, deadline: argv.deadline, status: { backlog: argv.backlog, dev: argv.dev, done: argv.done } }, - hierarchy: argv.hierarchy + filter: { tag: argv.tag, search: argv.search, deadline: argv.deadline, status: { backlog: argv.backlog, dev: argv.dev, done: argv.done } } }); + // + const hierarchy = argv.hierarchy; + if (component) { + if (argv.json || argv.yaml) { + if (argv.json) { + a.logger.con(JSON.stringify(component)); + } else { + a.logger.con(yaml.stringify(component)); + } + } else { + const dump = (c, indent, last) => { + // title + let ti = ` `; + let percentage = ''; + if (c.tasks.length) { + percentage = Math.round(c.tasks.reduce((acc, t) => acc + t.percentage, 0) / c.tasks.length); + if (percentage > 0) { + percentage = `(${percentage}%)`; + } else { + percentage = ''; + } + } + if (hierarchy) { + ti = `${indent}` + ((last && c.components.length === 0) ? ' ' : '│') + const title = (c.id) ? `${indent}${last?'└':'├'} ${c.id}` : ''; + a.logger.con(`${title} ${percentage}`); + } else { + if (c.tasks.length) { + a.logger.con(); + a.logger.con(`~ ${c.relativePath} ${percentage}`); + } + } + // tasks + const out = (task, indent) => { + if (task.title) { + const g = task.assignees.length ? ` @(${task.assignees.join(',')})` : ''; + const tg = task.tags.length ? ` #(${task.tags.join(',')})` : ''; + const dl = task.deadline ? ` (${task.deadline})` : ''; + const id = task.id ? ` ${task.id}:` : ''; + a.logger.con(`${indent}${task.status}${id} ${task.title}${g}${tg}${dl}`); + } + // console.log(task); + for (const t of task.tasks) { + out(t, `${indent} `); + } + } + for (const t of c.tasks) { + out(t, ti); + } + // components + const lng = c.components.length; + for (let i = 0; i < lng; i++) { + const lc = (i === lng - 1); + dump(c.components[i], indent + (last? ' ' : '│ '), lc); + } + } + dump(component, '', true); + } + } }); }) // diff --git a/src/app.js b/src/app.js index afa2ab6..b7f1feb 100644 --- a/src/app.js +++ b/src/app.js @@ -66,7 +66,7 @@ class App { } async ls(options) { - const {component, depth, who, filter, hierarchy} = options; + const {component, depth, who, filter} = options; let aees = [...who.assignees]; // // console.log(options, who.all || aees.length); @@ -84,7 +84,7 @@ class App { if (who.all || aees.length) { const c = await this.getCurrentComponent(component); if (c) { - await c.ls({depth, who: {...who, assignees: aees}, filter, hierarchy, indent: '', last: true}); + return await c.ls({depth, who: {...who, assignees: aees}, filter}); } } } diff --git a/src/component.js b/src/component.js index ee5db82..63f0a70 100644 --- a/src/component.js +++ b/src/component.js @@ -176,10 +176,16 @@ class Component { } async ls(options) { - const {depth, who, filter, hierarchy, indent, last} = options; + const {depth, who, filter} = options; const who2 = { ...who, assignees: await this.getAssignees(who.assignees)}; + // + const ts = { + id: this.id, + relativePath: this.getRelativePath(), + tasks: [], + components: [] + }; // tasks - const ts = { tasks: [] }; for (const task of this.tasks) { const t = await task.filter({who: who2, filter}); //console.log(t); @@ -188,39 +194,20 @@ class Component { } }; // - if (ts && ts.tasks.length) { - let ti = ' '; - if (hierarchy) { - ti = `${indent}${ti}`; - const title = (this.id) ? `${indent}${last?'└':'├'} ${this.id}` : ''; - const summary = '45%'; - this.logger.con(`${title} ${summary}`); - } else { - this.logger.con(); - this.logger.con(`~ ${this.getRelativePath()}`); - } - const out = (task, indent) => { - if (task.title) { - const a = task.assignees.length ? ` @(${task.assignees.join(',')})` : ''; - const tg = task.tags.length ? ` #(${task.tags.join(',')})` : ''; - const dl = task.deadline ? ` (${task.deadline})` : ''; - const id = task.id ? ` ${task.id}:` : ''; - this.logger.con(`${indent}${task.status}${id} ${task.title}${a}${tg}${dl}`); - } - for (const t of task.tasks) { - out(t, `${indent} `); - } - } - out(ts, ''); - } - // about components + // nested components if (depth) { const lng = this.components.length; for (let i = 0; i < lng; i++) { - const lc = (i === lng - 1); - await this.components[i].ls({depth: depth - 1, who: who2, filter, hierarchy, indent: indent + (last? ' ' : '│ '), last: lc}); + const cp = await this.components[i].ls({depth: depth - 1, who: who2, filter}); + if (cp) { + ts.components.push(cp); + } } } + // + if (ts.tasks.length || ts.components.length) { + return ts; + } } async describeProject(options) { diff --git a/src/server.js b/src/server.js index 924f15e..5a34103 100644 --- a/src/server.js +++ b/src/server.js @@ -58,6 +58,14 @@ class Server { root.getTeam(team, true, true) res.send(this.makeResponce(team)); }) + ea.get('/tasks', async(req, res) => { + res.send(this.makeResponce( await app.ls({ + component: null, + depth: 10, + who: { assignees: [], all: true }, + filter: { tag: [], search: [], deadline: [], status: { backlog: true, dev: true, done: false } } + }))); + }) ea.get('/srs', async(req, res) => { res.send(this.makeResponce( await app.describe({ what: { srs: true } }))); }) diff --git a/src/task.js b/src/task.js index 82e030f..91af29c 100644 --- a/src/task.js +++ b/src/task.js @@ -16,7 +16,7 @@ class Task { this.source = source; this.parent = parent; this.indent = indent; - this.status = '-'; + this.status = ''; this.id = null; this.title = ''; this.deadline = ''; @@ -55,7 +55,7 @@ class Task { this.links = links; } - async filter(options, alsoMe = false, alsoTags = false) { + async filter(options, alsoMe = false, alsoTags = false, statusToo = false) { //console.log('filter in', this.id); const {who, filter} = options; // check myself @@ -65,7 +65,7 @@ class Task { { statuses: ['-', '?', '!'], flag: filter.status.backlog }, { statuses: ['>'], flag: filter.status.dev }, { statuses: ['+', 'x'], flag: filter.status.done }, - ].find(v => v.flag && v.statuses.includes(this.status) ); + ].find(v => v.flag && v.statuses.includes(this.status) ) || statusToo; // const tags = [this.deadline].concat(this.tags); const tg = (filter.tag.length ? filter.tag.find( t => tags.includes(t) ) : true) || alsoTags; @@ -73,7 +73,11 @@ class Task { const sr = filter.search.length ? filter.search.find( s => this.title.indexOf(s) >= 0 ) : true; // // console.log(this.id, 'me', me, 'st', st, 'tg', tg, 'sr', sr); - const tasks = (await Promise.all(this.tasks.map(async t => t.filter(options, me, tg)))).filter(v => !!v); + const tasks = (await Promise.all(this.tasks.map(async t => t.filter(options, me, tg, st)))).filter(v => !!v); + let percentage = (this.status === '+' || this.status === 'x') ? 100 : 0; + if (this.tasks.length) { + percentage = Math.round(tasks.reduce((acc, t) => acc + t.percentage, 0) / tasks.length); + } //console.log(this.id, st, who.all, me, tg, sr, tasks.length); if (((who.all || me) && st && tg && sr) || tasks.length) { //console.log('filter out', this.id); @@ -81,6 +85,7 @@ class Task { status: this.status, id: this.id, title: this.title, + percentage, deadline: this.deadline, assignees: this.assignees, tags: this.tags, diff --git a/web/index.html b/web/index.html index 99825d9..56d4def 100644 --- a/web/index.html +++ b/web/index.html @@ -10,6 +10,7 @@ + diff --git a/web/main.js b/web/main.js index d9791be..8a24ffa 100644 --- a/web/main.js +++ b/web/main.js @@ -19,7 +19,7 @@ const colors = { component: 'gray', group: '#20C997', dev: '#007BFF', - backlog: '#FFA500', + todo: '#FFA500', tbd: '#FFDF58', blocked: '#DC3545', done: '#28A745', @@ -79,6 +79,46 @@ function getDeadlineDate(deadline) { ); return result; } + +function getMillisecondsFromDuration(duraction) { + if (duraction) { + const tb = { 'years': 31536000000, 'months': 2592000000, 'days': 86400000, 'hours': 3600000, 'minutes': 60000, 'seconds': 1000}; + return Object.keys(tb).reduce( + function(acc, key){ + if (duraction[key]) { + return acc + tb[key] * duraction[key]; + } + return acc; + }, + 0 + ); + } +} + +function getClosestRelease(timeline, format = ['years', 'months', 'days', 'hours']) { + let releaseName = 'n/a'; + let releaseDate = 'n/a'; + let timeToRelease = 'n/a'; + let releaseFeatures = 'n/a'; + if (timeline.length) { + let minDuration = Number.MAX_SAFE_INTEGER; + timeline.forEach(function(t) { + const duration = dateFns.fp.intervalToDuration({start: new Date(), end: new Date(t.deadline)}); + const ms = getMillisecondsFromDuration(duration); + if (ms > 0 && ms < minDuration) { + minDuration = ms; + releaseName = t.id; + releaseDate = t.deadline; + duration.minutes = null; + duration.seconds = null; + timeToRelease = dateFns.fp.formatDuration(duration, { format: ['months', 'weeks'], delimiter: ', ' }); + releaseFeatures = t.features; + } + }); + } + return {releaseName, releaseDate, timeToRelease, releaseFeatures}; + +}; //----------------------------------------------------------------------------- // Dashboard $("#dashboard-tab").click(function(){ @@ -91,14 +131,7 @@ function getProject(id, name, summary) { const diff = getStringFromInterval(lut); const lastUpdateTime = `Updated ${diff} ago`; // - let releaseName = 'n/a'; - let timeToRelease = 'n/a'; - if (summary.timeline.length) { - releaseName = summary.timeline[0].id; -// releaseDate = summary.timeline[0].date; - timeToRelease = getStringFromInterval(dateFns.fp.intervalToDuration({start: new Date(), end: new Date(summary.timeline[0].deadline)})); - } - + const { releaseName, timeToRelease } = getClosestRelease(summary.timeline); // const rd = dateFns.fp.intervalToDuration({start: new Date(summary.release.date), end: new Date() }); // console.log(rd); @@ -107,7 +140,7 @@ function getProject(id, name, summary) { '
' + '
' + '
' + - ` ${releaseName} in ${timeToRelease}` + + ` ${releaseName} in ${timeToRelease}` + `
${name} (${id})
` + '
' + '
' + @@ -131,26 +164,19 @@ function getProject(id, name, summary) { function getProjectDetails(description, summary) { const r = {}; // release - let releaseName = 'n/a'; - let releaseDate = 'n/a'; - let releaseFeatures = 'n/a'; - if (summary.timeline.length) { - releaseName = summary.timeline[0].id; - releaseDate = summary.timeline[0].deadline; - releaseFeatures = summary.timeline[0].features; - } + const { releaseName, releaseDate, releaseFeatures } = getClosestRelease(summary.timeline); + // r.html = '' + '
' + '
' + `
${description}
` + '
' + '
    ' + - '
  • Release
  • ' + - '
  • ' + - ` name${releaseName}` + + '
  • ' + + ` Release${releaseDate}` + '
  • ' + '
  • ' + - ` date${releaseDate}` + + ` name${releaseName}` + '
  • ' + '
  • ' + ` features${releaseFeatures}` + @@ -161,22 +187,22 @@ function getProjectDetails(description, summary) { '
  • ' + '
  • Tasks
  • ' + `
  • ` + - `
    dev
    ${summary.tasks.dev}` + + `
    dev
    ${summary.tasks.dev}` + '
  • ' + `
  • ` + - ` backlog${summary.tasks.todo}` + + ` todo${summary.tasks.todo}` + '
  • ' + `
  • ` + - ` tbd${summary.tasks.tbd}` + + ` tbd${summary.tasks.tbd}` + '
  • ' + `
  • ` + - ` blocked${summary.tasks.blocked}` + + ` blocked${summary.tasks.blocked}` + '
  • ' + '
  • ' + - ` done${summary.tasks.done}` + + ` done${summary.tasks.done}` + '
  • ' + '
  • ' + - ` dropped${summary.tasks.dropped}` + + ` dropped${summary.tasks.dropped}` + '
  • ' + '
' + '
'; @@ -230,7 +256,7 @@ function initDashboard() { datasets: [ { // backgroundColor: ["#3cba9f", "#3e95cd", "#8e5ea2"], - backgroundColor: [colors.rag.green, colors.rag.amber, colors.rag.red], + backgroundColor: [colors.timeline.dev, colors.timeline.todo, colors.timeline.blocked], data: [p.summary.tasks.dev,p.summary.tasks.todo,p.summary.tasks.tbd+p.summary.tasks.blocked] } ] @@ -295,18 +321,20 @@ function initDashboard() { }); p.charts = [tasksChart, worloadChart]; }); + updateDashboard(); } } }); - updateDashboard(); } function updateDashboard() { - projects.forEach(function(p) { - p.charts.forEach(function(c) { - c.update(); + if (projects) { + projects.forEach(function(p) { + p.charts.forEach(function(c) { + c.update(); + }); }); - }); + } } //----------------------------------------------------------------------------- @@ -366,8 +394,8 @@ function initTimeline() { function updateTimeline() { let data = []; - console.log(Date.UTC(2024, 10, 26)); - console.log((new Date("2024-11-26 16:00:00 UTC+0200")).getTime()); + // console.log(Date.UTC(2024, 10, 26)); + // console.log((new Date("2024-11-26 16:00:00 UTC+0200")).getTime()); // const updateInterval = function(cInterval, newInterval) { const result = {start: null, end: null}; @@ -389,7 +417,7 @@ function updateTimeline() { let cInterval = {start: null, end: null}; const cIndex = data.push({ id: id, - name: component.id.toUpperCase(), + name: component.name.toUpperCase(), start: (new Date("2024-11-26 9:00:00 UTC+0200")).getTime(), end: (new Date("2024-11-28 16:00:00 UTC+0200")).getTime(), color: colors.timeline.component, @@ -404,14 +432,14 @@ function updateTimeline() { const t = tasks[i]; // for (const t of tasks){ if (t.deadline) { - console.log(t.id, t.deadline, getDeadlineDate(t.deadline)); + //console.log(t.id, t.deadline, getDeadlineDate(t.deadline)); } const tId = parentId + '/' + (t.id ? t.id : index); // let tColor = colors.timeline.group; if (t.tasks.length === 0) { tColor = ({ - '-': colors.timeline.backlog, + '-': colors.timeline.todo, '>': colors.timeline.dev, '?': colors.timeline.tbd, '!': colors.timeline.blocked, @@ -421,7 +449,7 @@ function updateTimeline() { } tData.push({ id: tId, - name: t.status + ' ' + t.title, + name: t.title, start: (new Date("2024-11-26 16:00:00 UTC+0200")).getTime(), end: (new Date("2024-11-27 16:00:00 UTC+0200")).getTime(), color: tColor,