From 1ac77311fe916c91dee0b0d51c4c0f62afe13d56 Mon Sep 17 00:00:00 2001 From: vycdev2 Date: Fri, 18 Sep 2026 01:10:31 +0000 Subject: [PATCH] feat: add tag-scoped statistics --- CHANGELOG.md | 1 + README.md | 8 ++ src/utils/statistics-template.ts | 33 +++++ src/utils/statistics.ts | 68 ++++++++++- src/utils/statistics_tokens.ts | 34 ++++++ test/statistics-tags.spec.ts | 162 +++++++++++++++++++++++++ test/statistics-template.spec.ts | 15 +++ test/statistics-token-settings.spec.ts | 8 +- 8 files changed, 324 insertions(+), 5 deletions(-) create mode 100644 test/statistics-tags.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af930d..3c93f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ - Added optional whole-line background colors for configured special tags with `todo.colors.tags.lineBackground`. Fixes: https://github.com/vycdev/vscode-todo-plus-two/issues/93 - Added TextMate syntax scopes so Todo projects, standard statuses, tags, and formatting are visible in the editor minimap. Fixes: https://github.com/vycdev/vscode-todo-plus-two/issues/70 - Added a command to copy the project at the cursor with its rendered statistics included as text. Fixes: https://github.com/vycdev/vscode-todo-plus-two/issues/73 +- Added tag-scoped statistics tokens for todo counts, completion, estimates, and elapsed time, including AND/OR tag selectors. Fixes: https://github.com/vycdev/vscode-todo-plus-two/issues/81 - Fixed invalid statistics visibility expressions so they hide the affected statistics instead of interrupting extension updates. - Fixed the Due view so `todo.due.soonDays: 0` keeps future tasks out of the soon window. - Added a pending-task badge to the Todo activity bar icon on supported VS Code versions. Fixes: https://github.com/vycdev/vscode-todo-plus-two/issues/90 diff --git a/README.md b/README.md index b3d9f98..c5e81dc 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,14 @@ The following tokens can be used in `todo.statistics.project.text`, `todo.statis | `[wasted]` | Time wasted on task | | `[elapsed]` | Sum of `[lasted]` and `[wasted]` | +Tag-scoped tokens use `[tag:selector:metric]`. The metric can be any task or time token +from the table, and defaults to `all`, so `[tag:high]` counts todos tagged `@high` while +`[tag:high:elapsed]` reports their elapsed time. Join tag names with `&` to require every +tag or `|` to accept any tag: `[tag:frontend&high:pending]` and +`[tag:frontend|backend:done]`. `&` is evaluated before `|`, the leading `@` is optional, +and tag arguments do not affect matching (`@high(review)` matches `high`). These tokens +work in project text, status bar text and status bar tooltips. + ## Demo ### [Example todo file](https://github.com/vycdev/vscode-todo-plus-two/blob/master/resources/readme.todo) diff --git a/src/utils/statistics-template.ts b/src/utils/statistics-template.ts index 6f036a9..cad9c18 100644 --- a/src/utils/statistics-template.ts +++ b/src/utils/statistics-template.ts @@ -1,5 +1,24 @@ const timeTokens = new Set(['est', 'est-total', 'est-finished', 'lasted', 'wasted', 'elapsed']); +const tagMetrics = new Set([ + 'tags', + 'pending', + 'done', + 'cancelled', + 'finished', + 'all', + 'percentage', + 'est', + 'est-total', + 'lasted', + 'wasted', + 'elapsed', + 'est-finished', + 'est-finished-percentage', +]); + +const tagTokenRegex = /\[tag:([^\]:\s]+)(?::([a-z-]+))?\]/g; + const tokenRegexes: { [token: string]: RegExp } = {}; const getTokenRegex = (token: string): RegExp => { @@ -29,5 +48,19 @@ export const renderStatisticsTemplate = ( template = template.replace(regex, value); } + if (typeof tokens.getTagMetric === 'function') { + template = template.replace(tagTokenRegex, (placeholder, selector, requestedMetric) => { + const metric = requestedMetric || 'all'; + + if (!tagMetrics.has(metric)) return placeholder; + + let value = tokens.getTagMetric(selector, metric); + + if (timeTokens.has(metric) && value === '') value = '0s'; + + return value; + }); + } + return template; }; diff --git a/src/utils/statistics.ts b/src/utils/statistics.ts index f17dd68..444f1fa 100644 --- a/src/utils/statistics.ts +++ b/src/utils/statistics.ts @@ -114,6 +114,9 @@ const Statistics = { ]; // Global settings where tokens could be in use Statistics.tokens.updateDisabled(Statistics.tokens.disabled.global, tokens, globalSettings); + Statistics.tokens.disabled.global.tagged = !globalSettings.some((setting) => + _.includes(Config.getKey(setting), '[tag:') + ); const projectsSettings = ['statistics.project.enabled', 'statistics.project.text']; // Local settings where tokens could be in use @@ -122,6 +125,9 @@ const Statistics = { tokens, projectsSettings ); + Statistics.tokens.disabled.projects.tagged = !projectsSettings.some((setting) => + _.includes(Config.getKey(setting), '[tag:') + ); }, updateDisabled(obj, tokens: string[], settings: string[]) { @@ -137,6 +143,28 @@ const Statistics = { global: {}, + createTaggedTodo(item) { + const todo = new Tokens(); + + if (item instanceof TodoBox) { + todo.pending = 1; + } else if (item instanceof TodoDone) { + todo.done = 1; + } else if (item instanceof TodoCancelled) { + todo.cancelled = 1; + } else { + return; + } + + return todo; + }, + + addTaggedTodoTag(tag: Tag, todo: Tokens, disabledTokens) { + todo.tags++; + todo.tagNames.push(tag.text.replace(/^@/, '').replace(/\([^)]*\)$/, '')); + Statistics.timeTags.add(tag.text, todo, disabledTokens, todo.pending === 0); + }, + updateGlobal(items) { if (items.archive && Config.getKey('statistics.statusbar.ignoreArchive')) { // Keeping only items before the archive @@ -173,7 +201,8 @@ const Statistics = { const lines = getStatisticsLines(items); - let wasPending = false; + let wasPending = false, + taggedTodo: Tokens | undefined; for (let i = 0, l = lines.length; i < l; i++) { const nextItem: any = lines[i]; @@ -186,13 +215,30 @@ const Statistics = { Statistics.tokens.disabled.global, !wasPending ); + + if (taggedTodo) { + Statistics.tokens.addTaggedTodoTag( + nextItem, + taggedTodo, + Statistics.tokens.disabled.global + ); + } } else { + if (taggedTodo) tokens.taggedTodos.push(taggedTodo); + + taggedTodo = + Statistics.tokens.disabled.global.tagged === true + ? undefined + : Statistics.tokens.createTaggedTodo(nextItem); + // Only tags attached to a pending todo contribute remaining estimates. // Comments, projects and finished todos all end the pending context. wasPending = nextItem instanceof TodoBox; } } + if (taggedTodo) tokens.taggedTodos.push(taggedTodo); + Statistics.tokens.global = tokens; }, @@ -244,7 +290,8 @@ const Statistics = { includeRemainingDocument ); - let wasPending = false; + let wasPending = false, + taggedTodo: Tokens | undefined; for (let i = lineNr + 1; i < scopeEnd; i++) { const nextItem = lines[i]; @@ -258,7 +305,21 @@ const Statistics = { Statistics.tokens.disabled.projects, !wasPending ); + + if (taggedTodo) { + Statistics.tokens.addTaggedTodoTag( + nextItem, + taggedTodo, + Statistics.tokens.disabled.projects + ); + } } else { + if (taggedTodo) tokens.taggedTodos.push(taggedTodo); + + taggedTodo = + Statistics.tokens.disabled.projects.tagged === true + ? undefined + : Statistics.tokens.createTaggedTodo(nextItem); nextItem.level = nextItem.level || AST.getLevel(textDocument, nextItem.line.text); wasPending = nextItem instanceof TodoBox; @@ -282,6 +343,7 @@ const Statistics = { tokens.estTotalSeconds += nextTokens.estTotalSeconds; tokens.lastedSeconds += nextTokens.lastedSeconds; tokens.wastedSeconds += nextTokens.wastedSeconds; + tokens.taggedTodos.push(...nextTokens.taggedTodos); i += nextTokens.comments + @@ -303,6 +365,8 @@ const Statistics = { } } + if (taggedTodo) tokens.taggedTodos.push(taggedTodo); + Statistics.tokens.projects[project.lineNumber] = tokens; return tokens; diff --git a/src/utils/statistics_tokens.ts b/src/utils/statistics_tokens.ts index 3fd8137..cce0418 100644 --- a/src/utils/statistics_tokens.ts +++ b/src/utils/statistics_tokens.ts @@ -36,6 +36,40 @@ class StatisticsTokens { estTotalSeconds = 0; lastedSeconds = 0; wastedSeconds = 0; + tagNames: string[] = []; + taggedTodos: StatisticsTokens[] = []; + + getTagMetric(selector: string, metric = 'all') { + return this.getTagTokens(selector)[metric]; + } + + getTagTokens(selector: string) { + const clauses = selector.split('|').map((clause) => + clause + .split('&') + .map((tag) => tag.replace(/^@/, '')) + .filter(Boolean) + ), + matching = this.taggedTodos.filter((todo) => + clauses.some( + (clause) => clause.length && clause.every((tag) => todo.tagNames.indexOf(tag) >= 0) + ) + ), + tokens = new StatisticsTokens(); + + matching.forEach((todo) => { + tokens.tags += todo.tags; + tokens.pending += todo.pending; + tokens.done += todo.done; + tokens.cancelled += todo.cancelled; + tokens.estSeconds += todo.estSeconds; + tokens.estTotalSeconds += todo.estTotalSeconds; + tokens.lastedSeconds += todo.lastedSeconds; + tokens.wastedSeconds += todo.wastedSeconds; + }); + + return tokens; + } @memoize get finished() { diff --git a/test/statistics-tags.spec.ts b/test/statistics-tags.spec.ts new file mode 100644 index 0000000..ae68e6c --- /dev/null +++ b/test/statistics-tags.spec.ts @@ -0,0 +1,162 @@ +import { expect } from 'chai'; + +class Tag { + constructor( + public lineNumber: number, + public text: string + ) {} +} +class TodoBox { + line = { text: '' }; + level = 1; + + constructor(public lineNumber: number) {} +} +class TodoDone { + line = { text: '' }; + level = 1; + + constructor(public lineNumber: number) {} +} +class TodoCancelled { + line = { text: '' }; + level = 1; + + constructor(public lineNumber: number) {} +} +class Comment {} +class Project { + line = { text: 'Project:' }; + level = 0; + + constructor(public lineNumber: number) {} +} + +const loadSubjects = () => { + const NodeModule = require('module'), + originalLoad = NodeModule._load, + tokenPath = require.resolve('../src/utils/statistics_tokens'), + statisticsPath = require.resolve('../src/utils/statistics'), + previousTokens = require.cache[tokenPath], + previousStatistics = require.cache[statisticsPath], + config = { + getKey: (key: string) => { + if (key === 'timekeeping.estimate.format' || key === 'timekeeping.elapsed.format') { + return 'short-compact'; + } + if (key === 'hoursPerDay') return 24; + if (key === 'manHoursPerDay') return 8; + if (key === 'manDaysPerWeek') return 5; + return undefined; + }, + }; + + try { + NodeModule._load = (request, parent, isMain) => { + if (request === '../config') return { default: config }; + if (request === './time') return { default: { diff: (value) => String(value) } }; + return originalLoad(request, parent, isMain); + }; + delete require.cache[tokenPath]; + const Tokens = require(tokenPath).default; + + NodeModule._load = (request, parent, isMain) => { + if (request === 'vscode') return {}; + if (request === '../config') return { default: config }; + if (request === '../consts') return { default: { regexes: {} } }; + if (request === '../todo/items') { + return { Comment, Project, Tag, TodoBox, TodoDone, TodoCancelled }; + } + if (request === './ast') return { default: { getLevel: () => 0 } }; + if (request === './estimate') return { getEstimateDuration: () => undefined }; + if (request === './statistics-condition') return { evaluateStatisticsCondition: () => true }; + if (request === './statistics_tokens') return { default: Tokens }; + return originalLoad(request, parent, isMain); + }; + delete require.cache[statisticsPath]; + + return { Statistics: require(statisticsPath).default, Tokens }; + } finally { + NodeModule._load = originalLoad; + delete require.cache[tokenPath]; + delete require.cache[statisticsPath]; + if (previousTokens) require.cache[tokenPath] = previousTokens; + if (previousStatistics) require.cache[statisticsPath] = previousStatistics; + } +}; + +describe('Tag statistics', () => { + it('collects tag-scoped todo counts from the global statistics path', () => { + const { Statistics } = loadSubjects(), + items = { + archive: undefined, + comments: [], + projects: [], + todosBox: [new TodoBox(0)], + todosDone: [new TodoDone(1)], + todosCancelled: [new TodoCancelled(2)], + tags: [ + new Tag(0, '@high(review)'), + new Tag(0, '@frontend'), + new Tag(1, '@high'), + new Tag(1, '@backend'), + new Tag(2, '@low'), + new Tag(2, '@frontend'), + ], + }; + + Statistics.tokens.updateGlobal(items); + + expect(Statistics.tokens.global.getTagMetric('high', 'all')).to.equal(2); + expect(Statistics.tokens.global.getTagMetric('high', 'pending')).to.equal(1); + expect(Statistics.tokens.global.getTagMetric('high&frontend', 'all')).to.equal(1); + expect(Statistics.tokens.global.getTagMetric('backend|low', 'all')).to.equal(2); + expect(Statistics.tokens.global.getTagMetric('@frontend', 'cancelled')).to.equal(1); + }); + + it('aggregates time values for selected tags without double-counting OR matches', () => { + const { Tokens } = loadSubjects(), + first = new Tokens(), + second = new Tokens(), + root = new Tokens(); + + first.pending = 1; + first.tagNames = ['high', 'frontend']; + first.estSeconds = 60; + first.estTotalSeconds = 60; + first.lastedSeconds = 10; + + second.done = 1; + second.tagNames = ['high', 'backend']; + second.estTotalSeconds = 120; + second.lastedSeconds = 20; + + root.taggedTodos = [first, second]; + + const selected = root.getTagTokens('high|frontend'); + + expect(selected.all).to.equal(2); + expect(selected.estSeconds).to.equal(60); + expect(selected.estTotalSeconds).to.equal(180); + expect(selected.lastedSeconds).to.equal(30); + }); + + it('makes tag-scoped metrics available to project statistics', () => { + const { Statistics } = loadSubjects(), + project = new Project(0), + items = { + archive: undefined, + comments: [], + projects: [project], + todosBox: [new TodoBox(1)], + todosDone: [new TodoDone(2)], + todosCancelled: [], + tags: [new Tag(1, '@high'), new Tag(2, '@low')], + }; + + Statistics.tokens.updateProjects({} as any, items); + + expect(Statistics.tokens.projects[0].getTagMetric('high', 'pending')).to.equal(1); + expect(Statistics.tokens.projects[0].getTagMetric('low', 'done')).to.equal(1); + }); +}); diff --git a/test/statistics-template.spec.ts b/test/statistics-template.spec.ts index e3144e9..e3561c8 100644 --- a/test/statistics-template.spec.ts +++ b/test/statistics-template.spec.ts @@ -56,4 +56,19 @@ describe('Statistics template rendering', () => { '0s|[unknown]|0s' ); }); + + it('renders tag-scoped metrics and preserves invalid tag metrics', () => { + const tokens = { + getTagMetric: (selector: string, metric: string) => + selector === 'high&frontend' && metric === 'pending' ? 2 : '', + }; + + expect( + renderStatisticsTemplate( + '[tag:high&frontend:pending]|[tag:high:elapsed]|[tag:high:unknown]', + tokens, + [] + ) + ).to.equal('2|0s|[tag:high:unknown]'); + }); }); diff --git a/test/statistics-token-settings.spec.ts b/test/statistics-token-settings.spec.ts index ea3ffeb..32dd49a 100644 --- a/test/statistics-token-settings.spec.ts +++ b/test/statistics-token-settings.spec.ts @@ -31,10 +31,12 @@ describe('Statistics derived token settings', () => { expect(statistics.tokens.disabled.global.lasted).to.equal(false); expect(statistics.tokens.disabled.global.wasted).to.equal(false); expect(statistics.tokens.disabled.global.est).to.equal(true); + expect(statistics.tokens.disabled.global.tagged).to.equal(true); - settings['statistics.statusbar.text'] = '[pending]'; + settings['statistics.statusbar.text'] = '[tag:high:elapsed]'; statistics.tokens.updateDisabledAll(); - expect(statistics.tokens.disabled.global.lasted).to.equal(true); - expect(statistics.tokens.disabled.global.wasted).to.equal(true); + expect(statistics.tokens.disabled.global.lasted).to.equal(false); + expect(statistics.tokens.disabled.global.wasted).to.equal(false); + expect(statistics.tokens.disabled.global.tagged).to.equal(false); }); });