From b4a4f494a1493ee15ccbedfd6d8514fae44c258f Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 12:32:20 +0100 Subject: [PATCH 1/9] feat: progress bar for task cards --- src/bases/BasesDataAdapter.ts | 14 +- src/bases/BasesViewBase.ts | 69 +++++ src/bases/PropertyMappingService.ts | 4 + src/bases/TaskListView.ts | 3 +- src/bases/helpers.ts | 4 +- src/i18n/resources/de.ts | 23 ++ src/i18n/resources/en.ts | 23 ++ src/main.ts | 15 + src/services/ProgressService.ts | 120 ++++++++ src/settings/defaults.ts | 9 + src/settings/tabs/appearanceTab.ts | 98 +++++++ src/types.ts | 10 + src/types/settings.ts | 13 + src/ui/TaskCard.ts | 266 +++++++++++++++++ src/utils/helpers.ts | 22 +- src/utils/propertyHelpers.ts | 1 + src/views/PomodoroView.ts | 9 + src/views/StatsView.ts | 9 + styles/task-card-bem.css | 55 +++- tests/unit/services/ProgressService.test.ts | 306 ++++++++++++++++++++ 20 files changed, 1064 insertions(+), 9 deletions(-) create mode 100644 src/services/ProgressService.ts create mode 100644 tests/unit/services/ProgressService.test.ts diff --git a/src/bases/BasesDataAdapter.ts b/src/bases/BasesDataAdapter.ts index 52339ee2..d49db1f8 100644 --- a/src/bases/BasesDataAdapter.ts +++ b/src/bases/BasesDataAdapter.ts @@ -206,14 +206,20 @@ export class BasesDataAdapter { * Extract properties from a BasesEntry. * Extracts frontmatter and basic file properties only (cheap operations). * Computed file properties (backlinks, links, etc.) are fetched lazily via getComputedProperty(). + * + * Also adds virtual computed property 'task.progress' to entry.frontmatter so Bases can discover it. */ private extractEntryProperties(entry: any): Record { - // Extract all properties from the entry's frontmatter - // We don't filter by visible properties here - that happens during rendering - // This ensures all properties are available for TaskInfo creation const frontmatter = (entry as any).frontmatter || (entry as any).properties || {}; - // Start with frontmatter properties + // Add virtual computed property 'task.progress' to entry.frontmatter so Bases discovers it + if (entry && !frontmatter['task.progress']) { + if (!entry.frontmatter) { + entry.frontmatter = {}; + } + entry.frontmatter['task.progress'] = null; + } + const result = { ...frontmatter }; // Also extract file properties directly from the TFile object (these are cheap - no getValue calls) diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 8335025f..d351048c 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -40,6 +40,9 @@ export abstract class BasesViewBase extends Component { protected selectionModeCleanup: (() => void) | null = null; protected selectionIndicatorEl: HTMLElement | null = null; + // Signature cache for task cards (used by TaskListView, can be accessed by subclasses) + protected lastTaskSignatures?: Map; + constructor(controller: any, containerEl: HTMLElement, plugin: TaskNotesPlugin) { // Call Component constructor super(); @@ -62,6 +65,10 @@ export abstract class BasesViewBase extends Component { * Override from Component base class. */ onload(): void { + // Register computed properties like 'progress' to Bases query properties + // so they appear in the property selector + this.registerComputedProperties(); + this.setupContainer(); this.setupTaskUpdateListener(); this.setupSelectionHandling(); @@ -69,6 +76,52 @@ export abstract class BasesViewBase extends Component { this.render(); } + /** + * Register computed properties (like 'task.progress') to Bases query properties + * so they appear in the property selector dropdown. + * Called during onload() and can be called again during render() if query becomes available later. + */ + protected registerComputedProperties(): void { + try { + // Access Bases query through multiple possible paths + const query = + (this.data as any)?.query || + (this as any).controller?.query || + (this as any).query; + + if (!query) { + return; + } + + // Ensure properties map exists + if (!query.properties) { + query.properties = {}; + } + + const propsMap = query.properties as Record; + + // Register 'task.progress' as a computed property if not already present + if (!propsMap['task.progress']) { + const progressProperty = { + getDisplayName: () => { + try { + return this.plugin.i18n.translate('settings.appearance.taskCards.properties.progress') || 'Progress'; + } catch { + return 'Progress'; + } + }, + getType: () => 'text', + getValue: () => null, + isComputed: () => true, + }; + + propsMap['task.progress'] = progressProperty; + } + } catch (error) { + // Silently fail - property registration is optional + } + } + /** * BasesView lifecycle: Called when Bases data changes. * Required abstract method implementation. @@ -312,12 +365,28 @@ export abstract class BasesViewBase extends Component { } }); + // Listen for settings changes (e.g., progress bar display mode) + const settingsChangeListener = this.plugin.emitter.on("settings-changed", () => { + // Skip if view is not visible + if (!this.rootElement?.isConnected) return; + // Clear signature cache to force all task cards to be recreated with new settings + // This ensures that settings changes (like progress bar display mode) are immediately visible + if (this.lastTaskSignatures) { + this.lastTaskSignatures.clear(); + } + // Refresh view to apply new settings + this.debouncedRefresh(); + }); + // Register cleanup using Component lifecycle this.register(() => { if (this.taskUpdateListener) { this.plugin.emitter.offref(this.taskUpdateListener); this.taskUpdateListener = null; } + if (settingsChangeListener) { + this.plugin.emitter.offref(settingsChangeListener); + } }); } diff --git a/src/bases/PropertyMappingService.ts b/src/bases/PropertyMappingService.ts index 00fd53d0..5509a4e7 100644 --- a/src/bases/PropertyMappingService.ts +++ b/src/bases/PropertyMappingService.ts @@ -157,6 +157,10 @@ export class PropertyMappingService { // blockedBy → blocked (show status pill instead of dependency array) if (propId === "blockedBy") return "blocked"; + // progress → progress (explicitly handle progress as computed property) + // Support both 'progress' and 'task.progress' (standard form for TaskNotes computed properties) + if (propId === "progress" || propId === "task.progress") return "progress"; + // Keep everything else unchanged return propId; } diff --git a/src/bases/TaskListView.ts b/src/bases/TaskListView.ts index 73f1cd0a..54ae047d 100644 --- a/src/bases/TaskListView.ts +++ b/src/bases/TaskListView.ts @@ -21,7 +21,8 @@ export class TaskListView extends BasesViewBase { private currentTaskElements = new Map(); private lastRenderWasGrouped = false; private lastFlatPaths: string[] = []; - private lastTaskSignatures = new Map(); + // Signature cache - declared in BasesViewBase but initialized here for type safety + protected lastTaskSignatures = new Map(); private taskInfoCache = new Map(); private clickTimeouts = new Map(); private currentTargetDate = createUTCDateFromLocalCalendarDate(new Date()); diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index 18bb287f..616481bc 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -40,7 +40,8 @@ export function mapBasesPropertyToTaskCardProperty( ): string { // Delegate to PropertyMappingService if available (preferred path) if (plugin) { - // Import PropertyMappingService inline to avoid circular dependencies + // Use dynamic import to avoid circular dependencies + // eslint-disable-next-line @typescript-eslint/no-require-imports const { PropertyMappingService } = require("./PropertyMappingService"); const mapper = new PropertyMappingService(plugin, plugin.fieldMapper); return mapper.basesToTaskCardProperty(propId); @@ -61,6 +62,7 @@ export function mapBasesPropertyToTaskCardProperty( function applySpecialTransformations(propId: string): string { if (propId === "timeEntries") return "totalTrackedTime"; if (propId === "blockedBy") return "blocked"; + if (propId === "progress") return "progress"; // Explicitly handle progress return propId; } diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index b0d65e27..9ad93f69 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -1192,6 +1192,28 @@ export const de: TranslationTree = { tags: "Tags", blocked: "Blockiert", blocking: "Blockierend", + progress: "Fortschritt", + }, + progressBar: { + header: "Fortschrittsbalken", + description: "Konfiguriere, wie der Fortschrittsbalken auf Aufgabenkarten angezeigt wird.", + displayMode: { + name: "Anzeigemodus", + description: "Wie der Fortschrittsbalken angezeigt werden soll", + options: { + barOnly: "Nur Balken", + textOnly: "Nur Text", + barWithText: "Balken mit Text", + }, + }, + showCount: { + name: "Anzahl anzeigen", + description: "Anzahl der abgeschlossenen Checkboxen anzeigen (z.B. '2/5')", + }, + showPercentage: { + name: "Prozentzahl anzeigen", + description: "Fortschrittsprozentsatz anzeigen (z.B. '40%')", + }, }, }, taskFilenames: { @@ -2857,6 +2879,7 @@ export const de: TranslationTree = { blockingEmpty: "Keine abhängigen Aufgaben", blockingLoadError: "Abhängigkeiten konnten nicht geladen werden", googleCalendarSyncTooltip: "Mit Google Kalender synchronisiert", + progressTooltip: "Fortschritt: {completed} von {total} Checkboxen abgeschlossen ({percentage}%)", }, propertyEventCard: { unknownFile: "Unbekannte Datei", diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 22198fc2..b0804f5b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1216,6 +1216,28 @@ export const en: TranslationTree = { tags: "Tags", blocked: "Blocked", blocking: "Blocking", + progress: "Progress", + }, + progressBar: { + header: "Progress Bar", + description: "Configure how the progress bar is displayed on task cards.", + displayMode: { + name: "Display mode", + description: "How to display the progress bar", + options: { + barOnly: "Bar only", + textOnly: "Text only", + barWithText: "Bar with text", + }, + }, + showCount: { + name: "Show count", + description: "Display the count of completed checkboxes (e.g., '2/5')", + }, + showPercentage: { + name: "Show percentage", + description: "Display the completion percentage (e.g., '40%')", + }, }, }, taskFilenames: { @@ -2921,6 +2943,7 @@ export const en: TranslationTree = { blockedBadgeTooltip: "This task is waiting on another task", blockingBadge: "Blocking", blockingBadgeTooltip: "This task is blocking another task", + progressTooltip: "Progress: {completed} of {total} checkboxes completed ({percentage}%)", blockingToggle: "Blocking {count} tasks", loadingDependencies: "Loading dependencies...", blockingEmpty: "No dependent tasks", diff --git a/src/main.ts b/src/main.ts index 57ff575e..6797fea9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -97,6 +97,7 @@ import { MicrosoftCalendarService } from "./services/MicrosoftCalendarService"; import { LicenseService } from "./services/LicenseService"; import { CalendarProviderRegistry } from "./services/CalendarProvider"; import { TaskCalendarSyncService } from "./services/TaskCalendarSyncService"; +import { ProgressService } from "./services/ProgressService"; interface TranslatedCommandDefinition { id: string; @@ -218,6 +219,9 @@ export default class TaskNotesPlugin extends Plugin { // Task-to-Google Calendar sync service taskCalendarSyncService: TaskCalendarSyncService; + // Progress service for calculating task progress from checkboxes + progressService: ProgressService; + // Bases filter converter for exporting saved views basesFilterConverter: import("./services/BasesFilterConverter").BasesFilterConverter; @@ -365,6 +369,7 @@ export default class TaskNotesPlugin extends Plugin { this.statusBarService = new StatusBarService(this); this.notificationService = new NotificationService(this); this.viewPerformanceService = new ViewPerformanceService(this); + this.progressService = new ProgressService(); // Initialize Bases filter converter for saved view export const { BasesFilterConverter } = await import("./services/BasesFilterConverter"); @@ -996,6 +1001,11 @@ export default class TaskNotesPlugin extends Plugin { if (this.taskLinkDetectionService) { this.taskLinkDetectionService.clearCacheForFile(filePath); } + + // Clear progress cache for this file + if (this.progressService) { + this.progressService.clearCacheForTask(filePath); + } } else if (force) { // Full cache clear if forcing this.cacheManager.clearAllCaches(); @@ -1004,6 +1014,11 @@ export default class TaskNotesPlugin extends Plugin { if (this.taskLinkDetectionService) { this.taskLinkDetectionService.clearCache(); } + + // Clear progress cache completely + if (this.progressService) { + this.progressService.clearCache(); + } } // Only emit refresh event if triggerRefresh is true diff --git a/src/services/ProgressService.ts b/src/services/ProgressService.ts new file mode 100644 index 00000000..e73b8a1d --- /dev/null +++ b/src/services/ProgressService.ts @@ -0,0 +1,120 @@ +import { TaskInfo, ProgressInfo } from "../types"; + +/** + * Service for calculating task progress based on top-level checkboxes in task body + */ +export class ProgressService { + private cache: Map = new Map(); + + /** + * Calculate progress for a task based on top-level checkboxes in the task body + * Only counts checkboxes with no indentation (first level) + * + * @param task - The task to calculate progress for + * @returns ProgressInfo with completed count, total count, and percentage + */ + calculateProgress(task: TaskInfo): ProgressInfo | null { + // Validate input + if (!task || !task.path) { + return null; + } + + // If no details/content, return null (no progress to show) + if (!task.details || task.details.trim().length === 0) { + return null; + } + + // Create cache key based on task path and details hash + const cacheKey = `${task.path}:${this.hashString(task.details)}`; + + // Check cache first + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey)!; + } + + // Parse checkboxes from task body + const lines = task.details.split("\n"); + let total = 0; + let completed = 0; + + for (const line of lines) { + // Check if this is a checkbox line (first-level only) + const checkboxMatch = line.match(/^(\s*(?:[-*+]|\d+\.)\s+\[)([ xX])(\]\s+)(.*)/); + if (checkboxMatch) { + // Check indentation - only count if indentation is 0 (first level) + const indentation = line.match(/^(\s*)/)?.[1]?.length || 0; + if (indentation === 0) { + total++; + const checkState = checkboxMatch[2]; + if (checkState.toLowerCase() === "x") { + completed++; + } + } + } + } + + // If no checkboxes found, return null (no progress to show) + if (total === 0) { + return null; + } + + // Calculate percentage + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; + + const progressInfo: ProgressInfo = { + completed, + total, + percentage, + }; + + // Cache the result + this.cache.set(cacheKey, progressInfo); + + return progressInfo; + } + + /** + * Simple hash function for string caching + * @param str - String to hash + * @returns Hash string + */ + private hashString(str: string): string { + if (!str || str.length === 0) { + return "0"; + } + let hash = 0; + // Limit hash calculation for very long strings to prevent performance issues + const maxLength = 10000; + const strToHash = str.length > maxLength ? str.substring(0, maxLength) : str; + for (let i = 0; i < strToHash.length; i++) { + const char = strToHash.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash | 0; // Convert to 32-bit integer + } + return hash.toString(36); + } + + /** + * Clear the progress cache + * Useful when task details change + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Clear cache entry for a specific task + * @param taskPath - Path of the task to clear from cache + */ + clearCacheForTask(taskPath: string): void { + const keysToDelete: string[] = []; + for (const key of this.cache.keys()) { + if (key.startsWith(`${taskPath}:`)) { + keysToDelete.push(key); + } + } + for (const key of keysToDelete) { + this.cache.delete(key); + } + } +} diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 5e479035..bffaad7b 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -369,6 +369,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { "tags", // Tags "blocked", // Blocked indicator "blocking", // Blocking indicator + "progress", // Progress bar ], // Default visible properties for inline task cards (more compact by default) inlineVisibleProperties: ["status", "priority", "due", "scheduled", "recurrence"], @@ -407,4 +408,12 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { microsoftCalendarSyncTokens: {}, // Google Calendar task export settings googleCalendarExport: DEFAULT_GOOGLE_CALENDAR_EXPORT, + // Progress bar settings + progressBar: { + enabled: true, + displayMode: "bar-with-text", + showPercentage: true, + showCount: true, + emptyState: "show-zero", + }, }; diff --git a/src/settings/tabs/appearanceTab.ts b/src/settings/tabs/appearanceTab.ts index a3618dd0..8a201243 100644 --- a/src/settings/tabs/appearanceTab.ts +++ b/src/settings/tabs/appearanceTab.ts @@ -66,6 +66,104 @@ export function renderAppearanceTab( setting.setDesc(`Currently showing: ${currentLabels.join(", ")}`); setting.settingEl.addClass("settings-view__group-description"); }); + + // Progress Bar Settings + group.addSetting((setting) => { + setting.setHeading(); + setting.setName(translate("settings.appearance.taskCards.progressBar.header")); + setting.setDesc(translate("settings.appearance.taskCards.progressBar.description")); + }); + + // Initialize progressBar settings if not present + if (!plugin.settings.progressBar) { + plugin.settings.progressBar = { + enabled: true, + displayMode: "bar-with-text", + showPercentage: true, + showCount: true, + emptyState: "show-zero", + }; + } + + group.addSetting((setting) => + configureDropdownSetting(setting, { + name: translate("settings.appearance.taskCards.progressBar.displayMode.name"), + desc: translate("settings.appearance.taskCards.progressBar.displayMode.description"), + options: [ + { + value: "bar-only", + label: translate("settings.appearance.taskCards.progressBar.displayMode.options.barOnly"), + }, + { + value: "text-only", + label: translate("settings.appearance.taskCards.progressBar.displayMode.options.textOnly"), + }, + { + value: "bar-with-text", + label: translate("settings.appearance.taskCards.progressBar.displayMode.options.barWithText"), + }, + ], + getValue: () => plugin.settings.progressBar?.displayMode || "bar-with-text", + setValue: async (value: string) => { + if (!plugin.settings.progressBar) { + plugin.settings.progressBar = { + enabled: true, + displayMode: value as "bar-only" | "text-only" | "bar-with-text", + showPercentage: true, + showCount: true, + emptyState: "show-zero", + }; + } else { + plugin.settings.progressBar.displayMode = value as "bar-only" | "text-only" | "bar-with-text"; + } + save(); + }, + }) + ); + + group.addSetting((setting) => + configureToggleSetting(setting, { + name: translate("settings.appearance.taskCards.progressBar.showCount.name"), + desc: translate("settings.appearance.taskCards.progressBar.showCount.description"), + getValue: () => plugin.settings.progressBar?.showCount !== false, + setValue: async (value: boolean) => { + if (!plugin.settings.progressBar) { + plugin.settings.progressBar = { + enabled: true, + displayMode: "bar-with-text", + showPercentage: true, + showCount: value, + emptyState: "show-zero", + }; + } else { + plugin.settings.progressBar.showCount = value; + } + save(); + }, + }) + ); + + group.addSetting((setting) => + configureToggleSetting(setting, { + name: translate("settings.appearance.taskCards.progressBar.showPercentage.name"), + desc: translate("settings.appearance.taskCards.progressBar.showPercentage.description"), + getValue: () => plugin.settings.progressBar?.showPercentage !== false, + setValue: async (value: boolean) => { + if (!plugin.settings.progressBar) { + plugin.settings.progressBar = { + enabled: true, + displayMode: "bar-with-text", + showPercentage: value, + showCount: true, + emptyState: "show-zero", + }; + } else { + plugin.settings.progressBar.showPercentage = value; + } + save(); + }, + }) + ); } ); diff --git a/src/types.ts b/src/types.ts index d1dafd42..c65dd24b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -433,6 +433,15 @@ export interface TaskDependency { gap?: string; // Optional ISO 8601 duration offset between tasks } +/** + * Progress information for a task based on top-level checkboxes + */ +export interface ProgressInfo { + completed: number; // Number of completed checkboxes + total: number; // Total number of top-level checkboxes + percentage: number; // Completion percentage (0-100) +} + export interface TaskInfo { id?: string; // Task identifier (typically same as path for API consistency) title: string; @@ -465,6 +474,7 @@ export interface TaskInfo { isBlocked?: boolean; // True if any blocking dependency is incomplete isBlocking?: boolean; // True if this task blocks at least one other task details?: string; // Optional task body content + progress?: ProgressInfo; // Progress information based on top-level checkboxes (computed property) } export interface TaskCreationData extends Partial { diff --git a/src/types/settings.ts b/src/types/settings.ts index 4011758a..dd891423 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -201,6 +201,8 @@ export interface TaskNotesSettings { defaultVisibleProperties?: string[]; // Default visible properties for inline task cards (task link widgets in editor) inlineVisibleProperties?: string[]; + // Progress bar settings + progressBar?: ProgressBarSettings; // Bases integration settings enableBases: boolean; autoCreateDefaultBasesFiles: boolean; // Auto-create missing default Base files on startup @@ -288,6 +290,17 @@ export interface ICSIntegrationSettings { useICSEndAsDue: boolean; // Whether to use ICS event end time as task due date } +/** + * Configuration for progress bar display + */ +export interface ProgressBarSettings { + enabled: boolean; // Whether progress bar is enabled + displayMode: "bar-only" | "text-only" | "bar-with-text"; // How to display progress + showPercentage: boolean; // Show percentage in text + showCount: boolean; // Show count (e.g., "2/5") in text + emptyState: "hide" | "show-zero"; // Whether to show "0/0 (0%)" if no checkboxes found +} + /** * Configuration for exporting tasks to Google Calendar */ diff --git a/src/ui/TaskCard.ts b/src/ui/TaskCard.ts index 3aeb88d1..bc53515b 100644 --- a/src/ui/TaskCard.ts +++ b/src/ui/TaskCard.ts @@ -8,6 +8,8 @@ import { getRecurrenceDisplayText, filterEmptyProjects, calculateTotalTimeSpent, + calculateTaskProgress, + splitFrontmatterAndBody, } from "../utils/helpers"; import { FilterUtils } from "../utils/FilterUtils"; import { @@ -1069,6 +1071,210 @@ function renderGenericProperty( } } +/** + * Render progress property as a separate line + * Shows progress bar based on top-level checkboxes in task body + */ +async function renderProgressProperty( + container: HTMLElement, + task: TaskInfo, + plugin: TaskNotesPlugin, + layout: "default" | "compact" | "inline" +): Promise { + // For inline layout, skip progress bar (too complex for inline) + if (layout === "inline") { + return; + } + + // Load details if not present (lazy loading) + let taskDetails = task.details; + if (!taskDetails) { + try { + const file = plugin.app.vault.getAbstractFileByPath(task.path); + if (file instanceof TFile) { + const content = await plugin.app.vault.read(file); + const { body } = splitFrontmatterAndBody(content); + taskDetails = body; + } + } catch (error) { + console.warn("Could not load task details for progress calculation:", error); + return; + } + } + + // Create task with details for progress calculation + const taskWithDetails: TaskInfo = { + ...task, + details: taskDetails, + }; + + // Calculate progress using ProgressService + const progress = calculateTaskProgress(taskWithDetails, plugin.progressService); + + // If no progress (no checkboxes or no details), handle based on emptyState setting + if (!progress) { + const emptyState = plugin.settings.progressBar?.emptyState || "show-zero"; + + // Show 0% if details exist but no checkboxes and emptyState is "show-zero" + if (emptyState === "show-zero" && taskDetails && taskDetails.trim().length > 0) { + // Get display mode from settings (default to bar-with-text) + const displayMode = plugin.settings.progressBar?.displayMode || "bar-with-text"; + const showPercentage = plugin.settings.progressBar?.showPercentage !== false; + const showCount = plugin.settings.progressBar?.showCount !== false; + + const progressLine = container.createEl("div", { cls: "task-card__progress" }); + + // Render based on display mode + if (displayMode === "bar-only" || displayMode === "bar-with-text") { + const progressBar = progressLine.createEl("div", { cls: "task-card__progress-bar" }); + progressBar.style.setProperty("--progress-width", "0%"); + progressBar.setAttribute("aria-label", "0/0 (0%)"); + } + + if (displayMode === "text-only" || displayMode === "bar-with-text") { + const progressText = progressLine.createEl("span", { cls: "task-card__progress-text" }); + const parts: string[] = []; + if (showCount) { + parts.push("0/0"); + } + if (showPercentage) { + parts.push("(0%)"); + } + progressText.textContent = parts.join(" "); + } + return; + } + // No details or emptyState is "hide", don't show progress + return; + } + + // Get display mode from settings (default to bar-with-text) + const displayMode = plugin.settings.progressBar?.displayMode || "bar-with-text"; + const showPercentage = plugin.settings.progressBar?.showPercentage !== false; + const showCount = plugin.settings.progressBar?.showCount !== false; + + // Create progress container + const progressLine = container.createEl("div", { cls: "task-card__progress" }); + + // Render based on display mode + if (displayMode === "bar-only" || displayMode === "bar-with-text") { + const progressBar = progressLine.createEl("div", { cls: "task-card__progress-bar" }); + progressBar.style.setProperty("--progress-width", `${progress.percentage}%`); + progressBar.setAttribute("aria-label", `${progress.completed}/${progress.total} (${progress.percentage}%)`); + } + + if (displayMode === "text-only" || displayMode === "bar-with-text") { + const progressText = progressLine.createEl("span", { cls: "task-card__progress-text" }); + const parts: string[] = []; + if (showCount) { + parts.push(`${progress.completed}/${progress.total}`); + } + if (showPercentage) { + parts.push(`(${progress.percentage}%)`); + } + progressText.textContent = parts.join(" "); + setTooltip( + progressText, + plugin.i18n.translate("ui.taskCard.progressTooltip", { + completed: progress.completed, + total: progress.total, + percentage: progress.percentage, + }), + { placement: "top" } + ); + } +} + +/** + * Render progress property synchronously (for cases where details is already loaded) + */ +function renderProgressPropertySync( + container: HTMLElement, + task: TaskInfo, + plugin: TaskNotesPlugin, + layout: "default" | "compact" | "inline" +): void { + // For inline layout, skip progress bar (too complex for inline) + if (layout === "inline") { + return; + } + + // Calculate progress using ProgressService + const progress = calculateTaskProgress(task, plugin.progressService); + + // If no progress (no checkboxes or no details), handle based on emptyState setting + if (!progress) { + const emptyState = plugin.settings.progressBar?.emptyState || "show-zero"; + + // Show 0% if details exist but no checkboxes and emptyState is "show-zero" + if (emptyState === "show-zero" && task.details && task.details.trim().length > 0) { + // Get display mode from settings (default to bar-with-text) + const displayMode = plugin.settings.progressBar?.displayMode || "bar-with-text"; + const showPercentage = plugin.settings.progressBar?.showPercentage !== false; + const showCount = plugin.settings.progressBar?.showCount !== false; + + const progressLine = container.createEl("div", { cls: "task-card__progress" }); + + // Render based on display mode + if (displayMode === "bar-only" || displayMode === "bar-with-text") { + const progressBar = progressLine.createEl("div", { cls: "task-card__progress-bar" }); + progressBar.style.setProperty("--progress-width", "0%"); + progressBar.setAttribute("aria-label", "0/0 (0%)"); + } + + if (displayMode === "text-only" || displayMode === "bar-with-text") { + const progressText = progressLine.createEl("span", { cls: "task-card__progress-text" }); + const parts: string[] = []; + if (showCount) { + parts.push("0/0"); + } + if (showPercentage) { + parts.push("(0%)"); + } + progressText.textContent = parts.join(" "); + } + } + // No details or emptyState is "hide", don't show progress + return; + } + + // Get display mode from settings (default to bar-with-text) + const displayMode = plugin.settings.progressBar?.displayMode || "bar-with-text"; + const showPercentage = plugin.settings.progressBar?.showPercentage !== false; + const showCount = plugin.settings.progressBar?.showCount !== false; + + // Create progress container + const progressLine = container.createEl("div", { cls: "task-card__progress" }); + + // Render based on display mode + if (displayMode === "bar-only" || displayMode === "bar-with-text") { + const progressBar = progressLine.createEl("div", { cls: "task-card__progress-bar" }); + progressBar.style.setProperty("--progress-width", `${progress.percentage}%`); + progressBar.setAttribute("aria-label", `${progress.completed}/${progress.total} (${progress.percentage}%)`); + } + + if (displayMode === "text-only" || displayMode === "bar-with-text") { + const progressText = progressLine.createEl("span", { cls: "task-card__progress-text" }); + const parts: string[] = []; + if (showCount) { + parts.push(`${progress.completed}/${progress.total}`); + } + if (showPercentage) { + parts.push(`(${progress.percentage}%)`); + } + progressText.textContent = parts.join(" "); + setTooltip( + progressText, + plugin.i18n.translate("ui.taskCard.progressTooltip", { + completed: progress.completed, + total: progress.total, + percentage: progress.percentage, + }), + { placement: "top" } + ); + } +} + /** * Render a single property value with link detection */ @@ -1642,6 +1848,12 @@ export function createTaskCard( continue; } + // Progress property - handled separately as its own line + if (propertyId === "progress") { + // Skip here, will be rendered as separate line below + continue; + } + // Google Calendar sync indicator if (propertyId === "googleCalendarSync") { // Check if task has a Google Calendar event ID in frontmatter @@ -1670,6 +1882,20 @@ export function createTaskCard( // Show/hide metadata line based on content updateMetadataVisibility(metadataLine, metadataElements); + // Render progress as separate line if visible + const shouldShowProgress = propertiesToShow.includes("progress"); + if (shouldShowProgress) { + // Use sync version if details is already loaded, otherwise use async version + if (task.details !== undefined) { + renderProgressPropertySync(contentContainer, task, plugin, layout); + } else { + // Load details asynchronously and render progress + renderProgressProperty(contentContainer, task, plugin, layout).catch((error) => { + console.warn("Error rendering progress:", error); + }); + } + } + // Add click handlers with single/double click distinction const { clickHandler, dblclickHandler, contextmenuHandler } = createTaskClickHandler({ task, @@ -2233,6 +2459,12 @@ export function updateTaskCard( continue; } + // Progress property - handled separately as its own line + if (propertyId === "progress") { + // Skip here, will be rendered as separate line below + continue; + } + const element = renderPropertyMetadata(metadataLine, propertyId, task, plugin); if (element) { metadataElements.push(element); @@ -2243,6 +2475,40 @@ export function updateTaskCard( updateMetadataVisibility(metadataLine, metadataElements); } + // Update progress as separate line if visible + const contentContainer = element.querySelector(".task-card__content") as HTMLElement; + if (contentContainer) { + const propertiesToShow = + visibleProperties || + (plugin.settings.defaultVisibleProperties + ? convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin) + : getDefaultVisibleProperties(plugin)); + + const shouldShowProgress = propertiesToShow.includes("progress"); + const existingProgress = contentContainer.querySelector(".task-card__progress"); + + if (shouldShowProgress) { + // Remove existing progress if present + if (existingProgress) { + existingProgress.remove(); + } + + // Use sync version if details is already loaded, otherwise use async version + const layout = opts.layout || "default"; + if (task.details !== undefined) { + renderProgressPropertySync(contentContainer, task, plugin, layout); + } else { + // Load details asynchronously and render progress + renderProgressProperty(contentContainer, task, plugin, layout).catch((error) => { + console.warn("Error rendering progress:", error); + }); + } + } else if (existingProgress) { + // Remove progress if it should not be visible + existingProgress.remove(); + } + } + // Animation is now handled separately - don't add it here during reconciler updates } diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 47ae25da..f49ac488 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,9 +1,10 @@ import { normalizePath, TFile, Vault, App, parseYaml, stringifyYaml } from "obsidian"; import { format } from "date-fns"; import { RRule } from "rrule"; -import { TimeInfo, TaskInfo, TimeEntry, TimeBlock, DailyNoteFrontmatter } from "../types"; +import { TimeInfo, TaskInfo, TimeEntry, TimeBlock, DailyNoteFrontmatter, ProgressInfo } from "../types"; import { FieldMapper } from "../services/FieldMapper"; import { DEFAULT_FIELD_MAPPING } from "../settings/defaults"; +import { ProgressService } from "../services/ProgressService"; import { getTodayString, parseDateToLocal, @@ -1439,3 +1440,22 @@ export function sanitizeTags(tags: string): string { .filter((tag) => tag.length > 0) // Remove empty tags .join(", "); } + +/** + * Calculate progress for a task based on top-level checkboxes in the task body + * This is a lazy computation that should be called when progress property is visible + * + * @param task - The task to calculate progress for + * @param progressService - ProgressService instance (required for caching) + * @returns ProgressInfo or null if no checkboxes found + */ +export function calculateTaskProgress( + task: TaskInfo, + progressService: ProgressService +): ProgressInfo | null { + if (!progressService) { + console.warn("[TaskNotes] calculateTaskProgress called without ProgressService"); + return null; + } + return progressService.calculateProgress(task); +} diff --git a/src/utils/propertyHelpers.ts b/src/utils/propertyHelpers.ts index ff09bdaf..2ee26585 100644 --- a/src/utils/propertyHelpers.ts +++ b/src/utils/propertyHelpers.ts @@ -27,6 +27,7 @@ export function getAvailableProperties( { id: "priority", label: makeLabel("Priority", "priority") }, { id: "blocked", label: "Blocked Status" }, // Special property, not in FieldMapping { id: "blocking", label: "Blocking Status" }, // Special property, not in FieldMapping + { id: "progress", label: "Progress" }, // Computed property based on top-level checkboxes { id: "due", label: makeLabel("Due Date", "due") }, { id: "scheduled", label: makeLabel("Scheduled Date", "scheduled") }, { id: "timeEstimate", label: makeLabel("Time Estimate", "timeEstimate") }, diff --git a/src/views/PomodoroView.ts b/src/views/PomodoroView.ts index 43d1ae09..c49e259a 100644 --- a/src/views/PomodoroView.ts +++ b/src/views/PomodoroView.ts @@ -145,6 +145,15 @@ export class PomodoroView extends ItemView { } ); this.listeners.push(taskUpdateListener); + + // Listen for settings changes (e.g., progress bar display mode) + const settingsChangeListener = this.plugin.emitter.on("settings-changed", () => { + // Refresh task card display if a task is selected + if (this.currentSelectedTask) { + this.updateTaskCardDisplay(this.currentSelectedTask); + } + }); + this.listeners.push(settingsChangeListener); } async onOpen() { diff --git a/src/views/StatsView.ts b/src/views/StatsView.ts index 40e99bb9..7d95eb30 100644 --- a/src/views/StatsView.ts +++ b/src/views/StatsView.ts @@ -143,6 +143,15 @@ export class StatsView extends ItemView { ); this.listeners.push(taskUpdateListener); + // Listen for settings changes (e.g., progress bar display mode) + const settingsChangeListener = this.plugin.emitter.on("settings-changed", async () => { + // Refresh drill-down modal if it's open to apply new settings + if (this.drilldownModal && this.currentDrilldownData) { + await this.refreshDrilldownModal(); + } + }); + this.listeners.push(settingsChangeListener); + await this.render(); } diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index 045fb7eb..47d21a2a 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -24,8 +24,6 @@ /* Interactions & Accessibility - Smooth transitions */ transition: all var(--tn-transition-fast); outline: none; - role: listitem; - tabindex: 0; } .tasknotes-plugin .task-card:hover { @@ -1402,3 +1400,56 @@ .tasknotes-plugin .kanban-view__card-wrapper--selected-primary { box-shadow: inset 0 0 0 2px var(--interactive-accent); } + +/* ================================================================= + PROGRESS BAR STYLING + ================================================================= */ + +.tasknotes-plugin .task-card__progress { + display: flex; + align-items: center; + gap: var(--tn-spacing-sm); + margin-top: var(--tn-spacing-xs); + width: 100%; +} + +.tasknotes-plugin .task-card__progress-bar { + flex: 1; + height: 6px; + background-color: var(--background-modifier-border); + border-radius: var(--tn-radius-xs); + overflow: hidden; + position: relative; +} + +.tasknotes-plugin .task-card__progress-bar::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: var(--progress-width, 0%); + background-color: var(--interactive-accent); + border-radius: var(--tn-radius-xs); + transition: width var(--tn-transition-fast); +} + +.tasknotes-plugin .task-card__progress-text { + font-size: var(--tn-font-size-sm); + color: var(--tn-text-muted); + white-space: nowrap; + min-width: fit-content; +} + +/* Progress bar in compact layout */ +.tasknotes-plugin .task-card--layout-compact .task-card__progress { + margin-top: 2px; +} + +.tasknotes-plugin .task-card--layout-compact .task-card__progress-bar { + height: 4px; +} + +.tasknotes-plugin .task-card--layout-compact .task-card__progress-text { + font-size: var(--tn-font-size-xs); +} diff --git a/tests/unit/services/ProgressService.test.ts b/tests/unit/services/ProgressService.test.ts new file mode 100644 index 00000000..89475928 --- /dev/null +++ b/tests/unit/services/ProgressService.test.ts @@ -0,0 +1,306 @@ +import { ProgressService } from "../../../src/services/ProgressService"; +import { TaskInfo, ProgressInfo } from "../../../src/types"; + +describe("ProgressService", () => { + let service: ProgressService; + + beforeEach(() => { + service = new ProgressService(); + }); + + describe("calculateProgress", () => { + it("should return null when task has no details", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + }; + + const result = service.calculateProgress(task); + expect(result).toBeNull(); + }); + + it("should return null when details is empty", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: "", + }; + + const result = service.calculateProgress(task); + expect(result).toBeNull(); + }); + + it("should return null when details has no checkboxes", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: "Some text without checkboxes", + }; + + const result = service.calculateProgress(task); + expect(result).toBeNull(); + }); + + it("should count only top-level checkboxes (no indentation)", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [ ] Task 1 +- [x] Task 2 + - [ ] Subtask 1 (should not be counted) + - [x] Subtask 2 (should not be counted) +- [ ] Task 3`, + }; + + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + expect(result?.total).toBe(3); + expect(result?.completed).toBe(1); + expect(result?.percentage).toBe(33); + }); + + it("should calculate progress correctly for all completed", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [x] Task 1 +- [x] Task 2 +- [x] Task 3`, + }; + + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + expect(result?.total).toBe(3); + expect(result?.completed).toBe(3); + expect(result?.percentage).toBe(100); + }); + + it("should calculate progress correctly for none completed", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3`, + }; + + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + expect(result?.total).toBe(3); + expect(result?.completed).toBe(0); + expect(result?.percentage).toBe(0); + }); + + it("should handle uppercase X in checkboxes", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [X] Task 1 +- [x] Task 2 +- [ ] Task 3`, + }; + + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + expect(result?.total).toBe(3); + expect(result?.completed).toBe(2); + expect(result?.percentage).toBe(67); + }); + + it("should handle numbered lists", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `1. [ ] Task 1 +2. [x] Task 2 +3. [ ] Task 3`, + }; + + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + expect(result?.total).toBe(3); + expect(result?.completed).toBe(1); + expect(result?.percentage).toBe(33); + }); + + it("should handle mixed bullet types", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [ ] Task 1 +* [x] Task 2 ++ [ ] Task 3`, + }; + + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + expect(result?.total).toBe(3); + expect(result?.completed).toBe(1); + expect(result?.percentage).toBe(33); + }); + + it("should ignore indented checkboxes", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [ ] Task 1 + - [x] Subtask 1 + - [x] Sub-subtask +- [x] Task 2`, + }; + + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + expect(result?.total).toBe(2); + expect(result?.completed).toBe(1); + expect(result?.percentage).toBe(50); + }); + + it("should round percentage correctly", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [x] Task 1 +- [ ] Task 2 +- [ ] Task 3 +- [ ] Task 4 +- [ ] Task 5`, + }; + + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + expect(result?.total).toBe(5); + expect(result?.completed).toBe(1); + expect(result?.percentage).toBe(20); + }); + + it("should cache results for same task and details", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [x] Task 1 +- [ ] Task 2`, + }; + + const result1 = service.calculateProgress(task); + const result2 = service.calculateProgress(task); + + expect(result1).toEqual(result2); + expect(result1).not.toBeNull(); + }); + + it("should recalculate when details change", () => { + const task1: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [x] Task 1 +- [ ] Task 2`, + }; + + const task2: TaskInfo = { + ...task1, + details: `- [x] Task 1 +- [x] Task 2`, + }; + + const result1 = service.calculateProgress(task1); + const result2 = service.calculateProgress(task2); + + expect(result1?.completed).toBe(1); + expect(result2?.completed).toBe(2); + }); + }); + + describe("clearCache", () => { + it("should clear all cached progress", () => { + const task: TaskInfo = { + title: "Test Task", + status: "open", + priority: "normal", + path: "test.md", + archived: false, + details: `- [x] Task 1`, + }; + + service.calculateProgress(task); + service.clearCache(); + + // Cache should be cleared, but result should still be correct + const result = service.calculateProgress(task); + expect(result).not.toBeNull(); + }); + }); + + describe("clearCacheForTask", () => { + it("should clear cache for specific task only", () => { + const task1: TaskInfo = { + title: "Test Task 1", + status: "open", + priority: "normal", + path: "test1.md", + archived: false, + details: `- [x] Task 1`, + }; + + const task2: TaskInfo = { + title: "Test Task 2", + status: "open", + priority: "normal", + path: "test2.md", + archived: false, + details: `- [x] Task 2`, + }; + + service.calculateProgress(task1); + service.calculateProgress(task2); + + service.clearCacheForTask("test1.md"); + + // task2 should still be cached, task1 should be recalculated + const result1 = service.calculateProgress(task1); + const result2 = service.calculateProgress(task2); + + expect(result1).not.toBeNull(); + expect(result2).not.toBeNull(); + }); + }); +}); From da81daa360a320ba8ec48a5c524fb399a6059cb7 Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 12:41:05 +0100 Subject: [PATCH 2/9] feat: generate translations --- src/i18n/resources/de.ts | 8 ++++++++ src/i18n/resources/en.ts | 8 ++++++++ src/i18n/resources/es.ts | 31 +++++++++++++++++++++++++++++++ src/i18n/resources/fr.ts | 31 +++++++++++++++++++++++++++++++ src/i18n/resources/ja.ts | 31 +++++++++++++++++++++++++++++++ src/i18n/resources/ko.ts | 31 +++++++++++++++++++++++++++++++ src/i18n/resources/pt.ts | 35 +++++++++++++++++++++++++++++++++-- src/i18n/resources/ru.ts | 31 +++++++++++++++++++++++++++++++ src/i18n/resources/zh.ts | 31 +++++++++++++++++++++++++++++++ 9 files changed, 235 insertions(+), 2 deletions(-) diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 9ad93f69..d4e4ab54 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -1214,6 +1214,14 @@ export const de: TranslationTree = { name: "Prozentzahl anzeigen", description: "Fortschrittsprozentsatz anzeigen (z.B. '40%')", }, + emptyState: { + name: "Leerer Zustand", + description: "Wie der Fortschritt angezeigt wird, wenn keine Checkboxen vorhanden sind", + options: { + hide: "Verstecken", + showZero: "0/0 (0%) anzeigen", + }, + }, }, }, taskFilenames: { diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index b0804f5b..83ca4725 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1238,6 +1238,14 @@ export const en: TranslationTree = { name: "Show percentage", description: "Display the completion percentage (e.g., '40%')", }, + emptyState: { + name: "Empty state", + description: "How to display progress when there are no checkboxes", + options: { + hide: "Hide", + showZero: "Show 0/0 (0%)", + }, + }, }, }, taskFilenames: { diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 9e32d25f..7a1fbfec 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -1192,6 +1192,36 @@ export const es: TranslationTree = { tags: "Etiquetas", blocked: "Bloqueada", blocking: "Bloqueando", + progress: "Progreso", + }, + progressBar: { + header: "Barra de progreso", + description: "Configura cómo se muestra la barra de progreso en las tarjetas de tareas.", + displayMode: { + name: "Modo de visualización", + description: "Cómo mostrar la barra de progreso", + options: { + barOnly: "Solo barra", + textOnly: "Solo texto", + barWithText: "Barra con texto", + }, + }, + showCount: { + name: "Mostrar conteo", + description: "Mostrar el conteo de casillas completadas (ej., '2/5')", + }, + showPercentage: { + name: "Mostrar porcentaje", + description: "Mostrar el porcentaje de finalización (ej., '40%')", + }, + emptyState: { + name: "Estado vacío", + description: "Cómo mostrar el progreso cuando no hay casillas", + options: { + hide: "Ocultar", + showZero: "Mostrar 0/0 (0%)", + }, + }, }, }, taskFilenames: { @@ -2852,6 +2882,7 @@ export const es: TranslationTree = { blockedBadgeTooltip: "Esta tarea está esperando otra tarea", blockingBadge: "Bloqueando", blockingBadgeTooltip: "Esta tarea bloquea otra tarea", + progressTooltip: "Progreso: {completed} de {total} casillas completadas ({percentage}%)", blockingToggle: "Bloqueando {count} tareas", loadingDependencies: "Cargando dependencias...", blockingEmpty: "Sin tareas dependientes", diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 4ac959e0..a5d34306 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -1192,6 +1192,36 @@ export const fr: TranslationTree = { tags: "Étiquettes", blocked: "Bloqué", blocking: "Bloquant", + progress: "Progression", + }, + progressBar: { + header: "Barre de progression", + description: "Configurez l'affichage de la barre de progression sur les cartes de tâches.", + displayMode: { + name: "Mode d'affichage", + description: "Comment afficher la barre de progression", + options: { + barOnly: "Barre uniquement", + textOnly: "Texte uniquement", + barWithText: "Barre avec texte", + }, + }, + showCount: { + name: "Afficher le décompte", + description: "Afficher le nombre de cases cochées complétées (ex. '2/5')", + }, + showPercentage: { + name: "Afficher le pourcentage", + description: "Afficher le pourcentage de complétion (ex. '40%')", + }, + emptyState: { + name: "État vide", + description: "Comment afficher la progression lorsqu'il n'y a pas de cases", + options: { + hide: "Masquer", + showZero: "Afficher 0/0 (0%)", + }, + }, }, }, taskFilenames: { @@ -2852,6 +2882,7 @@ export const fr: TranslationTree = { blockedBadgeTooltip: "Cette tâche attend une autre tâche", blockingBadge: "Bloquant", blockingBadgeTooltip: "Cette tâche bloque une autre tâche", + progressTooltip: "Progression : {completed} sur {total} cases cochées complétées ({percentage}%)", blockingToggle: "Bloque {count} tâches", loadingDependencies: "Chargement des dépendances…", blockingEmpty: "Aucune tâche dépendante", diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index 89a27389..5c55833a 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -1192,6 +1192,36 @@ export const ja: TranslationTree = { tags: "タグ", blocked: "ブロック中", blocking: "ブロックしている", + progress: "進捗", + }, + progressBar: { + header: "進捗バー", + description: "タスクカードでの進捗バーの表示方法を設定します。", + displayMode: { + name: "表示モード", + description: "進捗バーの表示方法", + options: { + barOnly: "バーのみ", + textOnly: "テキストのみ", + barWithText: "バーとテキスト", + }, + }, + showCount: { + name: "カウントを表示", + description: "完了したチェックボックスの数を表示(例:'2/5')", + }, + showPercentage: { + name: "パーセンテージを表示", + description: "完了率を表示(例:'40%')", + }, + emptyState: { + name: "空の状態", + description: "チェックボックスがない場合の進捗表示方法", + options: { + hide: "非表示", + showZero: "0/0 (0%) を表示", + }, + }, }, }, taskFilenames: { @@ -2852,6 +2882,7 @@ export const ja: TranslationTree = { blockedBadgeTooltip: "このタスクは他のタスクを待っています", blockingBadge: "ブロックしている", blockingBadgeTooltip: "このタスクは他のタスクをブロックしています", + progressTooltip: "進捗: {completed}/{total} 個のチェックボックスが完了 ({percentage}%)", blockingToggle: "{count} 件のタスクをブロック", loadingDependencies: "依存関係を読み込み中…", blockingEmpty: "依存タスクはありません", diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index 3c80245b..f7714f8e 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -1154,6 +1154,36 @@ export const ko: TranslationTree = { tags: "태그", blocked: "차단됨", blocking: "차단 중", + progress: "진행률", + }, + progressBar: { + header: "진행률 표시줄", + description: "작업 카드에서 진행률 표시줄이 표시되는 방식을 설정합니다.", + displayMode: { + name: "표시 모드", + description: "진행률 표시줄 표시 방법", + options: { + barOnly: "표시줄만", + textOnly: "텍스트만", + barWithText: "표시줄과 텍스트", + }, + }, + showCount: { + name: "개수 표시", + description: "완료된 체크박스 개수 표시 (예: '2/5')", + }, + showPercentage: { + name: "백분율 표시", + description: "완료 백분율 표시 (예: '40%')", + }, + emptyState: { + name: "빈 상태", + description: "체크박스가 없을 때 진행률 표시 방법", + options: { + hide: "숨기기", + showZero: "0/0 (0%) 표시", + }, + }, }, }, taskFilenames: { @@ -2802,6 +2832,7 @@ export const ko: TranslationTree = { blockedBadgeTooltip: "이 작업은 다른 작업을 기다리고 있습니다", blockingBadge: "차단 중", blockingBadgeTooltip: "이 작업이 다른 작업을 차단하고 있습니다", + progressTooltip: "진행률: {completed}/{total}개의 체크박스 완료 ({percentage}%)", blockingToggle: "{count}개의 작업을 차단 중", loadingDependencies: "종속성 로딩 중...", blockingEmpty: "종속 작업 없음", diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index 939a3b8e..998fb22b 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -1194,8 +1194,38 @@ export const pt: TranslationTree = { contexts: "Contextos", tags: "Tags", blocked: "Bloqueada", - blocking: "Bloqueando" - } + blocking: "Bloqueando", + progress: "Progresso", + }, + progressBar: { + header: "Barra de Progresso", + description: "Configure como a barra de progresso é exibida nos cartões de tarefas.", + displayMode: { + name: "Modo de exibição", + description: "Como exibir a barra de progresso", + options: { + barOnly: "Apenas barra", + textOnly: "Apenas texto", + barWithText: "Barra com texto", + }, + }, + showCount: { + name: "Mostrar contagem", + description: "Exibir a contagem de caixas de seleção concluídas (ex.: '2/5')", + }, + showPercentage: { + name: "Mostrar porcentagem", + description: "Exibir a porcentagem de conclusão (ex.: '40%')", + }, + emptyState: { + name: "Estado vazio", + description: "Como exibir o progresso quando não há caixas de seleção", + options: { + hide: "Ocultar", + showZero: "Mostrar 0/0 (0%)", + }, + }, + }, }, taskFilenames: { header: "Nomes de Arquivo de Tarefa", @@ -2866,6 +2896,7 @@ export const pt: TranslationTree = { blockedBadgeTooltip: "Esta tarefa está aguardando outra tarefa", blockingBadge: "Bloqueando", blockingBadgeTooltip: "Esta tarefa está bloqueando outra tarefa", + progressTooltip: "Progresso: {completed} de {total} caixas de seleção concluídas ({percentage}%)", blockingToggle: "Bloqueando {count} tarefas", loadingDependencies: "Carregando dependências...", blockingEmpty: "Nenhuma tarefa dependente", diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index f7863282..f90ac7d8 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -1192,6 +1192,36 @@ export const ru: TranslationTree = { tags: "Теги", blocked: "Заблокирована", blocking: "Блокирует", + progress: "Прогресс", + }, + progressBar: { + header: "Индикатор прогресса", + description: "Настройте отображение индикатора прогресса на карточках задач.", + displayMode: { + name: "Режим отображения", + description: "Как отображать индикатор прогресса", + options: { + barOnly: "Только полоса", + textOnly: "Только текст", + barWithText: "Полоса с текстом", + }, + }, + showCount: { + name: "Показать количество", + description: "Отображать количество выполненных чекбоксов (например, '2/5')", + }, + showPercentage: { + name: "Показать процент", + description: "Отображать процент выполнения (например, '40%')", + }, + emptyState: { + name: "Пустое состояние", + description: "Как отображать прогресс, когда нет чекбоксов", + options: { + hide: "Скрыть", + showZero: "Показать 0/0 (0%)", + }, + }, }, }, taskFilenames: { @@ -2852,6 +2882,7 @@ export const ru: TranslationTree = { blockedBadgeTooltip: "Эта задача ожидает другую задачу", blockingBadge: "Блокирует", blockingBadgeTooltip: "Эта задача блокирует другую задачу", + progressTooltip: "Прогресс: {completed} из {total} чекбоксов выполнено ({percentage}%)", blockingToggle: "Блокирует {count} задач", loadingDependencies: "Загрузка зависимостей…", blockingEmpty: "Нет зависимых задач", diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index f9dd9c73..93204ad6 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -1192,6 +1192,36 @@ export const zh: TranslationTree = { tags: "标签", blocked: "已阻塞", blocking: "阻塞中", + progress: "进度", + }, + progressBar: { + header: "进度条", + description: "配置任务卡片上进度条的显示方式。", + displayMode: { + name: "显示模式", + description: "如何显示进度条", + options: { + barOnly: "仅条", + textOnly: "仅文本", + barWithText: "条和文本", + }, + }, + showCount: { + name: "显示计数", + description: "显示已完成的复选框计数(例如:'2/5')", + }, + showPercentage: { + name: "显示百分比", + description: "显示完成百分比(例如:'40%')", + }, + emptyState: { + name: "空状态", + description: "没有复选框时如何显示进度", + options: { + hide: "隐藏", + showZero: "显示 0/0 (0%)", + }, + }, }, }, taskFilenames: { @@ -2852,6 +2882,7 @@ export const zh: TranslationTree = { blockedBadgeTooltip: "此任务正在等待其他任务", blockingBadge: "阻塞中", blockingBadgeTooltip: "此任务正在阻塞其他任务", + progressTooltip: "进度:{completed}/{total} 个复选框已完成 ({percentage}%)", blockingToggle: "阻塞 {count} 个任务", loadingDependencies: "正在加载依赖…", blockingEmpty: "没有依赖的任务", From 9859b6eef85d54b155d9d75d0a859957caa61d16 Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 12:53:26 +0100 Subject: [PATCH 3/9] doc: add instructions for calculated properties to Development Guidelines --- Tasknotes-Development-Guidelines.md | 101 +++++++++++++++++++--------- 1 file changed, 70 insertions(+), 31 deletions(-) diff --git a/Tasknotes-Development-Guidelines.md b/Tasknotes-Development-Guidelines.md index 93267fc6..217153ff 100644 --- a/Tasknotes-Development-Guidelines.md +++ b/Tasknotes-Development-Guidelines.md @@ -99,9 +99,9 @@ The `MinimalNativeCache` serves as an intelligent coordinator rather than a data ```typescript // Only these are indexed for performance: tasksByDate: Map> // Calendar view optimization - tasksByStatus: Map> // FilterService optimization + tasksByStatus: Map> // FilterService optimization overdueTasks: Set // Overdue query optimization - + // Everything else computed on-demand: getAllTags() -> scans native cache when called getAllPriorities() -> scans native cache when called @@ -112,10 +112,10 @@ The `MinimalNativeCache` serves as an intelligent coordinator rather than a data ```typescript // Without coordination: Multiple views scan independently CalendarView -> scans 1000 files for date tasks - AgendaView -> scans 1000 files for date tasks + AgendaView -> scans 1000 files for date tasks KanbanView -> scans 1000 files for status tasks // Result: 3000 file scans on data change - + // With coordination: Single coordinated update MinimalCache -> coordinates single refresh signal All views -> refresh once with targeted data @@ -142,7 +142,7 @@ The event system now focuses on **coordination efficiency** rather than complex // All views receive coordinated signal // Each view refreshes with fresh native cache data }); - + // Inefficient: Direct native listening (avoided) app.metadataCache.on('changed', (file) => { // Each view independently processes the same change @@ -176,10 +176,10 @@ The UTC Anchor principle is a fundamental architectural decision that ensures ti ```typescript // For internal logic (comparisons, sorting, filtering) const date = parseDateToUTC('2025-08-01'); // Always 2025-08-01T00:00:00.000Z - + // For UI display (showing dates to users) const date = parseDateToLocal('2025-08-01'); // Midnight in user's timezone - + // Deprecated - do not use directly const date = parseDate('2025-08-01'); // Legacy function, aliased to parseDateToLocal ``` @@ -207,13 +207,13 @@ The UTC Anchor principle is a fundamental architectural decision that ensures ti * **For comparisons**: Use the provided safe functions that implement UTC anchors: `isSameDateSafe`, `isBeforeDateSafe`, `isOverdueTimeAware`. * **For storage**: Always store dates in YYYY-MM-DD format using `formatDateForStorage()`. * **For timestamps**: When creating `dateCreated` or `dateModified`, use `getCurrentTimestamp()` for timezone-aware ISO strings. - + * **Migration Pattern**: When updating existing code: ```typescript // Old pattern (fragile) const date = parseDate(task.due); if (isBefore(date, today)) { ... } - + // New pattern (robust) const date = parseDateToUTC(task.due); // For logic if (isBeforeDateSafe(task.due, getTodayString())) { ... } @@ -229,7 +229,7 @@ The UTC Anchor principle is a fundamental architectural decision that ensures ti async refreshTasks(): Promise { // Get task paths from minimal cache (indexed) const taskPaths = this.plugin.cacheManager.getTasksForDate(dateStr); - + // Get fresh task data from native cache const tasks = await Promise.all( taskPaths.map(path => { @@ -238,7 +238,7 @@ The UTC Anchor principle is a fundamental architectural decision that ensures ti return this.extractTaskInfo(path, metadata.frontmatter); }) ); - + this.renderTasks(tasks); } ``` @@ -289,6 +289,45 @@ Let's say you want to add a `complexity: 'simple' | 'medium' | 'hard'` property **Note**: With the minimal cache approach, most new properties should be computed on-demand rather than indexed. Only add indexes for frequently-accessed, performance-critical queries. +### 5.1.1. Adding a Computed Property to Tasks + +Computed properties are calculated dynamically from task data rather than stored in frontmatter. Examples include `totalTrackedTime` (from `timeEntries`), `blocked`/`blocking` (from `blockedBy` dependencies), and `progress` (from checkboxes in task body). See the `progress` property implementation for a complete example. + +Let's say you want to add a `progress` property that calculates completion percentage based on checkboxes in the task body. + +1. **Update `types.ts`**: Define the computed property interface (e.g., `ProgressInfo`) and add it to `TaskInfo` as optional (e.g., `progress?: ProgressInfo`). + +2. **Create a Service (if computation is complex)**: Create a new service class (e.g., `ProgressService`) in `/services/` with calculation logic and caching. Add cache invalidation methods (`clearCache()`, `clearCacheForTask(path)`). + +3. **Integrate Service in `main.ts`**: Add service instance to plugin class, initialize in `onload()`, and add cache invalidation in `notifyDataChanged()`. + +4. **Add Helper Function in `utils/helpers.ts`**: Create a helper function that delegates to the service (e.g., `calculateTaskProgress()`). + +5. **Update `utils/propertyHelpers.ts`**: Add the property to `coreProperties` array so it appears in property selection. + +6. **Implement Rendering in `ui/TaskCard.ts`**: Add rendering function (e.g., `renderProgressProperty`). Use lazy loading: only calculate when property is visible. Load `task.details` if needed using `vault.read()` (body content isn't in metadataCache). Handle empty states gracefully. + +7. **Integrate with Bases Views** (if property should be available in Bases): + * Register in `BasesViewBase.registerComputedProperties()`: Add to `query.properties` map as `task.progress` (standard form for TaskNotes computed properties). + * Add to `BasesDataAdapter.extractEntryProperties()`: Add placeholder to `entry.frontmatter['task.progress']` so Bases discovers it. + * Update `PropertyMappingService.applySpecialTransformations()`: Map both `progress` and `task.progress` to the internal property name. + +8. **Add Settings Integration (optional)**: If the property has display options, add settings interface, add to `TaskNotesSettings`, add defaults in `settings/defaults.ts`, add UI in settings tab, and add settings change listener in views to trigger re-renders. + +9. **Add i18n Translations**: Add translations for property name in all language files, and for settings UI if applicable. + +**Key Differences from Regular Properties:** +* Not stored in frontmatter: Computed properties are never written to YAML frontmatter. +* No FieldMapper integration: They don't need `FieldMapper` since they're not user-configurable field names. +* Lazy computation: Should only be calculated when the property is visible/needed. +* Service-based calculation: Complex computations should be encapsulated in a dedicated service. + +**Performance Considerations:** +* Use lazy loading: Only calculate when property is visible. +* Implement caching: Use service-level cache with hash-based invalidation for expensive computations. +* Body content: Use `vault.read()` for body content (not in metadataCache) - this is acceptable for lazy-loaded computed properties. +* Cache invalidation: Clear cache on task updates via `notifyDataChanged()`. + ### 5.2. Adding View-Specific Options to Saved Views The plugin supports capturing and restoring view-specific display options as part of saved views. This feature allows views to preserve their display preferences (toggles, visibility options) alongside filter configurations. @@ -300,7 +339,7 @@ The plugin supports capturing and restoring view-specific display options as par export class MyView extends ItemView { private showOption1: boolean = true; private showOption2: boolean = false; - + private setupViewOptions(): void { // Configure FilterBar with view options this.filterBar.setViewOptions([ @@ -318,7 +357,7 @@ private setupEventListeners(): void { this.filterBar.on('saveView', ({ name, query, viewOptions }) => { this.plugin.viewStateManager.saveView(name, query, viewOptions); }); - + // Apply loaded view options this.filterBar.on('loadViewOptions', (viewOptions: {[key: string]: boolean}) => { this.applyViewOptions(viewOptions); @@ -333,10 +372,10 @@ private applyViewOptions(viewOptions: {[key: string]: boolean}): void { if (viewOptions.hasOwnProperty('showOption2')) { this.showOption2 = viewOptions.showOption2; } - + // Update FilterBar to reflect loaded state this.setupViewOptions(); - + // Refresh view to apply changes this.refresh(); } @@ -381,7 +420,7 @@ saveView(name: string, query: FilterQuery, viewOptions?: {[key: string]: boolean **Advanced Calendar View Options:** - `showScheduled`: Display tasks with scheduled dates -- `showDue`: Display tasks with due dates +- `showDue`: Display tasks with due dates - `showTimeblocks`: Display time-blocking entries - `showRecurring`: Display recurring task events - `showICSEvents`: Display imported calendar events @@ -539,11 +578,11 @@ async onload() { // Essential initialization only await this.loadSettings(); this.initializeLightweightServices(); - + // Register view types and commands this.registerViews(); this.addCommands(); - + // Defer expensive operations this.app.workspace.onLayoutReady(() => { this.initializeAfterLayoutReady(); @@ -553,10 +592,10 @@ async onload() { private async initializeAfterLayoutReady() { // Minimal cache initialization (lightweight) this.cacheManager.initialize(); - + // Heavy service initialization await this.pomodoroService.initialize(); - + // Editor services with async imports const { TaskLinkDetectionService } = await import('./services/TaskLinkDetectionService'); } @@ -586,7 +625,7 @@ getTaskInfo(path: string): TaskInfo | null { getAllTasksOfStatus(status: string): TaskInfo[] { // Use essential index for paths const taskPaths = this.minimalCache.getTaskPathsByStatus(status); - + // Batch native cache access return taskPaths.map(path => this.getTaskInfo(path)).filter(Boolean); } @@ -606,9 +645,9 @@ getAllTasksOfStatus(status: string): TaskInfo[] { class MinimalNativeCache { // Only 3 essential indexes (~70% reduction from previous approach) private tasksByDate: Map> = new Map(); - private tasksByStatus: Map> = new Map(); + private tasksByStatus: Map> = new Map(); private overdueTasks: Set = new Set(); - + // No redundant data storage - everything comes from native cache getTaskInfo(path) { return this.extractFromNativeCache(path); // Always fresh @@ -634,15 +673,15 @@ export class MyView extends ItemView { this.plugin = plugin; // Lightweight constructor only } - + async onOpen() { // Wait for plugin readiness await this.plugin.onReady(); - + // Initialize view with native cache access this.initializeView(); } - + private async refreshData() { // Direct native cache access for fresh data const taskPaths = this.plugin.cacheManager.getTasksForDate(this.selectedDate); @@ -682,14 +721,14 @@ interface ICSSubscription { ```typescript async fetchSubscription(id: string): Promise { const subscription = this.getSubscription(id); - + let icsData: string; if (subscription.type === 'remote') { icsData = await this.fetchRemoteICS(subscription.url); } else { icsData = await this.readLocalICS(subscription.filePath); } - + const events = this.parseICS(icsData); this.updateCache(id, events); } @@ -708,7 +747,7 @@ private startFileWatcher(subscription: ICSSubscription): void { setTimeout(() => this.refreshSubscription(subscription.id), 1000); } }); - + // Store cleanup function this.fileWatchers.set(subscription.id, () => { this.plugin.app.vault.offref(modifyRef); @@ -878,7 +917,7 @@ const displayDate = hasTimeComponent(task.due) : format(parseDateToLocal(task.due), 'MMM d'); // Pattern 3: Filtering by date -const todayTasks = tasks.filter(task => +const todayTasks = tasks.filter(task => task.due && isSameDateSafe(task.due, getTodayString()) ); @@ -904,4 +943,4 @@ const updatedTask = { - ❌ Use deprecated `parseDate()` function - ❌ Mix parsing methods in the same logic flow - ❌ Assume timezone behavior without testing -- ❌ Compare Date objects from different parsing methods \ No newline at end of file +- ❌ Compare Date objects from different parsing methods From 3c53bd388bab02ebcaa3bada633f033a5d48de99 Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 13:56:24 +0100 Subject: [PATCH 4/9] fix: ensure task.progress is a number --- src/bases/BasesDataAdapter.ts | 3 ++- src/bases/BasesViewBase.ts | 33 ++++++++++++++++++++++++++--- src/ui/TaskCard.ts | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/bases/BasesDataAdapter.ts b/src/bases/BasesDataAdapter.ts index d49db1f8..3e6b41f1 100644 --- a/src/bases/BasesDataAdapter.ts +++ b/src/bases/BasesDataAdapter.ts @@ -213,11 +213,12 @@ export class BasesDataAdapter { const frontmatter = (entry as any).frontmatter || (entry as any).properties || {}; // Add virtual computed property 'task.progress' to entry.frontmatter so Bases discovers it + // Set to 0 (number) instead of null to ensure Bases recognizes it as a number property if (entry && !frontmatter['task.progress']) { if (!entry.frontmatter) { entry.frontmatter = {}; } - entry.frontmatter['task.progress'] = null; + entry.frontmatter['task.progress'] = 0; } const result = { ...frontmatter }; diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index d351048c..282c45de 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -1,4 +1,4 @@ -import { Component, App, setIcon } from "obsidian"; +import { Component, App, setIcon, TFile } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesDataAdapter } from "./BasesDataAdapter"; import { PropertyMappingService } from "./PropertyMappingService"; @@ -8,6 +8,7 @@ import { DEFAULT_INTERNAL_VISIBLE_PROPERTIES } from "../settings/defaults"; import { SearchBox } from "./components/SearchBox"; import { TaskSearchFilter } from "./TaskSearchFilter"; import { BatchContextMenu } from "../components/BatchContextMenu"; +import { splitFrontmatterAndBody } from "../utils/helpers"; /** * Abstract base class for all TaskNotes Bases views. @@ -110,8 +111,34 @@ export abstract class BasesViewBase extends Component { return 'Progress'; } }, - getType: () => 'text', - getValue: () => null, + getType: () => 'number', + getValue: (entry: any) => { + // Calculate progress for this entry + // Note: Bases expects synchronous getValue, so we can't read file content here + // Instead, we try to get progress from cache if available, or return null + // Progress will be calculated lazily during rendering + if (!entry?.file?.path) { + return null; + } + + try { + // Try to get TaskInfo from cache (if already loaded) + // This allows us to get progress if it was already calculated + const cachedTaskInfo = this.plugin.cacheManager?.getCachedTaskInfoSync?.(entry.file.path); + if (cachedTaskInfo?.progress) { + // Ensure we return a number, not a string + const percentage = cachedTaskInfo.progress.percentage; + return typeof percentage === 'number' ? percentage : Number(percentage) || 0; + } + + // If not in cache, return 0 instead of null to ensure Bases recognizes it as a number + // This ensures Bases treats it as a number property even when not yet calculated + return 0; + } catch (error) { + console.debug('[TaskNotes] Error getting progress in getValue:', error); + return 0; + } + }, isComputed: () => true, }; diff --git a/src/ui/TaskCard.ts b/src/ui/TaskCard.ts index bc53515b..5233e082 100644 --- a/src/ui/TaskCard.ts +++ b/src/ui/TaskCard.ts @@ -436,6 +436,15 @@ const PROPERTY_EXTRACTORS: Record any> = { dateCreated: (task) => task.dateCreated, dateModified: (task) => task.dateModified, googleCalendarSync: (task) => task.path, // Used to check if task is synced via plugin settings + progress: (task) => { + // Return percentage as number (0-100) for sorting and filtering + // If progress is already calculated, return the percentage + if (task.progress) { + return task.progress.percentage; + } + // If not calculated yet, return null (will be calculated lazily when needed) + return null; + }, }; /** @@ -576,6 +585,37 @@ function getPropertyValue(task: TaskInfo, propertyId: string, plugin: TaskNotesP } } + // Handle progress property specially - must return number for Bases sorting/filtering + if (propertyId === "progress" || propertyId === "task.progress") { + // If progress is already calculated, return the percentage + if (task.progress) { + return task.progress.percentage; + } + + // Try to get from Bases API first (for computed properties) + if (task.basesData && typeof task.basesData.getValue === "function") { + try { + const value = task.basesData.getValue("task.progress" as any); + if (value !== null && value !== undefined) { + const extracted = extractBasesValue(value); + // Ensure it's a number + if (typeof extracted === "number") { + return extracted; + } + // If Bases returned a ProgressInfo object, extract percentage + if (extracted && typeof extracted === "object" && "percentage" in extracted) { + return (extracted as any).percentage; + } + } + } catch (error) { + // Property doesn't exist in Bases, try fallback + } + } + + // If not available, return null (will be calculated lazily when rendering) + return null; + } + // Try to get property from Bases API first (for custom properties) // This ensures we get the same value that Bases is displaying if (task.basesData && typeof task.basesData.getValue === "function") { From 86a7e8e585228e309e3b0666c1311ba2b7952b00 Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 13:59:22 +0100 Subject: [PATCH 5/9] fix: ensure progress is sortable and filterable --- src/bases/BasesViewBase.ts | 3 +++ src/services/BasesFilterConverter.ts | 4 ++++ src/types.ts | 1 + src/utils/FilterUtils.ts | 10 ++++++++++ 4 files changed, 18 insertions(+) diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 282c45de..6cc0d3da 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -155,6 +155,9 @@ export abstract class BasesViewBase extends Component { * Debounced to prevent excessive re-renders during rapid file saves. */ onDataUpdated(): void { + // Re-register computed properties in case query was recreated + this.registerComputedProperties(); + // Skip if view is not visible if (!this.rootElement?.isConnected) { return; diff --git a/src/services/BasesFilterConverter.ts b/src/services/BasesFilterConverter.ts index aef22c38..67ad079d 100644 --- a/src/services/BasesFilterConverter.ts +++ b/src/services/BasesFilterConverter.ts @@ -310,6 +310,9 @@ export class BasesFilterConverter { case "blocking": frontmatterKey = "blocking"; // Computed property, not in field mapping break; + case "progress": + // Computed property - use task.progress prefix + return "task.progress"; // Note: "dependencies.isBlocked" is handled specially in convertConditionToString // Note: "dependencies.isBlocking" returns "true" (unsupported - requires reverse lookup) default: @@ -597,6 +600,7 @@ export class BasesFilterConverter { case "path": return "file.path"; case "timeEstimate": return fm.toUserField("timeEstimate"); case "recurrence": return fm.toUserField("recurrence"); + case "progress": return "task.progress"; // Computed property default: // Handle user fields if (sortKey.startsWith("user:")) { diff --git a/src/types.ts b/src/types.ts index c65dd24b..348504ea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -123,6 +123,7 @@ export type FilterProperty = | "dependencies.isBlocking" // Numeric properties | "timeEstimate" + | "progress" // Special properties | "recurrence" | "status.isCompleted" diff --git a/src/utils/FilterUtils.ts b/src/utils/FilterUtils.ts index 8f7c2a49..5e5bd946 100644 --- a/src/utils/FilterUtils.ts +++ b/src/utils/FilterUtils.ts @@ -293,6 +293,14 @@ export class FilterUtils { "is-greater-than-or-equal", "is-less-than-or-equal", ], + progress: [ + "is", + "is-not", + "is-greater-than", + "is-less-than", + "is-greater-than-or-equal", + "is-less-than-or-equal", + ], // Special properties recurrence: ["is-empty", "is-not-empty"], @@ -364,6 +372,8 @@ export class FilterUtils { return task.archived; case "timeEstimate": return task.timeEstimate; + case "progress": + return task.progress?.percentage ?? null; case "recurrence": return task.recurrence as TaskPropertyValue; case "status.isCompleted": From 6f2f1866b6112655c0281f48083865ba2424b777 Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 14:42:14 +0100 Subject: [PATCH 6/9] fix: increase compatibility with standard base views --- Tasknotes-Development-Guidelines.md | 65 ++++++++++++++++--------- src/bases/BasesDataAdapter.ts | 21 ++++---- src/bases/BasesViewBase.ts | 21 +++++--- src/main.ts | 75 ++++++++++++++++++++++++++++- src/services/FieldMapper.ts | 24 +++++++++ src/services/TaskService.ts | 38 +++++++++++++++ src/settings/defaults.ts | 1 + src/types.ts | 1 + 8 files changed, 205 insertions(+), 41 deletions(-) diff --git a/Tasknotes-Development-Guidelines.md b/Tasknotes-Development-Guidelines.md index 217153ff..c999888a 100644 --- a/Tasknotes-Development-Guidelines.md +++ b/Tasknotes-Development-Guidelines.md @@ -289,44 +289,63 @@ Let's say you want to add a `complexity: 'simple' | 'medium' | 'hard'` property **Note**: With the minimal cache approach, most new properties should be computed on-demand rather than indexed. Only add indexes for frequently-accessed, performance-critical queries. -### 5.1.1. Adding a Computed Property to Tasks +### 5.1.1. Adding a Computed Property That Needs Bases Integration -Computed properties are calculated dynamically from task data rather than stored in frontmatter. Examples include `totalTrackedTime` (from `timeEntries`), `blocked`/`blocking` (from `blockedBy` dependencies), and `progress` (from checkboxes in task body). See the `progress` property implementation for a complete example. +Some properties are calculated dynamically from task data (like `progress` from checkboxes in task body) but need to be available in Bases views for sorting and filtering. **Important**: For Bases compatibility, these properties must be persisted in frontmatter, not just computed on-demand. -Let's say you want to add a `progress` property that calculates completion percentage based on checkboxes in the task body. +**Why Frontmatter Persistence is Required:** +* Bases standard views (Table, etc.) require persistent values in frontmatter for sorting and filtering +* Computed properties without frontmatter storage don't work reliably in Bases +* The value must be recalculated and updated whenever the source data changes + +**Example: Progress Property Implementation** + +The `progress` property calculates completion percentage from top-level checkboxes in the task body and stores it in frontmatter as `task_progress` (percentage number 0-100). 1. **Update `types.ts`**: Define the computed property interface (e.g., `ProgressInfo`) and add it to `TaskInfo` as optional (e.g., `progress?: ProgressInfo`). -2. **Create a Service (if computation is complex)**: Create a new service class (e.g., `ProgressService`) in `/services/` with calculation logic and caching. Add cache invalidation methods (`clearCache()`, `clearCacheForTask(path)`). +2. **Add to FieldMapping**: Add the property to `FieldMapping` interface and `DEFAULT_FIELD_MAPPING` in `settings/defaults.ts` (e.g., `progress: "task_progress"`). + +3. **Update `FieldMapper.ts`**: + * Add reading logic in `mapFromFrontmatter()` to read the persisted value from frontmatter + * Add writing logic in `mapToFrontmatter()` to write the calculated value to frontmatter + +4. **Create a Service (if computation is complex)**: Create a new service class (e.g., `ProgressService`) in `/services/` with calculation logic and caching. Add cache invalidation methods (`clearCache()`, `clearCacheForTask(path)`). + +5. **Integrate Service in `main.ts`**: Add service instance to plugin class, initialize in `onload()`, and add cache invalidation in `notifyDataChanged()`. + +6. **Update `TaskService.ts`**: + * In `updateTask()`: Calculate and persist the property when source data changes (e.g., when `details` changes, recalculate progress and update frontmatter) + * In `createTask()`: Calculate and persist the property when creating new tasks if source data is present -3. **Integrate Service in `main.ts`**: Add service instance to plugin class, initialize in `onload()`, and add cache invalidation in `notifyDataChanged()`. +7. **Add File Change Listener**: In `main.ts`, add a listener (`metadataCache.on('changed')`) that recalculates and updates the property when files are modified directly (e.g., checkbox toggled in editor). Use debouncing to avoid excessive file writes. -4. **Add Helper Function in `utils/helpers.ts`**: Create a helper function that delegates to the service (e.g., `calculateTaskProgress()`). +8. **Add Helper Function in `utils/helpers.ts`**: Create a helper function that delegates to the service (e.g., `calculateTaskProgress()`). -5. **Update `utils/propertyHelpers.ts`**: Add the property to `coreProperties` array so it appears in property selection. +9. **Update `utils/propertyHelpers.ts`**: Add the property to `coreProperties` array so it appears in property selection. -6. **Implement Rendering in `ui/TaskCard.ts`**: Add rendering function (e.g., `renderProgressProperty`). Use lazy loading: only calculate when property is visible. Load `task.details` if needed using `vault.read()` (body content isn't in metadataCache). Handle empty states gracefully. +10. **Implement Rendering in `ui/TaskCard.ts`**: Add rendering function (e.g., `renderProgressProperty`). Use lazy loading: only calculate when property is visible. Load `task.details` if needed using `vault.read()` (body content isn't in metadataCache). Handle empty states gracefully. -7. **Integrate with Bases Views** (if property should be available in Bases): - * Register in `BasesViewBase.registerComputedProperties()`: Add to `query.properties` map as `task.progress` (standard form for TaskNotes computed properties). - * Add to `BasesDataAdapter.extractEntryProperties()`: Add placeholder to `entry.frontmatter['task.progress']` so Bases discovers it. - * Update `PropertyMappingService.applySpecialTransformations()`: Map both `progress` and `task.progress` to the internal property name. +11. **Integrate with Bases Views**: + * Register in `BasesViewBase.registerComputedProperties()`: Read from frontmatter first, fallback to cache if needed. Return the persisted value for Bases compatibility. + * Update `BasesDataAdapter.extractEntryProperties()`: Map the frontmatter property (e.g., `task_progress`) to Bases property name (e.g., `task.progress`) so Bases can discover and use it. -8. **Add Settings Integration (optional)**: If the property has display options, add settings interface, add to `TaskNotesSettings`, add defaults in `settings/defaults.ts`, add UI in settings tab, and add settings change listener in views to trigger re-renders. +12. **Add Settings Integration (optional)**: If the property has display options, add settings interface, add to `TaskNotesSettings`, add defaults in `settings/defaults.ts`, add UI in settings tab, and add settings change listener in views to trigger re-renders. -9. **Add i18n Translations**: Add translations for property name in all language files, and for settings UI if applicable. +13. **Add i18n Translations**: Add translations for property name in all language files, and for settings UI if applicable. -**Key Differences from Regular Properties:** -* Not stored in frontmatter: Computed properties are never written to YAML frontmatter. -* No FieldMapper integration: They don't need `FieldMapper` since they're not user-configurable field names. -* Lazy computation: Should only be calculated when the property is visible/needed. -* Service-based calculation: Complex computations should be encapsulated in a dedicated service. +**Key Implementation Details:** +* **Frontmatter Persistence**: The calculated value must be stored in frontmatter for Bases compatibility +* **Automatic Updates**: The value must be recalculated and updated whenever source data changes (via `updateTask()`, file change listener, etc.) +* **FieldMapper Integration**: Use `FieldMapper` to read/write the persisted value with user-configurable field names +* **Service-based Calculation**: Complex computations should be encapsulated in a dedicated service with caching +* **Debounced File Writes**: File change listeners should use debouncing to avoid excessive writes **Performance Considerations:** -* Use lazy loading: Only calculate when property is visible. -* Implement caching: Use service-level cache with hash-based invalidation for expensive computations. -* Body content: Use `vault.read()` for body content (not in metadataCache) - this is acceptable for lazy-loaded computed properties. -* Cache invalidation: Clear cache on task updates via `notifyDataChanged()`. +* Use lazy loading for UI rendering: Only calculate when property is visible +* Implement caching: Use service-level cache with hash-based invalidation for expensive computations +* Debounce file writes: File change listeners should debounce updates (e.g., 500ms) to batch rapid changes +* Cache invalidation: Clear cache on task updates via `notifyDataChanged()` ### 5.2. Adding View-Specific Options to Saved Views diff --git a/src/bases/BasesDataAdapter.ts b/src/bases/BasesDataAdapter.ts index 3e6b41f1..4475aae2 100644 --- a/src/bases/BasesDataAdapter.ts +++ b/src/bases/BasesDataAdapter.ts @@ -207,22 +207,23 @@ export class BasesDataAdapter { * Extracts frontmatter and basic file properties only (cheap operations). * Computed file properties (backlinks, links, etc.) are fetched lazily via getComputedProperty(). * - * Also adds virtual computed property 'task.progress' to entry.frontmatter so Bases can discover it. + * Also adds 'task.progress' property mapped from frontmatter 'task_progress' so Bases can discover it. */ private extractEntryProperties(entry: any): Record { const frontmatter = (entry as any).frontmatter || (entry as any).properties || {}; - // Add virtual computed property 'task.progress' to entry.frontmatter so Bases discovers it - // Set to 0 (number) instead of null to ensure Bases recognizes it as a number property - if (entry && !frontmatter['task.progress']) { - if (!entry.frontmatter) { - entry.frontmatter = {}; - } - entry.frontmatter['task.progress'] = 0; - } - const result = { ...frontmatter }; + // Map task_progress from frontmatter to task.progress for Bases compatibility + // Progress is now stored in frontmatter as task_progress (percentage number) + // We expose it as task.progress so Bases can use it for sorting/filtering + if (frontmatter['task_progress'] !== undefined) { + result['task.progress'] = frontmatter['task_progress']; + } else { + // If no progress in frontmatter, set to 0 (number) to ensure Bases recognizes it as a number property + result['task.progress'] = 0; + } + // Also extract file properties directly from the TFile object (these are cheap - no getValue calls) const file = entry.file; if (file) { diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 6cc0d3da..aa5c1497 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -113,17 +113,24 @@ export abstract class BasesViewBase extends Component { }, getType: () => 'number', getValue: (entry: any) => { - // Calculate progress for this entry - // Note: Bases expects synchronous getValue, so we can't read file content here - // Instead, we try to get progress from cache if available, or return null - // Progress will be calculated lazily during rendering + // Progress is now stored in frontmatter as task_progress (percentage number) + // Read it directly from frontmatter for Bases compatibility if (!entry?.file?.path) { return null; } try { - // Try to get TaskInfo from cache (if already loaded) - // This allows us to get progress if it was already calculated + // First try to get from frontmatter (persisted value) + const frontmatter = entry.frontmatter || entry.properties || {}; + const progressFieldName = this.plugin.fieldMapper.toUserField('progress'); + if (frontmatter[progressFieldName] !== undefined) { + const progressValue = frontmatter[progressFieldName]; + // Ensure we return a number + return typeof progressValue === 'number' ? progressValue : Number(progressValue) || 0; + } + + // Fallback: Try to get TaskInfo from cache (if already loaded) + // This allows us to get progress if it was already calculated but not yet persisted const cachedTaskInfo = this.plugin.cacheManager?.getCachedTaskInfoSync?.(entry.file.path); if (cachedTaskInfo?.progress) { // Ensure we return a number, not a string @@ -131,7 +138,7 @@ export abstract class BasesViewBase extends Component { return typeof percentage === 'number' ? percentage : Number(percentage) || 0; } - // If not in cache, return 0 instead of null to ensure Bases recognizes it as a number + // If not available, return 0 instead of null to ensure Bases recognizes it as a number // This ensures Bases treats it as a number property even when not yet calculated return 0; } catch (error) { diff --git a/src/main.ts b/src/main.ts index 6797fea9..0ae86893 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,7 +46,7 @@ import { TaskEditModal } from "./modals/TaskEditModal"; import { openTaskSelector } from "./modals/TaskSelectorWithCreateModal"; import { TimeEntryEditorModal } from "./modals/TimeEntryEditorModal"; import { PomodoroService } from "./services/PomodoroService"; -import { formatTime, getActiveTimeEntry } from "./utils/helpers"; +import { formatTime, getActiveTimeEntry, splitFrontmatterAndBody } from "./utils/helpers"; import { convertUTCToLocalCalendarDate, getCurrentTimestamp } from "./utils/dateUtils"; import { TaskManager } from "./utils/TaskManager"; import { DependencyCache } from "./utils/DependencyCache"; @@ -574,6 +574,9 @@ export default class TaskNotesPlugin extends Plugin { // Initialize date change detection to refresh tasks at midnight this.setupDateChangeDetection(); + // Set up listener to update progress when task files are modified directly + this.setupProgressUpdateListener(); + // Defer heavy service initialization until needed this.initializeServicesLazily(); @@ -1052,6 +1055,76 @@ export default class TaskNotesPlugin extends Plugin { this.scheduleNextMidnightCheck(); } + /** + * Set up listener to update progress when task files are modified directly (e.g., checkbox toggled in editor) + */ + private setupProgressUpdateListener(): void { + if (!this.progressService) { + return; + } + + // Debounce map to avoid multiple updates for the same file + const pendingUpdates = new Map(); + + this.registerEvent( + this.app.metadataCache.on("changed", async (file: TFile) => { + // Only process task files + const metadata = this.app.metadataCache.getFileCache(file); + if (!metadata?.frontmatter) { + return; + } + + // Check if this is a task file + const taskInfo = await this.cacheManager.getTaskInfo(file.path); + if (!taskInfo) { + return; + } + + // Clear any pending update for this file + const existingTimeout = pendingUpdates.get(file.path); + if (existingTimeout) { + clearTimeout(existingTimeout); + } + + // Debounce the update to avoid excessive file reads + const timeoutId = setTimeout(async () => { + pendingUpdates.delete(file.path); + try { + // Read file content to check if body changed + const content = await this.app.vault.read(file); + const { body } = splitFrontmatterAndBody(content); + + // Calculate progress + const taskWithDetails: TaskInfo = { + ...taskInfo, + details: body, + }; + const progress = this.progressService.calculateProgress(taskWithDetails); + + // Update frontmatter if progress changed + const currentProgress = metadata.frontmatter?.[this.fieldMapper.toUserField("progress")]; + const newProgressValue = progress ? progress.percentage : null; + + if (currentProgress !== newProgressValue) { + await this.app.fileManager.processFrontMatter(file, (frontmatter) => { + if (newProgressValue !== null) { + frontmatter[this.fieldMapper.toUserField("progress")] = newProgressValue; + } else { + // Remove progress if no checkboxes found + delete frontmatter[this.fieldMapper.toUserField("progress")]; + } + }); + } + } catch (error) { + console.debug(`[TaskNotes] Error updating progress for ${file.path}:`, error); + } + }, 500); // 500ms debounce + + pendingUpdates.set(file.path, timeoutId); + }) + ); + } + /** * Schedule a precise check at the next midnight */ diff --git a/src/services/FieldMapper.ts b/src/services/FieldMapper.ts index 4c45665b..df79e262 100644 --- a/src/services/FieldMapper.ts +++ b/src/services/FieldMapper.ts @@ -183,6 +183,21 @@ export class FieldMapper { mapped.archived = frontmatter.tags.includes(this.mapping.archiveTag); } + // Handle progress (stored as percentage number in frontmatter) + if (this.mapping.progress && frontmatter[this.mapping.progress] !== undefined) { + const progressValue = frontmatter[this.mapping.progress]; + // Convert to ProgressInfo if it's a number (percentage) + if (typeof progressValue === 'number') { + // We only store percentage in frontmatter, so we can't reconstruct completed/total + // This will be recalculated when details are loaded + mapped.progress = { + completed: 0, + total: 0, + percentage: progressValue, + }; + } + } + return mapped; } @@ -290,6 +305,15 @@ export class FieldMapper { frontmatter[this.mapping.reminders] = taskData.reminders; } + // Handle progress (store percentage as number in frontmatter) + if (this.mapping.progress && taskData.progress !== undefined) { + // Store percentage as number for Bases compatibility + frontmatter[this.mapping.progress] = taskData.progress.percentage; + } else if (this.mapping.progress && taskData.progress === null) { + // Remove progress if explicitly set to null + delete frontmatter[this.mapping.progress]; + } + // Handle tags (merge archive status into tags array) let tags = taskData.tags ? [...taskData.tags] : []; diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index c93a3866..2317cc26 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -395,6 +395,24 @@ export class TaskService { finalFrontmatter = { ...finalFrontmatter, ...taskData.customFrontmatter }; } + // Calculate and add progress if details are present + if (normalizedBody && this.plugin.progressService) { + const taskWithDetails: TaskInfo = { + ...completeTaskData, + details: normalizedBody, + } as TaskInfo; + const progress = this.plugin.progressService.calculateProgress(taskWithDetails); + if (progress) { + // Add progress to frontmatter via field mapper + const progressFrontmatter = this.plugin.fieldMapper.mapToFrontmatter( + { progress } as Partial, + taskTagForFrontmatter, + this.plugin.settings.storeTitleInFilename + ); + finalFrontmatter = { ...finalFrontmatter, ...progressFrontmatter }; + } + } + // Prepare file content const yamlHeader = stringifyYaml(finalFrontmatter); let content = `---\n${yamlHeader}---\n\n`; @@ -1362,6 +1380,22 @@ export class TaskService { dateModified: getCurrentTimestamp(), }; + // Calculate and update progress if details changed + if (normalizedDetails !== null && this.plugin.progressService) { + const taskWithDetails: TaskInfo = { + ...originalTask, + ...updates, + details: normalizedDetails, + }; + const progress = this.plugin.progressService.calculateProgress(taskWithDetails); + if (progress) { + completeTaskData.progress = progress; + } else { + // If no progress (no checkboxes), remove progress from frontmatter + completeTaskData.progress = null as any; + } + } + const mappedFrontmatter = this.plugin.fieldMapper.mapToFrontmatter( completeTaskData, this.plugin.settings.taskIdentificationMethod === "tag" @@ -1422,6 +1456,10 @@ export class TaskService { delete frontmatter[this.plugin.fieldMapper.toUserField("recurrence")]; if (updates.hasOwnProperty("blockedBy") && updates.blockedBy === undefined) delete frontmatter[this.plugin.fieldMapper.toUserField("blockedBy")]; + if (normalizedDetails !== null && this.plugin.progressService) { + // Progress is handled above via completeTaskData.progress + // If progress was set to null, it will be removed by mapToFrontmatter + } if (isRenameNeeded) { delete frontmatter[this.plugin.fieldMapper.toUserField("title")]; diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index bffaad7b..06cc0d6a 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -47,6 +47,7 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = { icsEventTag: "ics_event", googleCalendarEventId: "googleCalendarEventId", reminders: "reminders", + progress: "task_progress", // Store as task_progress in frontmatter for Bases compatibility }; // Default status configuration matches current hardcoded behavior diff --git a/src/types.ts b/src/types.ts index 348504ea..b4b2e854 100644 --- a/src/types.ts +++ b/src/types.ts @@ -688,6 +688,7 @@ export interface FieldMapping { icsEventTag: string; // Tag used for ICS event-related content googleCalendarEventId: string; // For Google Calendar sync (stores event ID) reminders: string; // For task reminders + progress: string; // For task progress percentage (0-100) based on top-level checkboxes } export interface StatusConfig { From 0f73b5c7581dea4f12ceec500a0a3813fccefad9 Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 14:58:20 +0100 Subject: [PATCH 7/9] fix: property naming --- src/bases/BasesDataAdapter.ts | 14 +++++++++----- src/settings/defaults.ts | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/bases/BasesDataAdapter.ts b/src/bases/BasesDataAdapter.ts index 4475aae2..0c5a9e6b 100644 --- a/src/bases/BasesDataAdapter.ts +++ b/src/bases/BasesDataAdapter.ts @@ -207,18 +207,22 @@ export class BasesDataAdapter { * Extracts frontmatter and basic file properties only (cheap operations). * Computed file properties (backlinks, links, etc.) are fetched lazily via getComputedProperty(). * - * Also adds 'task.progress' property mapped from frontmatter 'task_progress' so Bases can discover it. + * Also adds 'task.progress' property mapped from frontmatter progress field so Bases can discover it. */ private extractEntryProperties(entry: any): Record { const frontmatter = (entry as any).frontmatter || (entry as any).properties || {}; const result = { ...frontmatter }; - // Map task_progress from frontmatter to task.progress for Bases compatibility - // Progress is now stored in frontmatter as task_progress (percentage number) + // Map progress from frontmatter to task.progress for Bases compatibility + // Progress is stored in frontmatter using the configured field name (default: task_progress) // We expose it as task.progress so Bases can use it for sorting/filtering - if (frontmatter['task_progress'] !== undefined) { - result['task.progress'] = frontmatter['task_progress']; + // Get the configured field name from the plugin via basesView + const plugin = (this.basesView as any)?.plugin; + const progressFieldName = plugin?.fieldMapper?.toUserField?.('progress') || 'task_progress'; + + if (frontmatter[progressFieldName] !== undefined) { + result['task.progress'] = frontmatter[progressFieldName]; } else { // If no progress in frontmatter, set to 0 (number) to ensure Bases recognizes it as a number property result['task.progress'] = 0; diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 06cc0d6a..fad36d9b 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -47,7 +47,7 @@ export const DEFAULT_FIELD_MAPPING: FieldMapping = { icsEventTag: "ics_event", googleCalendarEventId: "googleCalendarEventId", reminders: "reminders", - progress: "task_progress", // Store as task_progress in frontmatter for Bases compatibility + progress: "progress", // Store as progress in frontmatter }; // Default status configuration matches current hardcoded behavior From 8372fce8af94a4523b376db21f7981c40278b158 Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 15:02:17 +0100 Subject: [PATCH 8/9] docs: document feature --- docs/features.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/features.md b/docs/features.md index e1288beb..9566072b 100644 --- a/docs/features.md +++ b/docs/features.md @@ -38,6 +38,12 @@ ICS export allows other systems to access task data with automatic updates. The See [Calendar Integration](features/calendar-integration.md) for details. +## Progress Tracking + +TaskNotes automatically calculates task completion progress based on first-level checkboxes in the task body. A visual progress bar displays completion percentage, similar to Trello. Progress is available in Bases views for sorting and filtering. + +See [Progress Tracking](features/progress-tracking.md) for details. + ## User Fields Custom fields extend task structure with additional data. These fields work in filtering, sorting, and templates. From ba126978daee8cd679044ab50ae5b62e2aa1377e Mon Sep 17 00:00:00 2001 From: phortx Date: Fri, 16 Jan 2026 15:03:50 +0100 Subject: [PATCH 9/9] docs: add actual feature doc file --- docs/features/progress-tracking.md | 194 +++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/features/progress-tracking.md diff --git a/docs/features/progress-tracking.md b/docs/features/progress-tracking.md new file mode 100644 index 00000000..d3f4d791 --- /dev/null +++ b/docs/features/progress-tracking.md @@ -0,0 +1,194 @@ +# Progress Tracking + +TaskNotes automatically calculates and displays task completion progress based on first-level checkboxes in the task body. This feature provides a visual progress bar similar to Trello, helping you track completion status at a glance. + +[← Back to Features](../features.md) + +## Overview + +The progress feature counts checkboxes at the first indentation level (no indentation) in your task's body content. It calculates completion percentage and displays it as a progress bar or text in task cards. + +![Progress Bar Example](../assets/progress-bar-example.png) + +## How It Works + +### Checkbox Detection + +Progress is calculated by analyzing the task body for checkboxes. Only **first-level checkboxes** (no indentation) are counted: + +```markdown +--- +title: Complete project setup +status: in-progress +progress: 50 +--- + +- [x] Set up repository +- [x] Configure CI/CD +- [ ] Write documentation +- [ ] Deploy to staging +``` + +In this example, there are 4 first-level checkboxes, 2 completed, resulting in 50% progress. + +### Nested Checkboxes + +Nested checkboxes (indented) are **not** counted: + +```markdown +- [x] Main task + - [x] Subtask 1 (not counted) + - [ ] Subtask 2 (not counted) +- [ ] Another main task +``` + +Only the two main tasks are counted for progress calculation. + +## Display Modes + +You can configure how progress is displayed in task cards through **Settings → Appearance & UI → Progress Bar**: + +### Bar Only +Shows only the visual progress bar without text. + +### Text Only +Shows only the text representation (e.g., "2/4 (50%)") without the bar. + +### Bar with Text (Default) +Shows both the progress bar and text representation. + +## Empty State Handling + +When a task has no checkboxes, you can configure how to handle the empty state: + +- **Show Zero**: Display "0/0 (0%)" even when there are no checkboxes +- **Hide**: Don't show progress at all when there are no checkboxes + +Configure this in **Settings → Appearance & UI → Progress Bar → Empty State**. + +## Using Progress in Bases Views + +Progress is available as `task.progress` in all Bases views (Table, Kanban, Calendar, etc.) and can be used for: + +### Sorting +Sort tasks by progress percentage to see which tasks are most complete. + +### Filtering +Filter tasks by progress using Bases filter expressions: + +``` +task.progress >= 50 +``` + +This shows all tasks that are at least 50% complete. + +### Grouping +Group tasks by progress ranges to organize your workflow: + +``` +task.progress < 25 → "Just Started" +task.progress >= 25 && task.progress < 75 → "In Progress" +task.progress >= 75 → "Nearly Done" +``` + +## Automatic Updates + +Progress is automatically recalculated and updated in the frontmatter when: + +- **Task details change**: When you update task body content via the task modal +- **Direct file edits**: When you toggle checkboxes directly in the editor +- **Task creation**: When creating new tasks with body content + +The progress value is stored in the task's frontmatter as a number (0-100), making it available for Bases sorting and filtering. + +## Frontmatter Storage + +Progress is stored in the task's frontmatter as a percentage number: + +```yaml +--- +title: Complete project setup +status: in-progress +progress: 50 +--- +``` + +The `progress` field contains the completion percentage (0-100) and is automatically updated when checkboxes change. + +## Performance + +Progress calculation is optimized for performance: + +- **Lazy Loading**: Progress is only calculated when the property is visible in task cards +- **Caching**: Calculated progress is cached to avoid redundant calculations +- **Automatic Cache Invalidation**: Cache is cleared when task content changes + +## Settings + +Configure progress display options in **Settings → Appearance & UI → Progress Bar**: + +- **Display Mode**: Choose between bar-only, text-only, or bar-with-text +- **Show Percentage**: Toggle percentage display in text mode +- **Show Count**: Toggle checkbox count display (e.g., "2/4") +- **Empty State**: Configure how to handle tasks without checkboxes + +## Examples + +### Project Milestones + +Track project completion with checkboxes: + +```markdown +--- +title: Q1 Product Launch +status: in-progress +progress: 60 +--- + +- [x] Market research +- [x] Product design +- [x] Development sprint 1 +- [ ] Development sprint 2 +- [ ] Testing phase +- [ ] Launch preparation +``` + +### Task Breakdown + +Break down complex tasks into checkboxes: + +```markdown +--- +title: Write technical documentation +status: in-progress +progress: 33 +--- + +- [x] Outline structure +- [ ] Write API documentation +- [ ] Write user guide +``` + +### Daily Checklist + +Use progress to track daily task completion: + +```markdown +--- +title: Daily standup preparation +status: open +progress: 0 +--- + +- [ ] Review yesterday's tasks +- [ ] Update task statuses +- [ ] Prepare today's priorities +- [ ] Check team blockers +``` + +## Tips + +- **Use consistent formatting**: Keep checkboxes at the first level for accurate counting +- **Update frequently**: Progress updates automatically, but you may need to save the file for changes to reflect +- **Combine with filters**: Use progress filters in Bases views to focus on tasks at specific completion stages +- **Visual feedback**: The progress bar provides quick visual feedback on task completion status