feat: add checklist progress task-card property with Bases support

This commit is contained in:
callumalpass 2026-02-17 20:02:45 +11:00
parent ae3cd41151
commit 69b8ae2506
23 changed files with 459 additions and 216 deletions

View file

@ -29,6 +29,13 @@ Example:
- [Mdbase](https://mdbase.dev) type generation now emits `tn_role` annotations on schema fields, allowing external tools (e.g. [mtn CLI](https://github.com/callumalpass/mdbase-tasknotes)) to discover each field's semantic role regardless of custom frontmatter names
- Mdbase type match rules now use tag or frontmatter property matching (based on task identification settings) instead of path glob, with automatic fallback to tag matching
- Mdbase type status field now includes `tn_completed_values` annotation, listing which status values count as completed
- (#1576) Added an optional `Checklist Progress` task-card property that renders a compact progress bar and `completed/total` count from top-level markdown checkboxes
- Uses Obsidian `metadataCache.listItems` parsing (no vault body reads), minimizing rendering overhead
- Nested checklist items are excluded so progress reflects first-level task steps only
- Bases views map `tasks` (`file.tasks`) to `Checklist Progress` for TaskNotes cards
- Existing `.base` files need `file.tasks` added to the view `order` YAML manually (it is not auto-added to pickers by default)
- Default generated `.base` templates now include `file.tasks` in view `order` arrays so checklist progress is available out of the box and then appears in the picker
- Thanks to @phortx for the initial PR and for opening #1576
## Changed

View file

@ -10,6 +10,8 @@ These settings control the visual appearance of the plugin, including the calend
Use **Default visible properties** to decide what metadata appears on task cards without opening each task. This is the primary control for card density.
Checklist progress is available as a visible property in task cards. In Bases view `order` arrays, the corresponding source property is `file.tasks` (shown as `tasks` in Bases property pickers once present in the view `order` list).
## Display Formatting
Use **Time format** to switch between 12-hour and 24-hour display across all TaskNotes surfaces.

View file

@ -179,6 +179,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
sort:
- property: due
direction: ASC
@ -224,6 +225,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
groupBy:
property: status
direction: ASC
@ -264,6 +266,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
sort:
- column: due
direction: ASC
@ -299,6 +302,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
sort:
- column: formula.urgencyScore
direction: DESC
@ -332,6 +336,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
sort:
- column: formula.urgencyScore
direction: DESC
@ -363,6 +368,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
sort:
- column: formula.urgencyScore
direction: DESC
@ -400,6 +406,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
sort:
- column: formula.urgencyScore
direction: DESC
@ -432,6 +439,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
sort:
- column: status
direction: ASC
@ -466,6 +474,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
options:
showScheduled: true
showDue: true
@ -512,6 +521,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
options:
showPropertyBasedEvents: false
calendarView: "listWeek"
@ -559,6 +569,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
groupBy:
property: status
direction: ASC
@ -579,6 +590,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
- type: tasknotesTaskList
name: "Blocked By"
filters:
@ -597,6 +609,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
- type: tasknotesKanban
name: "Blocking"
filters:
@ -615,6 +628,7 @@ views:
- file.name
- recurrence
- complete_instances
- file.tasks
groupBy:
property: status
direction: ASC
@ -629,6 +643,7 @@ If you've customized your TaskNotes settings (e.g., renamed properties, added cu
- **Custom priorities**: The `priorityWeight` formula will include all your configured priorities with their weights
- **Property-based identification**: If you identify tasks by a property instead of a tag, the filters will use that property
- **Custom visible properties**: The `order` arrays will include your configured visible properties
- **Essential card properties**: `file.name`, recurrence, `complete_instances`, and `file.tasks` are always included in generated `order` arrays for TaskNotes card rendering
After major settings changes, regenerate default files and diff against customized versions to merge template updates.
## Related

View file

@ -54,6 +54,9 @@ Each swimlane row includes:
Each task card displays information based on the visible properties configured in the Bases view. Standard task information includes title, priority, due date, and scheduled date.
To show checklist progress on cards, include `file.tasks` in the view `order` array.
For existing `.base` files, add this in YAML manually first; after it is in `order`, it appears in the Bases picker as `tasks`.
Click a card to open the task file for editing. Right-click to access the context menu for task actions. Drag cards between columns or swimlane cells to update the task's properties.
## Column Operations

View file

@ -56,7 +56,8 @@ views:
**`name`**: Display name shown in the view header
**`order`**: Array of property names that control which task properties are visible in the task cards. Properties are referenced using their Bases property paths (e.g., `note.status`, `note.priority`, `note.due`).
**`order`**: Array of property names that control which task properties are visible in the task cards. Properties are referenced using their Bases property paths (e.g., `note.status`, `note.priority`, `note.due`, `file.tasks`).
For existing `.base` files, add `file.tasks` in YAML manually; once present in `order`, it appears in the Bases picker as `tasks`.
**`sort`**: Array of sort criteria. Tasks are sorted by the first criterion, with ties broken by subsequent criteria.
- `column`: Property to sort by (e.g., `due`, `scheduled`, `priority`, `title`)
@ -88,6 +89,7 @@ TaskNotes properties are accessed in Bases YAML using these paths:
| Projects | `note.projects` | Associated projects |
| Contexts | `note.contexts` | Task contexts |
| Tags | `file.tags` | File tags |
| Checklist progress | `file.tasks` | First-level markdown checkbox progress shown as the TaskNotes checklist progress bar |
| Time estimate | `note.timeEstimate` | Estimated duration |
| Recurrence | `note.recurrence` | Recurrence pattern |
| Blocked by | `note.blockedBy` | Blocking dependencies |

View file

@ -24,9 +24,10 @@ import type { FrontmatterPropertyName, TaskCardPropertyId } from "../types";
* 4. Return: "complete_instances" (NOT "completeInstances")
* 5. TaskCard uses PROPERTY_EXTRACTORS["complete_instances"]
*
* Special transformations (for computed properties):
* - "timeEntries" "totalTrackedTime" (show total instead of raw array)
* - "blockedBy" "blocked" (show status pill instead of array)
* Special transformations (for computed properties):
* - "timeEntries" "totalTrackedTime" (show total instead of raw array)
* - "blockedBy" "blocked" (show status pill instead of array)
* - "file.tasks" / "formula.checklistProgress" "checklistProgress"
*/
export class PropertyMappingService {
constructor(
@ -90,20 +91,22 @@ export class PropertyMappingService {
return this.applySpecialTransformations(stripped);
}
if (basesPropertyId.startsWith("file.")) {
// Map specific file properties to TaskInfo equivalents
if (basesPropertyId === "file.ctime") return "dateCreated";
if (basesPropertyId === "file.mtime") return "dateModified";
if (basesPropertyId.startsWith("file.")) {
// Map specific file properties to TaskInfo equivalents
if (basesPropertyId === "file.ctime") return "dateCreated";
if (basesPropertyId === "file.mtime") return "dateModified";
if (basesPropertyId === "file.tasks") return "checklistProgress";
// Keep file.* prefix for computed file properties (backlinks, links, etc.)
// This distinguishes them from note.* properties with the same name
return basesPropertyId;
}
// Keep file.* prefix for computed file properties (backlinks, links, etc.)
// This distinguishes them from note.* properties with the same name
return basesPropertyId;
}
// Step 3: Keep formula properties unchanged
if (basesPropertyId.startsWith("formula.")) {
return basesPropertyId;
}
// Step 3: Keep formula properties unchanged
if (basesPropertyId.startsWith("formula.")) {
if (basesPropertyId === "formula.checklistProgress") return "checklistProgress";
return basesPropertyId;
}
// Step 4: Direct property (no prefix) - apply special transformations
return this.applySpecialTransformations(basesPropertyId);
@ -150,16 +153,21 @@ export class PropertyMappingService {
* These are display-only transformations that don't affect data storage.
* Transforms calculated properties to their display representations.
*/
private applySpecialTransformations(propId: string): string {
// timeEntries → totalTrackedTime (show computed total instead of raw array)
if (propId === "timeEntries") return "totalTrackedTime";
// blockedBy → blocked (show status pill instead of dependency array)
if (propId === "blockedBy") return "blocked";
// Keep everything else unchanged
return propId;
}
private applySpecialTransformations(propId: string): string {
// timeEntries → totalTrackedTime (show computed total instead of raw array)
if (propId === "timeEntries") return "totalTrackedTime";
// blockedBy → blocked (show status pill instead of dependency array)
if (propId === "blockedBy") return "blocked";
// Keep only explicit Bases-facing checklist progress aliases.
// "Tasks" (file.tasks) is selectable in Bases UI.
if (propId === "file.tasks") return "checklistProgress";
if (propId === "formula.checklistProgress") return "checklistProgress";
// Keep everything else unchanged
return propId;
}
/**
* Alias for basesToTaskCardProperty() for backward compatibility.

View file

@ -56,11 +56,14 @@ export function mapBasesPropertyToTaskCardProperty(
* Transformations:
* - timeEntries totalTrackedTime (show computed total instead of raw array)
* - blockedBy blocked (show status pill instead of dependency list)
* - file.tasks/formula.checklistProgress checklistProgress
* - All other properties pass through unchanged
*/
function applySpecialTransformations(propId: string): string {
if (propId === "timeEntries") return "totalTrackedTime";
if (propId === "blockedBy") return "blocked";
if (propId === "file.tasks") return "checklistProgress";
if (propId === "formula.checklistProgress") return "checklistProgress";
return propId;
}

View file

@ -1187,6 +1187,7 @@ export const de: TranslationTree = {
scheduled: "Planungsdatum",
timeEstimate: "Zeitschätzung",
totalTrackedTime: "Gesamte erfasste Zeit",
checklistProgress: "Checklist Progress",
recurrence: "Wiederholung",
completedDate: "Abschlussdatum",
createdDate: "Erstellungsdatum",
@ -3040,6 +3041,7 @@ export const de: TranslationTree = {
scheduledDate: "Planungsdatum",
timeEstimate: "Zeitschätzung",
totalTrackedTime: "Gesamte erfasste Zeit",
checklistProgress: "Checklist Progress",
recurrence: "Wiederholung",
completedDate: "Abschlussdatum",
createdDate: "Erstellungsdatum",

View file

@ -1212,6 +1212,7 @@ export const en: TranslationTree = {
scheduled: "Scheduled Date",
timeEstimate: "Time Estimate",
totalTrackedTime: "Total Tracked Time",
checklistProgress: "Checklist Progress",
recurrence: "Recurrence",
completedDate: "Completed Date",
createdDate: "Created Date",
@ -3110,6 +3111,7 @@ export const en: TranslationTree = {
scheduledDate: "Scheduled Date",
timeEstimate: "Time Estimate",
totalTrackedTime: "Total Tracked Time",
checklistProgress: "Checklist Progress",
recurrence: "Recurrence",
completedDate: "Completed Date",
createdDate: "Created Date",

View file

@ -1187,6 +1187,7 @@ export const es: TranslationTree = {
scheduled: "Fecha programada",
timeEstimate: "Estimación de tiempo",
totalTrackedTime: "Tiempo total rastreado",
checklistProgress: "Checklist Progress",
recurrence: "Recurrencia",
completedDate: "Fecha de finalización",
createdDate: "Fecha de creación",
@ -3040,6 +3041,7 @@ export const es: TranslationTree = {
scheduledDate: "Fecha programada",
timeEstimate: "Estimación de tiempo",
totalTrackedTime: "Tiempo total rastreado",
checklistProgress: "Checklist Progress",
recurrence: "Recurrencia",
completedDate: "Fecha de finalización",
createdDate: "Fecha de creación",

View file

@ -1187,6 +1187,7 @@ export const fr: TranslationTree = {
scheduled: "Date planifiée",
timeEstimate: "Estimation de temps",
totalTrackedTime: "Temps suivi total",
checklistProgress: "Checklist Progress",
recurrence: "Récurrence",
completedDate: "Date d'achèvement",
createdDate: "Date de création",
@ -3040,6 +3041,7 @@ export const fr: TranslationTree = {
scheduledDate: "Date planifiée",
timeEstimate: "Estimation de temps",
totalTrackedTime: "Temps suivi total",
checklistProgress: "Checklist Progress",
recurrence: "Récurrence",
completedDate: "Date d'achèvement",
createdDate: "Date de création",

View file

@ -1187,6 +1187,7 @@ export const ja: TranslationTree = {
scheduled: "予定日",
timeEstimate: "時間見積もり",
totalTrackedTime: "総追跡時間",
checklistProgress: "Checklist Progress",
recurrence: "繰り返し",
completedDate: "完了日",
createdDate: "作成日",
@ -3040,6 +3041,7 @@ export const ja: TranslationTree = {
scheduledDate: "予定日",
timeEstimate: "時間見積もり",
totalTrackedTime: "総追跡時間",
checklistProgress: "Checklist Progress",
recurrence: "繰り返し",
completedDate: "完了日",
createdDate: "作成日",

View file

@ -1149,6 +1149,7 @@ export const ko: TranslationTree = {
scheduled: "예정일",
timeEstimate: "시간 예상",
totalTrackedTime: "총 기록 시간",
checklistProgress: "Checklist Progress",
recurrence: "반복",
completedDate: "완료일",
createdDate: "생성일",
@ -2990,6 +2991,7 @@ export const ko: TranslationTree = {
scheduledDate: "예정일",
timeEstimate: "시간 예상",
totalTrackedTime: "총 기록 시간",
checklistProgress: "Checklist Progress",
recurrence: "반복",
completedDate: "완료일",
createdDate: "생성일",

View file

@ -1190,6 +1190,7 @@ export const pt: TranslationTree = {
scheduled: "Data Agendada",
timeEstimate: "Estimativa de Tempo",
totalTrackedTime: "Tempo Total Registrado",
checklistProgress: "Checklist Progress",
recurrence: "Recorrência",
completedDate: "Data de Conclusão",
createdDate: "Data de Criação",
@ -3054,6 +3055,7 @@ export const pt: TranslationTree = {
scheduledDate: "Data Agendada",
timeEstimate: "Estimativa de Tempo",
totalTrackedTime: "Tempo Total Registrado",
checklistProgress: "Checklist Progress",
recurrence: "Recorrência",
completedDate: "Data de Conclusão",
createdDate: "Data de Criação",

View file

@ -1187,6 +1187,7 @@ export const ru: TranslationTree = {
scheduled: "Запланированная дата",
timeEstimate: "Оценка времени",
totalTrackedTime: "Общее отслеженное время",
checklistProgress: "Checklist Progress",
recurrence: "Повторение",
completedDate: "Дата завершения",
createdDate: "Дата создания",
@ -3040,6 +3041,7 @@ export const ru: TranslationTree = {
scheduledDate: "Запланированная дата",
timeEstimate: "Оценка времени",
totalTrackedTime: "Общее отслеженное время",
checklistProgress: "Checklist Progress",
recurrence: "Повторение",
completedDate: "Дата завершения",
createdDate: "Дата создания",

View file

@ -1187,6 +1187,7 @@ export const zh: TranslationTree = {
scheduled: "安排日期",
timeEstimate: "时间估计",
totalTrackedTime: "总跟踪时间",
checklistProgress: "Checklist Progress",
recurrence: "重复",
completedDate: "完成日期",
createdDate: "创建日期",
@ -3040,6 +3041,7 @@ export const zh: TranslationTree = {
scheduledDate: "安排日期",
timeEstimate: "时间估计",
totalTrackedTime: "总跟踪时间",
checklistProgress: "Checklist Progress",
recurrence: "重复",
completedDate: "完成日期",
createdDate: "创建日期",

View file

@ -108,6 +108,10 @@ function mapPropertyToBasesProperty(property: string, plugin: TaskNotesPlugin):
case "totalTrackedTime":
// totalTrackedTime is computed from timeEntries, use the timeEntries property
return fm.toUserField("timeEntries");
case "checklistProgress":
// checklistProgress is computed from markdown checklist items.
// Use file.tasks as the selectable Bases source property.
return "file.tasks";
}
// Try to map using FieldMapper
@ -145,6 +149,7 @@ function generateOrderArray(plugin: TaskNotesPlugin): string[] {
"file.name", // title
mapPropertyToBasesProperty("recurrence", plugin),
mapPropertyToBasesProperty("complete_instances", plugin),
mapPropertyToBasesProperty("checklistProgress", plugin),
].filter((prop): prop is string => !!prop);
// Combine, removing duplicates while preserving order

View file

@ -176,6 +176,13 @@ export class PropertyVisibilityDropdown {
),
category: "core" as const,
},
{
id: "checklistProgress",
name: this.plugin.i18n.translate(
"components.propertyVisibilityDropdown.properties.checklistProgress"
),
category: "core" as const,
},
{
id: "recurrence",
name: this.plugin.i18n.translate(

View file

@ -1,5 +1,5 @@
/* eslint-disable no-console */
import { TFile, setIcon, Notice, Modal, App, setTooltip, parseLinktext, Menu } from "obsidian";
import { TFile, setIcon, Notice, Modal, App, setTooltip, parseLinktext, Menu, type CachedMetadata } from "obsidian";
import { TaskInfo } from "../types";
import TaskNotesPlugin from "../main";
import { TaskContextMenu } from "../components/TaskContextMenu";
@ -454,6 +454,63 @@ function getDefaultVisibleProperties(plugin: TaskNotesPlugin): string[] {
return convertInternalToUserProperties(internalDefaults, plugin);
}
interface ChecklistProgress {
completed: number;
total: number;
percent: number;
}
/**
* Get checklist progress from Obsidian's metadata cache listItems.
* Uses parsed metadata only (no vault body reads) for better performance.
*/
function getChecklistProgress(taskPath: string, plugin: TaskNotesPlugin): ChecklistProgress | null {
const file = plugin.app.vault.getAbstractFileByPath(taskPath);
if (!(file instanceof TFile)) return null;
const fileCache = plugin.app.metadataCache.getFileCache(file);
return calculateChecklistProgress(fileCache);
}
/**
* Calculate first-level checklist progress from cached list items.
* Only top-level task list items are counted (nested subtasks are ignored).
*/
function calculateChecklistProgress(cache: CachedMetadata | null): ChecklistProgress | null {
const listItems = cache?.listItems;
if (!Array.isArray(listItems) || listItems.length === 0) {
return null;
}
let total = 0;
let completed = 0;
for (const item of listItems) {
if (!item || typeof item.task !== "string") continue;
// Obsidian uses parent >= 0 for nested list items.
const isNested = typeof item.parent === "number" && item.parent >= 0;
if (isNested) continue;
total += 1;
// Count only explicit checked boxes as complete ([x] / [X]).
// Other task markers (e.g. [-], [>]) are treated as not complete.
if (item.task.toLowerCase() === "x") {
completed += 1;
}
}
if (total === 0) {
return null;
}
return {
completed,
total,
percent: Math.round((completed / total) * 100),
};
}
/**
* Property value extractors for better type safety and error handling
*/
@ -479,6 +536,7 @@ const PROPERTY_EXTRACTORS: Record<string, (task: TaskInfo) => any> = {
dateCreated: (task) => task.dateCreated,
dateModified: (task) => task.dateModified,
googleCalendarSync: (task) => task.path, // Used to check if task is synced via plugin settings
checklistProgress: (task) => task.path, // Used to compute checklist progress from metadata cache listItems
};
/**
@ -921,6 +979,29 @@ const PROPERTY_RENDERERS: Record<string, PropertyRenderer> = {
element.textContent = `Linked to ${value.length} calendar ${value.length === 1 ? "event" : "events"}`;
}
},
checklistProgress: (element, _value, task, plugin) => {
const progress = getChecklistProgress(task.path, plugin);
if (!progress) {
return;
}
const progressEl = element.createEl("span", { cls: "task-card__progress" });
const progressBar = progressEl.createEl("span", { cls: "task-card__progress-bar" });
const progressFill = progressBar.createEl("span", { cls: "task-card__progress-fill" });
progressFill.style.width = `${progress.percent}%`;
if (progress.percent > 0 && progress.percent < 5) {
progressFill.style.minWidth = "2px";
}
progressEl.createEl("span", {
cls: "task-card__progress-label",
text: `${progress.completed}/${progress.total}`,
});
setTooltip(progressEl, `${progress.percent}% complete (${progress.completed}/${progress.total})`, {
placement: "top",
});
},
};
/**

View file

@ -31,6 +31,7 @@ export function getAvailableProperties(
{ id: "scheduled", label: makeLabel("Scheduled Date", "scheduled") },
{ id: "timeEstimate", label: makeLabel("Time Estimate", "timeEstimate") },
{ id: "totalTrackedTime", label: "Total Tracked Time" }, // Computed property, not in FieldMapping
{ id: "checklistProgress", label: "Checklist Progress" }, // Computed from metadata cache listItems
{ id: "recurrence", label: makeLabel("Recurrence", "recurrence") },
{ id: "completeInstances", label: makeLabel("Completed Instances", "completeInstances") },
{ id: "skippedInstances", label: makeLabel("Skipped Instances", "skippedInstances") },

View file

@ -170,6 +170,43 @@
height: 12px;
}
/* Checklist progress metadata */
.tasknotes-plugin .task-card__metadata-property--checklistProgress {
display: inline-flex;
align-items: center;
gap: 6px;
}
.tasknotes-plugin .task-card__progress {
display: inline-flex;
align-items: center;
gap: 6px;
}
.tasknotes-plugin .task-card__progress-bar {
width: 72px;
height: 6px;
background: color-mix(in srgb, var(--background-modifier-border) 70%, transparent);
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
}
.tasknotes-plugin .task-card__progress-fill {
display: block;
height: 100%;
width: 0;
background: var(--interactive-accent);
border-radius: inherit;
transition: width var(--tn-transition-fast);
}
.tasknotes-plugin .task-card__progress-label {
font-size: var(--tn-font-size-sm);
font-variant-numeric: tabular-nums;
color: var(--tn-text-muted);
}
.tasknotes-plugin .task-card__tag {
background: var(--tn-bg-secondary);
@ -1254,6 +1291,23 @@
vertical-align: baseline;
}
.tasknotes-plugin .task-card--layout-inline .task-card__metadata-property--checklistProgress {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tasknotes-plugin .task-card--layout-inline .task-card__progress {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tasknotes-plugin .task-card--layout-inline .task-card__progress-bar {
width: 42px;
height: 4px;
}
/* Metadata pills in inline mode */
.tasknotes-plugin .task-card--layout-inline .task-card__metadata-pill {
display: inline;

View file

@ -0,0 +1,26 @@
import { PropertyMappingService } from "../../../src/bases/PropertyMappingService";
describe("Issue #1576 - Bases checklist progress aliases", () => {
const fieldMapperStub = {
isRecognizedProperty: jest.fn(() => false),
getMapping: jest.fn(() => ({})),
toUserField: jest.fn((key: string) => key),
fromUserField: jest.fn(() => null),
};
const mapper = new PropertyMappingService({} as any, fieldMapperStub as any);
it("maps file.tasks to checklistProgress", () => {
expect(mapper.basesToTaskCardProperty("file.tasks")).toBe("checklistProgress");
});
it("maps formula.checklistProgress to checklistProgress", () => {
expect(mapper.basesToTaskCardProperty("formula.checklistProgress")).toBe("checklistProgress");
});
it("does not map note/task tasks aliases", () => {
expect(mapper.basesToTaskCardProperty("tasks")).toBe("tasks");
expect(mapper.basesToTaskCardProperty("note.tasks")).toBe("tasks");
expect(mapper.basesToTaskCardProperty("task.tasks")).toBe("tasks");
});
});

View file

@ -1,211 +1,222 @@
/**
* Skipped reproduction tests for Issue #1576:
* [FR]: Display Progress Bar
*
* Feature Description:
* Inspired by Trello, display a small progress bar on each task card showing
* the completion progress of first-level checkboxes in the task's details.
*
* For example, if a task's `details` field contains:
* - [x] Buy groceries
* - [ ] Clean the house
* - [x] Walk the dog
* - [ ] Read a book
*
* The progress bar should show 50% (2 of 4 completed).
*
* Key Implementation Points:
* 1. Parse `task.details` for markdown checkboxes (- [ ] and - [x])
* 2. Only count first-level checkboxes (not nested/indented ones)
* 3. Calculate completion percentage
* 4. Render a progress bar element on the task card (after title, before/in metadata)
* 5. Show text like "2/4" alongside the bar
* 6. Only show the progress bar when checkboxes exist in details
*
* Relevant Source Files:
* - src/ui/TaskCard.ts (createTaskCard, updateTaskCard)
* - src/types.ts (TaskInfo.details field)
* - src/utils/TasksPluginParser.ts (CHECKBOX_PATTERN regex)
* - styles/task-card-bem.css (BEM card styles)
*
* Suggested BEM Classes:
* - .task-card__progress (container)
* - .task-card__progress-bar (the bar track)
* - .task-card__progress-fill (the filled portion)
* - .task-card__progress-label (text like "2/4")
*/
import { createTaskCard, updateTaskCard } from "../../../src/ui/TaskCard";
import { TaskFactory } from "../../helpers/mock-factories";
import { App, MockObsidian } from "../../__mocks__/obsidian";
import { describe, it, expect } from '@jest/globals';
jest.mock("obsidian");
/**
* Helper to count first-level markdown checkboxes in a string.
* First-level means no leading whitespace (or minimal, non-nested indentation).
* Returns { total, completed }.
*/
function countCheckboxes(text: string): { total: number; completed: number } {
const lines = text.split('\n');
let total = 0;
let completed = 0;
jest.mock("../../../src/utils/helpers", () => ({
calculateTotalTimeSpent: jest.fn(() => 0),
getEffectiveTaskStatus: jest.fn((task) => task.status || "open"),
shouldUseRecurringTaskUI: jest.fn(() => false),
getRecurringTaskCompletionText: jest.fn(() => "Not completed for this date"),
getRecurrenceDisplayText: jest.fn(() => "Daily"),
filterEmptyProjects: jest.fn((projects) => projects?.filter((p: string) => p && p.trim()) || []),
}));
for (const line of lines) {
// First-level checkboxes: no leading whitespace (or at most the list marker indent)
const match = line.match(/^(?:[-*+]|\d+\.)\s+\[([ xX])\]/);
if (match) {
total++;
if (match[1] === 'x' || match[1] === 'X') {
completed++;
}
jest.mock("../../../src/utils/dateUtils", () => ({
isTodayTimeAware: jest.fn(() => false),
isOverdueTimeAware: jest.fn(() => false),
formatDateTimeForDisplay: jest.fn(() => "Jan 15"),
getDatePart: jest.fn(() => ""),
getTimePart: jest.fn(() => null),
formatDateForStorage: jest.fn((value: Date | string) => {
if (value instanceof Date) {
return value.toISOString().split("T")[0];
}
return value?.split("T")[0] || "";
}),
}));
jest.mock("../../../src/components/TaskContextMenu", () => ({
TaskContextMenu: jest.fn().mockImplementation(() => ({
show: jest.fn(),
})),
}));
describe("Issue #1576 - Display Progress Bar on task cards", () => {
let app: App;
let plugin: any;
beforeEach(() => {
jest.clearAllMocks();
MockObsidian.reset();
app = new App();
plugin = {
app,
fieldMapper: {
lookupMappingKey: jest.fn((propertyId: string) => {
const mapped = new Set(["status", "priority", "due", "scheduled", "contexts", "projects"]);
return mapped.has(propertyId) ? propertyId : null;
}),
isPropertyForField: jest.fn((propertyId: string, field: string) => propertyId === field),
toUserField: jest.fn((field: string) => field),
getMapping: jest.fn(() => ({
status: "status",
priority: "priority",
due: "due",
scheduled: "scheduled",
contexts: "contexts",
projects: "projects",
})),
},
statusManager: {
isCompletedStatus: jest.fn((status: string) => status === "done"),
getStatusConfig: jest.fn((status: string) => ({
value: status,
label: status,
color: "#666666",
})),
getNextStatus: jest.fn(() => "done"),
getCompletedStatuses: jest.fn(() => ["done"]),
},
priorityManager: {
getPriorityConfig: jest.fn((priority: string) => ({
value: priority,
label: priority,
color: "#ff0000",
})),
},
getActiveTimeSession: jest.fn(() => null),
cacheManager: {
getTaskInfo: jest.fn(),
},
updateTaskProperty: jest.fn(),
getTaskByPath: jest.fn(),
projectSubtasksService: {
isTaskUsedAsProject: jest.fn().mockResolvedValue(false),
isTaskUsedAsProjectSync: jest.fn().mockReturnValue(false),
},
i18n: {
translate: jest.fn((key: string) => key),
},
settings: {
singleClickAction: "edit",
doubleClickAction: "none",
showExpandableSubtasks: true,
subtaskChevronPosition: "right",
hideCompletedFromOverdue: true,
calendarViewSettings: {
timeFormat: "24",
},
},
};
jest.spyOn(console, "error").mockImplementation(() => {});
jest.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
function listItem(taskChar: string, parent: number, line: number) {
return {
task: taskChar,
parent,
position: {
start: { line, col: 0, offset: 0 },
end: { line, col: 10, offset: 10 },
},
};
}
return { total, completed };
}
describe('Issue #1576: Display Progress Bar on task cards', () => {
describe('checkbox counting logic', () => {
it.skip('reproduces issue #1576 - should count first-level checkboxes in task details', () => {
const details = [
'- [x] Buy groceries',
'- [ ] Clean the house',
'- [x] Walk the dog',
'- [ ] Read a book',
].join('\n');
const result = countCheckboxes(details);
expect(result.total).toBe(4);
expect(result.completed).toBe(2);
it("renders checklist progress and ignores nested checkboxes", () => {
const task = TaskFactory.createTask({
path: "tasks/progress-task.md",
title: "Checklist task",
});
it.skip('reproduces issue #1576 - should ignore nested checkboxes (only first-level)', () => {
const details = [
'- [x] Main task 1',
' - [x] Subtask 1a',
' - [ ] Subtask 1b',
'- [ ] Main task 2',
' - [x] Deeply nested',
].join('\n');
// Only first-level items should be counted (Main task 1, Main task 2)
const result = countCheckboxes(details);
expect(result.total).toBe(2);
expect(result.completed).toBe(1);
MockObsidian.createTestFile(task.path, "# Checklist task");
app.metadataCache.setCache(task.path, {
frontmatter: { title: task.title },
listItems: [
listItem("x", -1, 10), // top-level complete
listItem(" ", -1, 11), // top-level incomplete
listItem("x", 10, 12), // nested complete (ignored)
listItem(" ", 11, 13), // nested incomplete (ignored)
],
});
it.skip('reproduces issue #1576 - should return zero counts when no checkboxes exist', () => {
const details = 'This is a plain text description with no checkboxes.';
const card = createTaskCard(task, plugin, ["checklistProgress"]);
const result = countCheckboxes(details);
expect(result.total).toBe(0);
expect(result.completed).toBe(0);
});
it.skip('reproduces issue #1576 - should handle all checkboxes completed', () => {
const details = [
'- [x] Task A',
'- [X] Task B',
'- [x] Task C',
].join('\n');
const result = countCheckboxes(details);
expect(result.total).toBe(3);
expect(result.completed).toBe(3);
});
it.skip('reproduces issue #1576 - should handle numbered list checkboxes', () => {
const details = [
'1. [x] First item',
'2. [ ] Second item',
'3. [x] Third item',
].join('\n');
const result = countCheckboxes(details);
expect(result.total).toBe(3);
expect(result.completed).toBe(2);
});
it.skip('reproduces issue #1576 - should handle empty details', () => {
const result = countCheckboxes('');
expect(result.total).toBe(0);
expect(result.completed).toBe(0);
});
const progressEl = card.querySelector(".task-card__progress");
expect(progressEl).not.toBeNull();
expect(card.querySelector(".task-card__progress-label")?.textContent).toBe("1/2");
expect((card.querySelector(".task-card__progress-fill") as HTMLElement).style.width).toBe("50%");
});
describe('progress bar rendering on task card', () => {
it.skip('reproduces issue #1576 - task card should include a progress bar when details contain checkboxes', () => {
// When createTaskCard() is called with a task that has checkboxes in details,
// the card DOM should contain a .task-card__progress element.
//
// Expected DOM structure:
// <div class="task-card__progress">
// <div class="task-card__progress-bar">
// <div class="task-card__progress-fill" style="width: 50%"></div>
// </div>
// <span class="task-card__progress-label">2/4</span>
// </div>
//
// const task = TaskFactory.createTask({
// details: '- [x] Done\n- [ ] Not done\n- [x] Also done\n- [ ] Pending'
// });
// const card = createTaskCard(task, mockPlugin);
// const progressEl = card.querySelector('.task-card__progress');
// expect(progressEl).not.toBeNull();
// const fill = card.querySelector('.task-card__progress-fill') as HTMLElement;
// expect(fill.style.width).toBe('50%');
// const label = card.querySelector('.task-card__progress-label');
// expect(label?.textContent).toBe('2/4');
it("does not render checklist progress when only nested checkboxes exist", () => {
const task = TaskFactory.createTask({
path: "tasks/nested-only-task.md",
title: "Nested only",
});
it.skip('reproduces issue #1576 - task card should NOT show progress bar when details have no checkboxes', () => {
// When task.details contains no checkboxes, no progress bar should be rendered.
//
// const task = TaskFactory.createTask({
// details: 'Just some notes about the task.'
// });
// const card = createTaskCard(task, mockPlugin);
// const progressEl = card.querySelector('.task-card__progress');
// expect(progressEl).toBeNull();
MockObsidian.createTestFile(task.path, "# Nested only");
app.metadataCache.setCache(task.path, {
frontmatter: { title: task.title },
listItems: [
listItem("x", 5, 10),
listItem(" ", 5, 11),
],
});
it.skip('reproduces issue #1576 - task card should NOT show progress bar when details are undefined', () => {
// When task.details is undefined, no progress bar should be rendered.
//
// const task = TaskFactory.createTask({ details: undefined });
// const card = createTaskCard(task, mockPlugin);
// const progressEl = card.querySelector('.task-card__progress');
// expect(progressEl).toBeNull();
});
const card = createTaskCard(task, plugin, ["checklistProgress"]);
it.skip('reproduces issue #1576 - progress bar should show 100% when all checkboxes are completed', () => {
// When all checkboxes are checked, the progress fill should be at 100%.
//
// const task = TaskFactory.createTask({
// details: '- [x] Task A\n- [x] Task B\n- [x] Task C'
// });
// const card = createTaskCard(task, mockPlugin);
// const fill = card.querySelector('.task-card__progress-fill') as HTMLElement;
// expect(fill.style.width).toBe('100%');
// const label = card.querySelector('.task-card__progress-label');
// expect(label?.textContent).toBe('3/3');
});
expect(card.querySelector(".task-card__progress")).toBeNull();
const metadata = card.querySelector(".task-card__metadata") as HTMLElement;
expect(metadata.style.display).toBe("none");
});
describe('progress bar CSS', () => {
it.skip('reproduces issue #1576 - task-card-bem.css should contain progress bar styles', () => {
// The CSS should include styles for:
// - .task-card__progress (container, flex row, aligned)
// - .task-card__progress-bar (track, background, border-radius, height)
// - .task-card__progress-fill (fill, transition, themed color)
// - .task-card__progress-label (small font, muted color)
//
// const fs = require('fs');
// const path = require('path');
// const cssPath = path.resolve(__dirname, '../../../styles/task-card-bem.css');
// const css = fs.readFileSync(cssPath, 'utf-8');
// expect(css).toContain('.task-card__progress');
// expect(css).toContain('.task-card__progress-bar');
// expect(css).toContain('.task-card__progress-fill');
// expect(css).toContain('.task-card__progress-label');
it("updates checklist progress after metadata cache changes", () => {
const task = TaskFactory.createTask({
path: "tasks/update-progress-task.md",
title: "Update progress",
});
MockObsidian.createTestFile(task.path, "# Update progress");
app.metadataCache.setCache(task.path, {
frontmatter: { title: task.title },
listItems: [
listItem("x", -1, 10),
listItem(" ", -1, 11),
],
});
const card = createTaskCard(task, plugin, ["checklistProgress"]);
expect(card.querySelector(".task-card__progress-label")?.textContent).toBe("1/2");
app.metadataCache.setCache(task.path, {
frontmatter: { title: task.title },
listItems: [
listItem("x", -1, 10),
listItem("x", -1, 11),
],
});
updateTaskCard(card, task, plugin, ["checklistProgress"]);
expect(card.querySelector(".task-card__progress-label")?.textContent).toBe("2/2");
expect((card.querySelector(".task-card__progress-fill") as HTMLElement).style.width).toBe("100%");
});
it("treats non-x task markers as incomplete", () => {
const task = TaskFactory.createTask({
path: "tasks/custom-marker-task.md",
title: "Custom marker",
});
MockObsidian.createTestFile(task.path, "# Custom marker");
app.metadataCache.setCache(task.path, {
frontmatter: { title: task.title },
listItems: [
listItem("-", -1, 10), // not completed
listItem("x", -1, 11), // completed
],
});
const card = createTaskCard(task, plugin, ["checklistProgress"]);
expect(card.querySelector(".task-card__progress-label")?.textContent).toBe("1/2");
expect((card.querySelector(".task-card__progress-fill") as HTMLElement).style.width).toBe("50%");
});
});