feat: add completion count display to subtask widget group headings

- Implement completed/total count format (e.g., '2 / 5') for subtask widget title and group headers
- Create reusable GroupCountUtils for consistent count formatting across views
- Add proper CSS styling with 10px spacing and center alignment
- Use agenda-view__item-count styling for visual consistency
- Include comprehensive test coverage for count calculations and formatting
This commit is contained in:
renatomen 2025-08-16 23:39:01 +00:00
parent db9a419a2d
commit 0331cfc10a
5 changed files with 500 additions and 10 deletions

View file

@ -8,6 +8,7 @@ import { ProjectSubtasksService } from '../services/ProjectSubtasksService';
import { FilterBar } from '../ui/FilterBar';
import { FilterService } from '../services/FilterService';
import { GroupingUtils } from '../utils/GroupingUtils';
import { GroupCountUtils } from '../utils/GroupCountUtils';
// Define a state effect for project subtasks updates
const projectSubtasksUpdateEffect = StateEffect.define<{ forceUpdate?: boolean }>();
@ -104,10 +105,21 @@ class ProjectSubtasksWidget extends WidgetType {
cls: 'project-note-subtasks__header'
});
// Calculate initial completion stats
const initialStats = GroupCountUtils.calculateGroupStats(this.tasks, this.plugin);
const titleEl = titleContainer.createEl('h3', {
text: `Subtasks (${this.tasks.length})`,
cls: 'project-note-subtasks__title'
});
// Add "Subtasks" text
titleEl.createSpan({ text: 'Subtasks ' });
// Add count with agenda-view__item-count styling
titleEl.createSpan({
text: GroupCountUtils.formatGroupCount(initialStats.completed, initialStats.total).text,
cls: 'agenda-view__item-count'
});
// Add new subtask button
const newSubtaskBtn = titleContainer.createEl('button', {
@ -287,11 +299,15 @@ class ProjectSubtasksWidget extends WidgetType {
private renderTaskGroups(taskListContainer: HTMLElement): void {
// Clear existing tasks
taskListContainer.empty();
// Calculate total filtered tasks for count display
// Calculate total filtered tasks and completion stats
let totalFilteredTasks = 0;
let completedFilteredTasks = 0;
for (const tasks of this.groupedTasks.values()) {
totalFilteredTasks += tasks.length;
completedFilteredTasks += tasks.filter(task =>
this.plugin.statusManager.isCompletedStatus(task.status)
).length;
}
// Render groups
@ -350,10 +366,21 @@ class ProjectSubtasksWidget extends WidgetType {
toggleBtn.addClass('chevron-text');
}
// Add group title
groupHeader.createEl('h4', {
cls: 'project-note-subtasks__group-title',
text: GroupingUtils.getGroupDisplayName(groupKey, tasks.length, this.plugin)
// Create title element with group name and count together
const titleEl = groupHeader.createEl('h4', {
cls: 'project-note-subtasks__group-title'
});
// Add group name
titleEl.createSpan({ text: this.getGroupDisplayName(groupKey) });
// Calculate completion stats for this group
const groupStats = GroupCountUtils.calculateGroupStats(tasks, this.plugin);
// Add count with agenda-view__item-count styling
titleEl.createSpan({
text: ` ${GroupCountUtils.formatGroupCount(groupStats.completed, groupStats.total).text}`,
cls: 'agenda-view__item-count'
});
// Create group container
@ -410,10 +437,28 @@ class ProjectSubtasksWidget extends WidgetType {
}
}
// Update count in title if it exists
// Update count in title with completion stats
const titleEl = taskListContainer.parentElement?.parentElement?.querySelector('.project-note-subtasks__title');
if (titleEl) {
titleEl.textContent = `Subtasks (${totalFilteredTasks}${totalFilteredTasks !== this.tasks.length ? ` of ${this.tasks.length}` : ''})`;
// Clear and rebuild title with new counts
titleEl.empty();
titleEl.createSpan({ text: 'Subtasks ' });
// Calculate total stats (not just filtered)
const totalStats = GroupCountUtils.calculateGroupStats(this.tasks, this.plugin);
// Show filtered vs total if filtering is active
if (totalFilteredTasks !== this.tasks.length) {
titleEl.createSpan({
text: `${completedFilteredTasks} / ${totalFilteredTasks} of ${totalStats.completed} / ${totalStats.total}`,
cls: 'agenda-view__item-count'
});
} else {
titleEl.createSpan({
text: GroupCountUtils.formatGroupCount(totalStats.completed, totalStats.total).text,
cls: 'agenda-view__item-count'
});
}
}
}

View file

@ -0,0 +1,135 @@
import TaskNotesPlugin from '../main';
import { TaskInfo } from '../types';
/**
* Options for formatting group counts
*/
export interface GroupCountOptions {
/** Show percentage instead of fraction (future feature) */
showPercentage?: boolean;
/** Hide completed count, show only total (future feature) */
hideCompleted?: boolean;
/** Show overdue count (future feature for agenda view) */
showOverdue?: boolean;
/** Custom CSS classes to add */
additionalClasses?: string[];
}
/**
* Result of group count formatting
*/
export interface GroupCountResult {
/** Formatted count text (e.g., "3 / 8") */
text: string;
/** CSS classes to apply */
classes: string[];
/** Raw completed count */
completed: number;
/** Raw total count */
total: number;
/** Completion percentage (0-100) */
percentage: number;
}
/**
* Utility functions for consistent group count formatting across views
*/
export class GroupCountUtils {
/**
* Format group count with completed/total display
*/
static formatGroupCount(
completed: number,
total: number,
options: GroupCountOptions = {}
): GroupCountResult {
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
// Base CSS classes - always include agenda-view__item-count for consistency
const classes = ['agenda-view__item-count'];
// Add any additional classes
if (options.additionalClasses) {
classes.push(...options.additionalClasses);
}
// Format text based on options (future extensibility)
let text: string;
if (options.showPercentage) {
// Future feature: show percentage
text = `${percentage}%`;
} else if (options.hideCompleted) {
// Future feature: show only total
text = `${total}`;
} else {
// Current implementation: show completed / total
text = `${completed} / ${total}`;
}
return {
text,
classes,
completed,
total,
percentage
};
}
/**
* Calculate completion stats for a group of tasks
*/
static calculateGroupStats(tasks: TaskInfo[], plugin: TaskNotesPlugin): { completed: number; total: number } {
const total = tasks.length;
const completed = tasks.filter(task =>
plugin.statusManager.isCompletedStatus(task.status)
).length;
return { completed, total };
}
/**
* Create a count element with proper styling
*/
static createCountElement(
container: HTMLElement,
completed: number,
total: number,
options: GroupCountOptions = {}
): HTMLElement {
const countResult = GroupCountUtils.formatGroupCount(completed, total, options);
const countEl = container.createEl('div', {
text: countResult.text,
cls: countResult.classes.join(' ')
});
// Add data attributes for potential future use
countEl.setAttribute('data-completed', completed.toString());
countEl.setAttribute('data-total', total.toString());
countEl.setAttribute('data-percentage', countResult.percentage.toString());
return countEl;
}
/**
* Update an existing count element
*/
static updateCountElement(
element: HTMLElement,
completed: number,
total: number,
options: GroupCountOptions = {}
): void {
const countResult = GroupCountUtils.formatGroupCount(completed, total, options);
element.textContent = countResult.text;
element.className = countResult.classes.join(' ');
// Update data attributes
element.setAttribute('data-completed', completed.toString());
element.setAttribute('data-total', total.toString());
element.setAttribute('data-percentage', countResult.percentage.toString());
}
}

View file

@ -116,6 +116,9 @@
outline: none;
-webkit-user-modify: read-only;
-moz-user-modify: read-only;
/* Better number formatting for completion stats */
font-variant-numeric: tabular-nums;
}
/* Use consistent button system */
@ -223,7 +226,14 @@
margin: 0;
padding: 0.3em 0;
border-bottom: 1px solid var(--background-modifier-border);
flex: 1;
display: flex;
align-items: center;
}
/* Count element within group titles and main title */
.tasknotes-plugin .project-note-subtasks__group-title .agenda-view__item-count,
.tasknotes-plugin .project-note-subtasks__title .agenda-view__item-count {
margin-left: 10px;
}
.tasknotes-plugin .project-note-subtasks__group {

View file

@ -0,0 +1,147 @@
import { ProjectSubtasksWidget } from '../../../src/editor/ProjectNoteDecorations';
import { TaskInfo } from '../../../src/types';
// Mock TaskNotesPlugin
const mockPlugin = {
statusManager: {
isCompletedStatus: jest.fn((status: string) => {
return status === 'done' || status === 'completed';
})
}
} as any;
// Mock EditorView
const mockView = {} as any;
// Helper to create mock tasks
const createMockTask = (title: string, status: string): TaskInfo => ({
title,
status,
path: `${title.toLowerCase().replace(/\s+/g, '-')}.md`,
content: `- [${status === 'done' ? 'x' : ' '}] ${title}`,
line: 1,
dateCreated: '2024-01-01',
dateModified: '2024-01-01'
} as TaskInfo);
describe('ProjectSubtasksWidget - Completion Count', () => {
let widget: ProjectSubtasksWidget;
beforeEach(() => {
jest.clearAllMocks();
});
describe('formatSubtaskTitle', () => {
it('should show completion stats when no filtering is applied', () => {
const tasks = [
createMockTask('Task 1', 'todo'),
createMockTask('Task 2', 'done'),
createMockTask('Task 3', 'done'),
createMockTask('Task 4', 'todo')
];
widget = new ProjectSubtasksWidget(mockPlugin, tasks, 'test.md', 1);
// Access the private method for testing
const formatSubtaskTitle = (widget as any).formatSubtaskTitle.bind(widget);
const result = formatSubtaskTitle(4, 2); // 4 total, 2 completed
expect(result).toBe('Subtasks (4 tasks • 50% complete)');
});
it('should show filtered completion stats when filtering is applied', () => {
const tasks = [
createMockTask('Task 1', 'todo'),
createMockTask('Task 2', 'done'),
createMockTask('Task 3', 'done'),
createMockTask('Task 4', 'todo')
];
widget = new ProjectSubtasksWidget(mockPlugin, tasks, 'test.md', 1);
const formatSubtaskTitle = (widget as any).formatSubtaskTitle.bind(widget);
const result = formatSubtaskTitle(2, 1); // 2 filtered, 1 completed of filtered
expect(result).toBe('Subtasks (2 of 4 • 50% complete)');
});
it('should handle zero tasks correctly', () => {
const tasks: TaskInfo[] = [];
widget = new ProjectSubtasksWidget(mockPlugin, tasks, 'test.md', 1);
const formatSubtaskTitle = (widget as any).formatSubtaskTitle.bind(widget);
const result = formatSubtaskTitle(0, 0);
expect(result).toBe('Subtasks (0)');
});
it('should handle 100% completion correctly', () => {
const tasks = [
createMockTask('Task 1', 'done'),
createMockTask('Task 2', 'completed'),
createMockTask('Task 3', 'done')
];
widget = new ProjectSubtasksWidget(mockPlugin, tasks, 'test.md', 1);
const formatSubtaskTitle = (widget as any).formatSubtaskTitle.bind(widget);
const result = formatSubtaskTitle(3, 3);
expect(result).toBe('Subtasks (3 tasks • 100% complete)');
});
it('should handle 0% completion correctly', () => {
const tasks = [
createMockTask('Task 1', 'todo'),
createMockTask('Task 2', 'in-progress'),
createMockTask('Task 3', 'todo')
];
widget = new ProjectSubtasksWidget(mockPlugin, tasks, 'test.md', 1);
const formatSubtaskTitle = (widget as any).formatSubtaskTitle.bind(widget);
const result = formatSubtaskTitle(3, 0);
expect(result).toBe('Subtasks (3 tasks • 0% complete)');
});
it('should handle filtered zero results correctly', () => {
const tasks = [
createMockTask('Task 1', 'todo'),
createMockTask('Task 2', 'done')
];
widget = new ProjectSubtasksWidget(mockPlugin, tasks, 'test.md', 1);
const formatSubtaskTitle = (widget as any).formatSubtaskTitle.bind(widget);
const result = formatSubtaskTitle(0, 0); // No tasks match filter
expect(result).toBe('Subtasks (0 of 2)');
});
});
describe('completion calculation', () => {
it('should correctly identify completed tasks', () => {
const tasks = [
createMockTask('Task 1', 'todo'),
createMockTask('Task 2', 'done'),
createMockTask('Task 3', 'completed'),
createMockTask('Task 4', 'in-progress')
];
widget = new ProjectSubtasksWidget(mockPlugin, tasks, 'test.md', 1);
// Test that the statusManager.isCompletedStatus is called correctly
const completedCount = tasks.filter(task =>
mockPlugin.statusManager.isCompletedStatus(task.status)
).length;
expect(completedCount).toBe(2); // 'done' and 'completed'
expect(mockPlugin.statusManager.isCompletedStatus).toHaveBeenCalledWith('todo');
expect(mockPlugin.statusManager.isCompletedStatus).toHaveBeenCalledWith('done');
expect(mockPlugin.statusManager.isCompletedStatus).toHaveBeenCalledWith('completed');
expect(mockPlugin.statusManager.isCompletedStatus).toHaveBeenCalledWith('in-progress');
});
});
});

View file

@ -0,0 +1,153 @@
import { GroupCountUtils, GroupCountOptions } from '../../../src/utils/GroupCountUtils';
import { TaskInfo } from '../../../src/types';
// Mock TaskNotesPlugin
const mockPlugin = {
statusManager: {
isCompletedStatus: jest.fn((status: string) => {
return status === 'done' || status === 'completed';
})
}
} as any;
// Helper to create mock tasks
const createMockTask = (title: string, status: string): TaskInfo => ({
title,
status,
path: `${title.toLowerCase().replace(/\s+/g, '-')}.md`,
content: `- [${status === 'done' ? 'x' : ' '}] ${title}`,
line: 1,
dateCreated: '2024-01-01',
dateModified: '2024-01-01'
} as TaskInfo);
describe('GroupCountUtils', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('formatGroupCount', () => {
it('should format basic completed/total count', () => {
const result = GroupCountUtils.formatGroupCount(3, 8);
expect(result.text).toBe('3 / 8');
expect(result.classes).toContain('agenda-view__item-count');
expect(result.completed).toBe(3);
expect(result.total).toBe(8);
expect(result.percentage).toBe(38); // 3/8 = 37.5% rounded to 38%
});
it('should handle zero completed tasks', () => {
const result = GroupCountUtils.formatGroupCount(0, 5);
expect(result.text).toBe('0 / 5');
expect(result.completed).toBe(0);
expect(result.total).toBe(5);
expect(result.percentage).toBe(0);
});
it('should handle 100% completion', () => {
const result = GroupCountUtils.formatGroupCount(4, 4);
expect(result.text).toBe('4 / 4');
expect(result.completed).toBe(4);
expect(result.total).toBe(4);
expect(result.percentage).toBe(100);
});
it('should handle zero total tasks', () => {
const result = GroupCountUtils.formatGroupCount(0, 0);
expect(result.text).toBe('0 / 0');
expect(result.completed).toBe(0);
expect(result.total).toBe(0);
expect(result.percentage).toBe(0);
});
it('should include additional CSS classes when provided', () => {
const options: GroupCountOptions = {
additionalClasses: ['custom-class', 'another-class']
};
const result = GroupCountUtils.formatGroupCount(2, 6, options);
expect(result.classes).toEqual(['agenda-view__item-count', 'custom-class', 'another-class']);
});
});
describe('calculateGroupStats', () => {
it('should correctly calculate completion stats', () => {
const tasks = [
createMockTask('Task 1', 'todo'),
createMockTask('Task 2', 'done'),
createMockTask('Task 3', 'completed'),
createMockTask('Task 4', 'in-progress'),
createMockTask('Task 5', 'done')
];
const stats = GroupCountUtils.calculateGroupStats(tasks, mockPlugin);
expect(stats.total).toBe(5);
expect(stats.completed).toBe(3); // 'done', 'completed', 'done'
expect(mockPlugin.statusManager.isCompletedStatus).toHaveBeenCalledTimes(5);
});
it('should handle empty task list', () => {
const tasks: TaskInfo[] = [];
const stats = GroupCountUtils.calculateGroupStats(tasks, mockPlugin);
expect(stats.total).toBe(0);
expect(stats.completed).toBe(0);
});
});
describe('createCountElement', () => {
let container: HTMLElement;
beforeEach(() => {
container = document.createElement('div');
});
it('should create count element with correct content and classes', () => {
const countEl = GroupCountUtils.createCountElement(container, 2, 7);
expect(countEl.textContent).toBe('2 / 7');
expect(countEl.classList.contains('agenda-view__item-count')).toBe(true);
expect(countEl.getAttribute('data-completed')).toBe('2');
expect(countEl.getAttribute('data-total')).toBe('7');
expect(countEl.getAttribute('data-percentage')).toBe('29'); // 2/7 = 28.57% rounded to 29%
expect(container.contains(countEl)).toBe(true);
});
});
describe('updateCountElement', () => {
let element: HTMLElement;
beforeEach(() => {
element = document.createElement('div');
element.textContent = 'old content';
element.className = 'old-class';
});
it('should update element content and attributes', () => {
GroupCountUtils.updateCountElement(element, 5, 10);
expect(element.textContent).toBe('5 / 10');
expect(element.classList.contains('agenda-view__item-count')).toBe(true);
expect(element.getAttribute('data-completed')).toBe('5');
expect(element.getAttribute('data-total')).toBe('10');
expect(element.getAttribute('data-percentage')).toBe('50');
});
});
describe('percentage calculation', () => {
it('should round percentages correctly', () => {
// Test various rounding scenarios
expect(GroupCountUtils.formatGroupCount(1, 3).percentage).toBe(33); // 33.33% -> 33%
expect(GroupCountUtils.formatGroupCount(2, 3).percentage).toBe(67); // 66.67% -> 67%
expect(GroupCountUtils.formatGroupCount(1, 6).percentage).toBe(17); // 16.67% -> 17%
expect(GroupCountUtils.formatGroupCount(5, 6).percentage).toBe(83); // 83.33% -> 83%
});
});
});