feat(timer): add timer statistics panel and Working On view

- Add TimerStatisticsPanel component for displaying active and completed timers
- Implement completed timer history with configurable max records
- Add timer controls (start/pause/resume/stop) to task list and tree views
- Integrate timer auto-start option in quick capture settings
- Add Working On navigation item for viewing active timers
- Refactor timer manager with unified storage abstraction
- Add i18n translations for timer features (en/zh-cn)
- Add timer statistics CSS styles
This commit is contained in:
Quorafind 2025-12-09 11:38:26 +08:00
parent 14ca05a9da
commit 9be6bf7580
21 changed files with 3125 additions and 610 deletions

@ -1 +1 @@
Subproject commit 3cb570633ae01ab569b26a956639fc167da398ba
Subproject commit 3107a754e650b64cb5dd68101ebbad99ac422f27

View file

@ -428,6 +428,8 @@ export interface QuickCaptureSettings {
templateFile: string; // Template file path
writeContentTagsToFrontmatter?: boolean; // When true, write #tags from content into frontmatter.tags (merged, deduped)
};
// Timer integration settings
autoStartTimer?: boolean; // Whether to auto-start timer when creating task (default false)
}
/** Define the structure for task gutter settings */
@ -1192,6 +1194,8 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
templateFile: "",
writeContentTagsToFrontmatter: false,
},
// Timer integration
autoStartTimer: false,
},
// Workflow Defaults
@ -1370,6 +1374,16 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
filterRules: {},
filterBlanks: false,
},
{
id: "working-on",
name: t("Working On"),
icon: "timer",
type: "default",
visible: true,
hideCompletedAndAbandonedTasks: true,
filterRules: {},
filterBlanks: false,
},
{
id: "forecast",
name: t("Forecast"),

View file

@ -22,6 +22,7 @@ import {
getNextStatusPrimary,
getAllStatusMarks,
} from "@/utils/status-cycle-resolver";
import { TaskTimerManager } from "@/managers/timer-manager";
/**
* FluentActionHandlers - Handles all user actions and task operations
@ -56,7 +57,7 @@ export class FluentActionHandlers extends Component {
private app: App,
private plugin: TaskProgressBarPlugin,
private getWorkspaceId: () => string,
private useSideLeaves: () => boolean,
private useSideLeaves: () => boolean
) {
super();
}
@ -173,7 +174,7 @@ export class FluentActionHandlers extends Component {
async handleTaskUpdate(
originalTask: Task,
updatedTask: Task,
successMessage?: string,
successMessage?: string
): Promise<void> {
if (!this.plugin.writeAPI) {
console.error("WriteAPI not available");
@ -183,7 +184,7 @@ export class FluentActionHandlers extends Component {
try {
const updates = this.extractChangedFields(
originalTask,
updatedTask,
updatedTask
);
const writeResult = await this.plugin.writeAPI.updateTask({
taskId: originalTask.id,
@ -211,13 +212,13 @@ export class FluentActionHandlers extends Component {
*/
async handleKanbanTaskStatusUpdate(
task: Task,
newStatusMark: string,
newStatusMark: string
): Promise<void> {
console.log(
`[FluentActionHandlers] Processing kanban status update for task ${task.id}`,
`[FluentActionHandlers] Processing kanban status update for task ${task.id}`
);
console.log(
`[FluentActionHandlers] Status change: ${task.status} -> ${newStatusMark}`,
`[FluentActionHandlers] Status change: ${task.status} -> ${newStatusMark}`
);
const isCompleted = this.isCompletedMark(newStatusMark);
@ -225,7 +226,7 @@ export class FluentActionHandlers extends Component {
if (task.status !== newStatusMark || task.completed !== isCompleted) {
console.log(
"[FluentActionHandlers] Status change detected, calling handleTaskUpdate...",
"[FluentActionHandlers] Status change detected, calling handleTaskUpdate..."
);
await this.handleTaskUpdate(task, {
...task,
@ -239,7 +240,7 @@ export class FluentActionHandlers extends Component {
console.log("[FluentActionHandlers] handleTaskUpdate completed");
} else {
console.log(
"[FluentActionHandlers] No status change needed, skipping update",
"[FluentActionHandlers] No status change needed, skipping update"
);
}
}
@ -272,7 +273,7 @@ export class FluentActionHandlers extends Component {
const currentMark = task.status || " ";
const applicableCycles = findApplicableCycles(
currentMark,
this.plugin.settings.statusCycles,
this.plugin.settings.statusCycles
);
if (applicableCycles.length > 0) {
@ -285,7 +286,7 @@ export class FluentActionHandlers extends Component {
for (const cycle of applicableCycles) {
const nextStatusResult = getNextStatusPrimary(
currentMark,
[cycle],
[cycle]
);
if (nextStatusResult) {
submenu.addItem((subItem) => {
@ -300,9 +301,9 @@ export class FluentActionHandlers extends Component {
createTaskCheckbox(
nextStatusResult.mark,
task,
el,
el
);
},
}
);
subItem.titleEl.createEl("span", {
cls: "status-option",
@ -310,7 +311,7 @@ export class FluentActionHandlers extends Component {
});
subItem.onClick(async () => {
const willComplete = this.isCompletedMark(
nextStatusResult.mark,
nextStatusResult.mark
);
const updatedTask = {
...task,
@ -331,7 +332,7 @@ export class FluentActionHandlers extends Component {
await this.handleKanbanTaskStatusUpdate(
updatedTask,
nextStatusResult.mark,
nextStatusResult.mark
);
});
});
@ -347,7 +348,7 @@ export class FluentActionHandlers extends Component {
// Show all available statuses
const allStatusNames = getAllStatusNames(
this.plugin.settings.statusCycles,
this.plugin.settings.statusCycles
);
for (const statusName of Array.from(allStatusNames)) {
// Find the mark for this status
@ -367,7 +368,7 @@ export class FluentActionHandlers extends Component {
},
(el) => {
createTaskCheckbox(mark, task, el);
},
}
);
subItem.titleEl.createEl("span", {
cls: "status-option",
@ -389,7 +390,7 @@ export class FluentActionHandlers extends Component {
await this.handleKanbanTaskStatusUpdate(
updatedTask,
mark,
mark
);
});
});
@ -408,7 +409,7 @@ export class FluentActionHandlers extends Component {
},
(el) => {
createTaskCheckbox(mark, task, el);
},
}
);
subItem.titleEl.createEl("span", {
cls: "status-option",
@ -438,6 +439,9 @@ export class FluentActionHandlers extends Component {
this.addPriorityMenuItems(menu, task);
this.addDateMenuItems(menu, task);
// Timer menu items
this.addTimerMenuItems(menu, task);
menu.addSeparator();
menu.addItem((item) => {
item.setTitle(t("Set Project"));
@ -562,7 +566,7 @@ export class FluentActionHandlers extends Component {
private addPostponeDateMenu(
menu: Menu,
task: Task,
dateType: TaskDateType,
dateType: TaskDateType
): void {
menu.addItem((item) => {
const subMenu = item
@ -600,7 +604,7 @@ export class FluentActionHandlers extends Component {
private async postponeDate(
task: Task,
dateType: TaskDateType,
offsetDays: number,
offsetDays: number
): Promise<void> {
try {
const currentTimestamp = task.metadata?.[dateType];
@ -614,7 +618,7 @@ export class FluentActionHandlers extends Component {
task,
dateType,
newTimestamp,
offsetDays,
offsetDays
);
const updatedTask: Task = {
@ -624,23 +628,172 @@ export class FluentActionHandlers extends Component {
const action = offsetDays > 0 ? "postponed" : "advanced";
const message = `${this.getDateLabel(
dateType,
dateType
)} ${action} by ${Math.abs(offsetDays)} day(s)`;
await this.handleTaskUpdate(task, updatedTask, t(message));
} catch (error) {
console.error(
"[FluentActionHandlers] Failed to postpone date:",
error,
error
);
new Notice(t("Failed to postpone date"));
}
}
/**
* Add timer-related menu items
*/
private addTimerMenuItems(menu: Menu, task: Task): void {
// Only show timer options if timer feature is enabled
if (!this.plugin.settings.taskTimer?.enabled) {
return;
}
menu.addSeparator();
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer
);
// Check if task has an existing block ID and active timer
const blockId = task.metadata?.id;
const existingTimer = blockId
? timerManager.getTimerByFileAndBlock(task.filePath, blockId)
: null;
if (existingTimer && existingTimer.status === "running") {
// Timer is running - show pause and stop options
menu.addItem((item) => {
item.setTitle(t("Pause Timer"));
item.setIcon("pause");
item.onClick(() => {
const taskId = `taskTimer_${task.filePath}#${blockId}`;
timerManager.pauseTimer(taskId);
new Notice(t("Timer paused"));
});
});
menu.addItem((item) => {
item.setTitle(t("Stop Timer"));
item.setIcon("square");
item.onClick(async () => {
const taskId = `taskTimer_${task.filePath}#${blockId}`;
const duration = timerManager.completeTimer(taskId);
new Notice(t("Timer stopped") + `: ${duration}`);
});
});
} else if (existingTimer && existingTimer.status === "paused") {
// Timer is paused - show resume and stop options
menu.addItem((item) => {
item.setTitle(t("Resume Timer"));
item.setIcon("play");
item.onClick(() => {
const taskId = `taskTimer_${task.filePath}#${blockId}`;
timerManager.resumeTimer(taskId);
new Notice(t("Timer resumed"));
});
});
menu.addItem((item) => {
item.setTitle(t("Stop Timer"));
item.setIcon("square");
item.onClick(async () => {
const taskId = `taskTimer_${task.filePath}#${blockId}`;
const duration = timerManager.completeTimer(taskId);
new Notice(t("Timer stopped") + `: ${duration}`);
});
});
} else {
// No active timer - show start option
menu.addItem((item) => {
item.setTitle(t("Start Timer"));
item.setIcon("timer");
item.onClick(async () => {
await this.startTimerForTask(task);
});
});
}
}
/**
* Start a timer for a task
* If the task doesn't have a block ID, generate one and update the task
*/
private async startTimerForTask(task: Task): Promise<void> {
try {
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer
);
let blockId = task.metadata?.id;
// If task doesn't have a block ID, generate one and save it
if (!blockId) {
blockId = timerManager.generateBlockId();
// Update task with the new block ID
const updatedTask: Task = {
...task,
metadata: {
...task.metadata,
id: blockId,
},
};
// Save the block ID to the task
await this.handleTaskUpdate(
task,
updatedTask,
t("Block ID added")
);
// Update local reference
task = updatedTask;
}
// Change task status to In Progress if not already
const inProgressMarks = (
this.plugin.settings.taskStatuses.inProgress || ">|/"
).split("|");
const completedMarks = (
this.plugin.settings.taskStatuses.completed || "x|X"
).split("|");
// Only change status if not completed and not already in progress
if (
!task.completed &&
!inProgressMarks.includes(task.status || " ")
) {
const inProgressMark = inProgressMarks[0] || "/";
const statusUpdatedTask: Task = {
...task,
status: inProgressMark,
};
await this.handleTaskUpdate(
task,
statusUpdatedTask,
t("Task status updated")
);
}
// Start the timer
timerManager.startTimer(task.filePath, blockId);
new Notice(t("Timer started"));
} catch (error) {
console.error(
"[FluentActionHandlers] Failed to start timer:",
error
);
new Notice(t("Failed to start timer"));
}
}
private openDatePicker(
task: Task,
dateType: TaskDateType,
isPostpone: boolean = false,
isPostpone: boolean = false
): void {
const currentDate = task.metadata?.[dateType];
const initialDate =
@ -652,7 +805,7 @@ export class FluentActionHandlers extends Component {
this.app,
this.plugin,
initialDate,
this.getDateMark(dateType),
this.getDateMark(dateType)
);
modal.onDateSelected = async (dateStr: string | null) => {
@ -666,14 +819,14 @@ export class FluentActionHandlers extends Component {
if (isPostpone && typeof currentDate === "number") {
const offsetDays = Math.round(
(newTimestamp - currentDate) / (24 * 60 * 60 * 1000),
(newTimestamp - currentDate) / (24 * 60 * 60 * 1000)
);
const updatedMetadata = smartPostponeRelatedDates(
task,
dateType,
newTimestamp,
offsetDays,
offsetDays
);
const updatedTask: Task = {
@ -684,7 +837,7 @@ export class FluentActionHandlers extends Component {
await this.handleTaskUpdate(
task,
updatedTask,
t(`${this.getDateLabel(dateType)} updated`),
t(`${this.getDateLabel(dateType)} updated`)
);
} else {
const updatedTask: Task = {
@ -698,7 +851,7 @@ export class FluentActionHandlers extends Component {
await this.handleTaskUpdate(
task,
updatedTask,
t(`${this.getDateLabel(dateType)} updated`),
t(`${this.getDateLabel(dateType)} updated`)
);
}
};
@ -809,7 +962,7 @@ export class FluentActionHandlers extends Component {
} catch (error) {
console.error(
"[FluentActionHandlers] Failed to duplicate task:",
error,
error
);
new Notice(t("Failed to duplicate task"));
}
@ -905,7 +1058,7 @@ export class FluentActionHandlers extends Component {
*/
private async deleteTask(
task: Task,
deleteChildren: boolean,
deleteChildren: boolean
): Promise<void> {
if (!this.plugin.writeAPI) {
console.error("WriteAPI not available for deleteTask");
@ -933,13 +1086,13 @@ export class FluentActionHandlers extends Component {
new Notice(
t("Failed to delete task") +
": " +
(result.error || "Unknown error"),
(result.error || "Unknown error")
);
}
} catch (error) {
console.error("Error deleting task:", error);
new Notice(
t("Failed to delete task") + ": " + (error as any).message,
t("Failed to delete task") + ": " + (error as any).message
);
}
}
@ -995,7 +1148,7 @@ export class FluentActionHandlers extends Component {
try {
const lower = mark.toLowerCase();
const completedCfg = String(
this.plugin.settings.taskStatuses?.completed || "x",
this.plugin.settings.taskStatuses?.completed || "x"
);
const completedSet = completedCfg
.split("|")
@ -1012,7 +1165,7 @@ export class FluentActionHandlers extends Component {
*/
private extractChangedFields(
originalTask: Task,
updatedTask: Task,
updatedTask: Task
): Partial<Task> {
const changes: Partial<Task> = {};
@ -1041,6 +1194,7 @@ export class FluentActionHandlers extends Component {
"scheduledDate",
"completedDate",
"recurrence",
"id", // Block ID for timer tracking
];
for (const field of metadataFields) {

View file

@ -39,6 +39,7 @@ const VIEW_MODE_CONFIG: Record<string, ViewMode[]> = {
today: ["list", "tree", "kanban", "calendar"],
upcoming: ["list", "tree", "kanban", "calendar"],
flagged: ["list", "tree", "kanban", "calendar"],
"working-on": ["list", "tree", "kanban", "calendar"],
// Projects: empty by default (overview mode), but FluentTaskView enables modes when a project is selected
projects: [],
@ -109,7 +110,7 @@ export class FluentComponentManager extends Component {
onTaskContextMenu: (event: MouseEvent, task: Task) => void;
onKanbanTaskStatusUpdate: (
taskId: string,
newStatusMark: string,
newStatusMark: string
) => void;
};
@ -123,15 +124,15 @@ export class FluentComponentManager extends Component {
onTaskCompleted: (task: Task) => void;
onTaskUpdate: (
originalTask: Task,
updatedTask: Task,
updatedTask: Task
) => Promise<void>;
onTaskContextMenu: (event: MouseEvent, task: Task) => void;
onKanbanTaskStatusUpdate: (
taskId: string,
newStatusMark: string,
newStatusMark: string
) => void;
},
private selectionManager?: TaskSelectionManager,
private selectionManager?: TaskSelectionManager
) {
super();
this.viewHandlers = viewHandlers;
@ -169,7 +170,7 @@ export class FluentComponentManager extends Component {
this.app,
this.plugin,
this.contentArea,
viewHandlers,
viewHandlers
);
this.parentView.addChild(this.viewComponentManager);
@ -190,7 +191,7 @@ export class FluentComponentManager extends Component {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(
originalTask,
updatedTask,
updatedTask
);
}
},
@ -198,7 +199,7 @@ export class FluentComponentManager extends Component {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
selectionManager: this.selectionManager,
},
}
);
this.parentView.addChild(this.contentComponent);
this.contentComponent.load();
@ -219,14 +220,14 @@ export class FluentComponentManager extends Component {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(
originalTask,
updatedTask,
updatedTask
);
}
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
},
}
);
this.parentView.addChild(this.forecastComponent);
this.forecastComponent.load();
@ -247,14 +248,14 @@ export class FluentComponentManager extends Component {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(
originalTask,
updatedTask,
updatedTask
);
}
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
},
}
);
this.parentView.addChild(this.tagsComponent);
this.tagsComponent.load();
@ -275,14 +276,14 @@ export class FluentComponentManager extends Component {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(
originalTask,
updatedTask,
updatedTask
);
}
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
},
}
);
this.parentView.addChild(this.projectsComponent);
this.projectsComponent.load();
@ -303,14 +304,14 @@ export class FluentComponentManager extends Component {
if (originalTask && updatedTask) {
await this.viewHandlers.onTaskUpdate(
originalTask,
updatedTask,
updatedTask
);
}
},
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
},
}
);
this.parentView.addChild(this.reviewComponent);
this.reviewComponent.load();
@ -336,7 +337,7 @@ export class FluentComponentManager extends Component {
onEventContextMenu: (ev: MouseEvent, event: Task) => {
if (event) this.viewHandlers.onTaskContextMenu(ev, event);
},
},
}
);
this.parentView.addChild(this.calendarComponent);
@ -350,7 +351,7 @@ export class FluentComponentManager extends Component {
onTaskStatusUpdate: async (taskId, newStatusMark) => {
this.viewHandlers.onKanbanTaskStatusUpdate(
taskId,
newStatusMark,
newStatusMark
);
},
onTaskSelected: (task) => {
@ -362,7 +363,7 @@ export class FluentComponentManager extends Component {
onTaskContextMenu: (event, task) => {
if (task) this.viewHandlers.onTaskContextMenu(event, task);
},
},
}
);
this.parentView.addChild(this.kanbanComponent);
@ -377,7 +378,7 @@ export class FluentComponentManager extends Component {
this.viewHandlers.onTaskCompleted(task),
onTaskContextMenu: (event: MouseEvent, task: Task) =>
this.viewHandlers.onTaskContextMenu(event, task),
},
}
);
this.parentView.addChild(this.ganttComponent);
this.ganttComponent.load();
@ -408,7 +409,7 @@ export class FluentComponentManager extends Component {
// Smart hiding - only hide currently visible component (unless initial hide)
if (!isInitialHide && this.currentVisibleComponent) {
console.log(
"[FluentComponent] Smart hide - only hiding current visible component",
"[FluentComponent] Smart hide - only hiding current visible component"
);
this.currentVisibleComponent.containerEl?.hide();
this.currentVisibleComponent = null;
@ -416,7 +417,7 @@ export class FluentComponentManager extends Component {
// Hide all components
console.log(
"[FluentComponent] Hiding all components",
isInitialHide ? "(initial hide)" : "",
isInitialHide ? "(initial hide)" : ""
);
this.contentComponent?.containerEl.hide();
this.forecastComponent?.containerEl.hide();
@ -454,20 +455,20 @@ export class FluentComponentManager extends Component {
filteredTasks: Task[],
currentFilterState: RootFilterState | null,
viewMode: ViewMode,
project?: string | null,
project?: string | null
): void {
console.log(
"[FluentComponent] switchView called with:",
viewId,
"viewMode:",
viewMode,
viewMode
);
// Remove transient overlays (loading/error/empty) before showing components
if (this.contentArea) {
this.contentArea
.querySelectorAll(
".tg-fluent-loading, .tg-fluent-error-state, .tg-fluent-empty-state",
".tg-fluent-loading, .tg-fluent-error-state, .tg-fluent-empty-state"
)
.forEach((el) => el.remove());
}
@ -500,7 +501,7 @@ export class FluentComponentManager extends Component {
viewId,
tasks,
filteredTasks,
viewMode,
viewMode
);
return;
}
@ -521,7 +522,7 @@ export class FluentComponentManager extends Component {
this.app,
this.plugin,
twoColumnConfig,
viewId,
viewId
);
this.parentView.addChild(twoColumnComponent);
@ -539,7 +540,7 @@ export class FluentComponentManager extends Component {
const twoColumnComponent = this.twoColumnViewComponents.get(viewId);
if (!twoColumnComponent) {
console.warn(
`[FluentComponent] Missing two column component for view ${viewId}`,
`[FluentComponent] Missing two column component for view ${viewId}`
);
return;
}
@ -551,7 +552,7 @@ export class FluentComponentManager extends Component {
// Check if it's a special view managed by ViewComponentManager
if (this.viewComponentManager.isSpecialView(viewId)) {
targetComponent = this.viewComponentManager.showComponent(
viewId,
viewId
) as ManagedViewComponent | null;
} else if (
specificViewType === "forecast" ||
@ -573,13 +574,13 @@ export class FluentComponentManager extends Component {
// 2. Without project selection (from view navigation) → use ProjectsComponent for overview
if (project) {
console.log(
"[FluentComponent] Projects view with selected project - using ContentComponent",
"[FluentComponent] Projects view with selected project - using ContentComponent"
);
targetComponent = this.contentComponent;
modeForComponent = viewId;
} else {
console.log(
"[FluentComponent] Projects view without selection - using ProjectsComponent",
"[FluentComponent] Projects view without selection - using ProjectsComponent"
);
targetComponent = this.projectsComponent;
modeForComponent = viewId;
@ -612,13 +613,13 @@ export class FluentComponentManager extends Component {
console.log(
"[FluentComponent] Target component determined:",
targetComponent?.constructor?.name,
targetComponent?.constructor?.name
);
if (targetComponent) {
console.log(
`[FluentComponent] Activating component for view ${viewId}:`,
targetComponent.constructor.name,
targetComponent.constructor.name
);
targetComponent.containerEl.show();
this.currentVisibleComponent = targetComponent;
@ -629,7 +630,7 @@ export class FluentComponentManager extends Component {
this.topNavigation
) {
console.log(
"[FluentComponent] Setting TopNavigation reference for ContentComponent",
"[FluentComponent] Setting TopNavigation reference for ContentComponent"
);
this.contentComponent.setTopNavigation(this.topNavigation);
}
@ -637,7 +638,7 @@ export class FluentComponentManager extends Component {
// Set view mode first for ContentComponent
if (typeof targetComponent.setViewMode === "function") {
console.log(
`[FluentComponent] Setting view mode for ${viewId} to ${modeForComponent}`,
`[FluentComponent] Setting view mode for ${viewId} to ${modeForComponent}`
);
targetComponent.setViewMode(modeForComponent as any, project);
}
@ -648,14 +649,14 @@ export class FluentComponentManager extends Component {
// Tags view: uses filtered tasks for tag index (left sidebar), all tasks for tree view lookup
if (viewId === "tags") {
console.log(
`[FluentComponent] Calling setTasks for ${viewId} with FILTERED tasks (${filteredTasks.length}) and ALL tasks (${tasks.length})`,
`[FluentComponent] Calling setTasks for ${viewId} with FILTERED tasks (${filteredTasks.length}) and ALL tasks (${tasks.length})`
);
targetComponent.setTasks(filteredTasks, tasks);
} else if (viewId === "review") {
// Review view still needs all tasks
console.log(
`[FluentComponent] Calling setTasks for ${viewId} with ALL tasks:`,
tasks.length,
tasks.length
);
targetComponent.setTasks(tasks);
} else if (viewId === "projects" && !project) {
@ -663,7 +664,7 @@ export class FluentComponentManager extends Component {
// First param: filtered tasks (for building sidebar project index)
// Second param: all tasks (for tree view parent-child lookup)
console.log(
`[FluentComponent] Calling setTasks for projects with FILTERED tasks (${filteredTasks.length}) and ALL tasks (${tasks.length})`,
`[FluentComponent] Calling setTasks for projects with FILTERED tasks (${filteredTasks.length}) and ALL tasks (${tasks.length})`
);
targetComponent.setTasks(filteredTasks, tasks);
} else {
@ -672,14 +673,14 @@ export class FluentComponentManager extends Component {
// Forecast view: remove badge-only items
if (viewId === "forecast") {
filteredTasksLocal = filteredTasksLocal.filter(
(task) => !(task as any).badge,
(task) => !(task as any).badge
);
}
console.log(
"[FluentComponent] Calling setTasks with filtered:",
filteredTasksLocal.length,
"all:",
tasks.length,
tasks.length
);
targetComponent.setTasks(filteredTasksLocal, tasks);
}
@ -701,8 +702,8 @@ export class FluentComponentManager extends Component {
tasks,
viewId as any,
this.plugin,
filterOptions,
),
filterOptions
)
);
}
@ -715,7 +716,7 @@ export class FluentComponentManager extends Component {
}
} else {
console.warn(
`[FluentComponent] No target component found for viewId: ${viewId}`,
`[FluentComponent] No target component found for viewId: ${viewId}`
);
}
}
@ -727,11 +728,11 @@ export class FluentComponentManager extends Component {
viewId: string,
tasks: Task[],
filteredTasks: Task[],
viewMode: ViewMode,
viewMode: ViewMode
): void {
console.log(
"[FluentComponent] renderContentWithViewMode called, viewMode:",
viewMode,
viewMode
);
// Hide current component
@ -763,7 +764,7 @@ export class FluentComponentManager extends Component {
console.log(
"[FluentComponent] Setting tasks to ContentComponent, filtered:",
filteredTasks.length,
filteredTasks.length
);
this.contentComponent.setTasks(filteredTasks, tasks);
this.currentVisibleComponent = this.contentComponent;
@ -786,14 +787,14 @@ export class FluentComponentManager extends Component {
(cycleId: string | null) => {
console.log(
"[FluentComponent] Cycle changed to:",
cycleId,
cycleId
);
// Update kanban component's selected cycle
this.kanbanComponent["selectedCycleId"] = cycleId;
this.kanbanComponent["saveCycleSelection"]();
// Re-render columns with new cycle
this.kanbanComponent["renderColumns"]();
},
}
);
// Show cycle selector with current selection
@ -803,7 +804,7 @@ export class FluentComponentManager extends Component {
console.log(
"[FluentComponent] Setting",
filteredTasks.length,
"tasks to kanban",
"tasks to kanban"
);
this.kanbanComponent.setTasks(filteredTasks);
this.currentVisibleComponent = this.kanbanComponent;
@ -812,11 +813,11 @@ export class FluentComponentManager extends Component {
case "calendar":
// Use CalendarComponent
console.log(
"[FluentComponent] Calendar mode in renderContentWithViewMode",
"[FluentComponent] Calendar mode in renderContentWithViewMode"
);
if (!this.calendarComponent) {
console.log(
"[FluentComponent] No calendar component available!",
"[FluentComponent] No calendar component available!"
);
return;
}
@ -827,7 +828,7 @@ export class FluentComponentManager extends Component {
console.log(
"[FluentComponent] Setting",
filteredTasks.length,
"tasks to calendar",
"tasks to calendar"
);
this.calendarComponent.setTasks(filteredTasks);
this.currentVisibleComponent = this.calendarComponent;
@ -843,7 +844,7 @@ export class FluentComponentManager extends Component {
viewId: string,
tasks: Task[],
filteredTasks: Task[],
viewMode: ViewMode,
viewMode: ViewMode
): void {
// Content-based views (list/tree/kanban/calendar)
if (this.isContentBasedView(viewId)) {
@ -860,7 +861,7 @@ export class FluentComponentManager extends Component {
this.contentComponent?.setTasks?.(
filteredTasks,
tasks,
true,
true
);
break;
}
@ -936,7 +937,7 @@ export class FluentComponentManager extends Component {
*/
renderErrorState(
context: ErrorContext | string,
onRetry: () => void,
onRetry: () => void
): void {
if (!this.contentArea) return;
@ -1001,7 +1002,7 @@ export class FluentComponentManager extends Component {
*/
private createErrorContext(
errorEl: HTMLElement,
errorContext: ErrorContext,
errorContext: ErrorContext
): void {
if (
!errorContext.viewId &&
@ -1043,7 +1044,7 @@ export class FluentComponentManager extends Component {
*/
private createErrorMessage(
errorEl: HTMLElement,
errorContext: ErrorContext,
errorContext: ErrorContext
): void {
const userMessage =
errorContext.userMessage ||
@ -1061,7 +1062,7 @@ export class FluentComponentManager extends Component {
*/
private createTechnicalDetails(
errorEl: HTMLElement,
errorContext: ErrorContext,
errorContext: ErrorContext
): void {
if (!errorContext.originalError) return;
@ -1159,7 +1160,7 @@ export class FluentComponentManager extends Component {
emptyEl.createDiv({
cls: "tg-fluent-empty-description",
text: t(
"Create your first task to get started with Task Genius",
"Create your first task to get started with Task Genius"
),
});
@ -1193,12 +1194,12 @@ export class FluentComponentManager extends Component {
*/
getAvailableModesForView(
viewId: string,
selectedProject?: string | null,
selectedProject?: string | null
): ViewMode[] {
// Check for special two-column views
const viewConfig = getViewSettingOrDefault(
this.plugin,
viewId as ViewMode,
viewId as ViewMode
);
if (viewConfig?.specificConfig?.viewType === "twocolumn") {
return [];

View file

@ -6,6 +6,7 @@ import { RootFilterState } from "@/components/features/task/filter/ViewTaskFilte
import { isDataflowEnabled } from "@/dataflow/createDataflow";
import { Events, on } from "@/dataflow/events/Events";
import { sortTasks } from "@/commands/sortTaskCommands";
import { TaskTimerManager } from "@/managers/timer-manager";
/**
* FluentDataManager - Stateless data loading, filtering, and sorting executor
@ -121,12 +122,20 @@ export class FluentDataManager extends Component {
const viewId = this.getCurrentViewId();
const filterState = this.getCurrentFilterState();
console.log(`[FluentData] applyFilters called for viewId: ${viewId}, total tasks: ${tasks.length}`);
// Build filter options
const filterOptions: any = {
textQuery:
filterState.filterInputValue || filterState.searchQuery || "",
};
// Always enable v2Filters for Working-on so the special filter runs
if (viewId === "working-on") {
filterOptions.v2Filters = filterOptions.v2Filters || {};
console.log("[FluentData] Working-on view detected, will apply special filter");
}
// Apply advanced filters from the filter popover/modal
if (
filterState.currentFilterState &&
@ -161,8 +170,15 @@ export class FluentDataManager extends Component {
filterOptions,
);
// Apply additional fluent-specific filters if needed
if (filterOptions.v2Filters) {
// Always apply working-on filter when in that view, regardless of v2Filters
if (viewId === "working-on") {
console.log(`[FluentData] Before applyWorkingOnFilter: ${filteredTasks.length} tasks`);
filteredTasks = this.applyWorkingOnFilter(filteredTasks);
console.log(`[FluentData] After applyWorkingOnFilter: ${filteredTasks.length} tasks`);
}
// Apply additional fluent-specific filters if needed (but skip working-on duplicate filter)
if (filterOptions.v2Filters && viewId !== "working-on") {
filteredTasks = this.applyV2Filters(
filteredTasks,
filterOptions.v2Filters,
@ -170,7 +186,7 @@ export class FluentDataManager extends Component {
}
console.log(
`[FluentData] Filtered ${filteredTasks.length} tasks from ${tasks.length} total`,
`[FluentData] Final filtered result: ${filteredTasks.length} tasks from ${tasks.length} total for viewId: ${viewId}`,
);
// Apply sorting (global default first, then view-specific)
@ -192,6 +208,12 @@ export class FluentDataManager extends Component {
const normalizeProjectId = (value?: string | null): string =>
(value ?? "").trim().toLowerCase();
// Working-on View Special Filter
// Shows tasks that are: 1) In Progress status OR 2) Have an active timer
if (viewId === "working-on") {
result = this.applyWorkingOnFilter(result);
}
// Status filter
if (filters.status && filters.status !== "all") {
switch (filters.status) {
@ -282,6 +304,64 @@ export class FluentDataManager extends Component {
return result;
}
/**
* Apply Working-on view filter
* Shows tasks that are: 1) In Progress status OR 2) Have an active timer (running or paused)
* @param tasks - Tasks to filter
* @returns Filtered tasks for Working-on view
*/
private applyWorkingOnFilter(tasks: Task[]): Task[] {
console.log(`[FluentData] applyWorkingOnFilter called with ${tasks.length} tasks`);
// Get in-progress status marks from settings
const inProgressMarks = (
this.plugin.settings.taskStatuses.inProgress || ">|/"
)
.split("|")
.map((m) => m.trim())
.filter(Boolean);
console.log(`[FluentData] In-progress marks: ${inProgressMarks.join(", ")}`);
// Get timer manager instance if timer feature is enabled
let timerManager: TaskTimerManager | null = null;
let activeTimerBlockIds: Set<string> = new Set();
if (this.plugin.settings.taskTimer?.enabled) {
timerManager = new TaskTimerManager(this.plugin.settings.taskTimer);
// Get all active timers and build a set of block IDs
const activeTimers = timerManager.getAllActiveTimers();
console.log(`[FluentData] Active timers count: ${activeTimers.length}`);
for (const timer of activeTimers) {
if (timer.status === "running" || timer.status === "paused") {
activeTimerBlockIds.add(timer.blockId);
}
}
}
const result = tasks.filter((task) => {
// Skip completed tasks
if (task.completed) return false;
// Condition 1: Task has In Progress status
const taskStatus = task.status || " ";
const isInProgress = inProgressMarks.includes(taskStatus);
if (isInProgress) return true;
// Condition 2: Task has an active timer
const blockId = task.metadata?.id;
if (blockId && activeTimerBlockIds.has(blockId)) {
return true;
}
return false;
});
console.log(`[FluentData] applyWorkingOnFilter result: ${result.length} tasks after filter`);
return result;
}
/**
* Apply sorting to tasks (pure function - returns sorted tasks)
* Applies global default sorting first, then view-specific sorting

View file

@ -21,6 +21,7 @@ import { SuggestManager } from "@/components/ui/suggest";
import { EmbeddableMarkdownEditor } from "@/editor-extensions/core/markdown-editor";
import { extractMetadataFromFilter } from "../../task/filter/filter-metadata-extractor";
import type { RootFilterState } from "../../task/filter/ViewTaskFilter";
import { TaskTimerManager } from "@/managers/timer-manager";
/**
* Quick capture save strategy types
@ -553,6 +554,25 @@ export abstract class BaseQuickCaptureModal extends Modal {
let targetFile = this.tempTargetFilePath;
// Timer auto-start: prepare block ID if enabled (checkbox mode only)
let timerBlockId: string | undefined;
const shouldAutoStartTimer =
this.currentMode === "checkbox" &&
this.plugin.settings.taskTimer?.enabled &&
this.plugin.settings.quickCapture.autoStartTimer;
if (shouldAutoStartTimer) {
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer,
);
timerBlockId = timerManager.generateBlockId();
// Append block ID to the content
processedContent = this.appendBlockIdToContent(
processedContent,
timerBlockId,
);
}
// Handle file mode
if (this.currentMode === "file" && this.taskMetadata.customFileName) {
targetFile = processDateTemplates(this.taskMetadata.customFileName);
@ -606,6 +626,64 @@ export abstract class BaseQuickCaptureModal extends Modal {
};
await saveCapture(this.app, processedContent, captureOptions);
// Start timer after successful save (checkbox mode only)
if (shouldAutoStartTimer && timerBlockId) {
await this.startTimerForNewTask(targetFile, timerBlockId);
}
}
/**
* Append block ID to content for timer tracking
*/
private appendBlockIdToContent(content: string, blockId: string): string {
// Check if content already has a block ID
if (/\s\^[\w-]+\s*$/.test(content)) {
return content;
}
return `${content} ^${blockId}`;
}
/**
* Start timer for a newly created task
*/
private async startTimerForNewTask(
targetFile: string,
blockId: string,
): Promise<void> {
try {
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer,
);
// Resolve the actual file path
let resolvedPath = targetFile;
if (this.plugin.settings.quickCapture.targetType === "daily-note") {
const dateStr = moment().format(
this.plugin.settings.quickCapture.dailyNoteSettings.format,
);
const folder =
this.plugin.settings.quickCapture.dailyNoteSettings.folder;
resolvedPath = folder
? `${folder}/${dateStr}.md`
: `${dateStr}.md`;
}
// Ensure .md extension
if (!resolvedPath.endsWith(".md")) {
resolvedPath += ".md";
}
// Start the timer
timerManager.startTimer(resolvedPath, blockId);
new Notice(t("Timer started for new task"));
} catch (error) {
console.error(
"[QuickCapture] Failed to start timer for new task:",
error,
);
}
}
/**

View file

@ -304,6 +304,31 @@ export function renderQuickCaptureSettingsTab(
}),
);
// Timer integration section (only show if task timer is enabled)
if (settingTab.plugin.settings.taskTimer?.enabled) {
new Setting(containerEl).setName(t("Timer Integration")).setHeading();
new Setting(containerEl)
.setName(t("Auto-start timer"))
.setDesc(
t(
"Automatically start the timer when creating a new task via quick capture (checkbox mode only)",
),
)
.addToggle((toggle) =>
toggle
.setValue(
settingTab.plugin.settings.quickCapture
.autoStartTimer ?? false,
)
.onChange(async (value) => {
settingTab.plugin.settings.quickCapture.autoStartTimer =
value;
settingTab.applySettingsUpdate();
}),
);
}
// File creation mode settings
new Setting(containerEl).setName(t("File Creation Mode")).setHeading();

View file

@ -0,0 +1,538 @@
import { Component, setIcon } from "obsidian";
import TaskProgressBarPlugin from "@/index";
import { Task } from "@/types/task";
import {
CompletedTimerRecord,
TaskTimerManager,
TimerState,
} from "@/managers/timer-manager";
import { t } from "@/translations/helper";
import "@/styles/timer-statistics.css";
interface TimerStatisticsOptions {
onTaskClick?: (task: Task | null) => void;
onTaskContextMenu?: (event: MouseEvent, task: Task | null) => void;
}
export class TimerStatisticsPanel extends Component {
public containerEl: HTMLElement;
private plugin: TaskProgressBarPlugin;
private tasks: Task[];
private timerManager: TaskTimerManager;
private hasActiveTimerInterval: boolean = false;
private activeTimerSignature = "";
private lastCleanup = 0;
private onTaskClick?: (task: Task | null) => void;
private onTaskContextMenu?: (event: MouseEvent, task: Task | null) => void;
constructor(
private parentEl: HTMLElement,
plugin: TaskProgressBarPlugin,
tasks: Task[] = [],
options: TimerStatisticsOptions = {}
) {
super();
this.plugin = plugin;
this.tasks = tasks;
this.onTaskClick = options.onTaskClick;
this.onTaskContextMenu = options.onTaskContextMenu;
// Reuse the plugin's shared timer manager to keep settings and state consistent
if (!this.plugin.taskTimerManager) {
this.plugin.taskTimerManager = new TaskTimerManager(
this.plugin.settings.taskTimer
);
} else {
this.plugin.taskTimerManager.updateSettings(
this.plugin.settings.taskTimer
);
}
this.timerManager = this.plugin.taskTimerManager;
}
onload() {
this.containerEl = this.parentEl.createDiv({
cls: "timer-statistics-panel",
});
// Clean up old/orphaned timers before initial render
this.timerManager.cleanup();
this.lastCleanup = Date.now();
this.render();
this.startUpdateInterval();
}
/**
* Update the tasks list
*/
public setTasks(tasks: Task[]) {
this.tasks = tasks;
this.render();
}
/**
* Start periodic updates using registerInterval
*/
private startUpdateInterval() {
if (this.hasActiveTimerInterval) {
return;
}
this.hasActiveTimerInterval = true;
// Update every second - registerInterval auto-cleans on component unload
this.registerInterval(
window.setInterval(() => {
this.updateTimerDisplays();
}, 1000)
);
}
/**
* Update timer displays without full re-render
*/
private updateTimerDisplays() {
if (!this.containerEl) return;
// Update summary values
const allTimers = this.timerManager.getAllActiveTimers();
const signature = this.computeTimerSignature(allTimers);
// If timer list or statuses changed, re-render to add/remove items
if (signature !== this.activeTimerSignature) {
this.activeTimerSignature = signature;
this.render();
return;
}
let totalDuration = 0;
let runningCount = 0;
let pausedCount = 0;
allTimers.forEach((timer) => {
totalDuration += this.timerManager.getCurrentDuration(timer.taskId);
if (timer.status === "running") {
runningCount++;
} else if (timer.status === "paused") {
pausedCount++;
}
});
// Update summary card values
const totalTimeEl = this.containerEl.querySelector(
".timer-stats-card-total .timer-stats-card-value"
);
if (totalTimeEl) {
totalTimeEl.textContent =
this.timerManager.formatDuration(totalDuration);
}
const runningCountEl = this.containerEl.querySelector(
".timer-stats-card-running .timer-stats-card-value"
);
if (runningCountEl) {
runningCountEl.textContent = String(runningCount);
}
const pausedCountEl = this.containerEl.querySelector(
".timer-stats-card-paused .timer-stats-card-value"
);
if (pausedCountEl) {
pausedCountEl.textContent = String(pausedCount);
}
const totalTimersEl = this.containerEl.querySelector(
".timer-stats-card-count .timer-stats-card-value"
);
if (totalTimersEl) {
totalTimersEl.textContent = String(allTimers.length);
}
// Update individual timer durations
this.containerEl
.querySelectorAll(".timer-item[data-task-id]")
.forEach((el) => {
const taskId = el.getAttribute("data-task-id");
if (taskId) {
const duration =
this.timerManager.getCurrentDuration(taskId);
const durationEl = el.querySelector(".timer-item-duration");
if (durationEl) {
durationEl.textContent =
this.timerManager.formatDuration(duration);
}
}
});
// Periodic cleanup while panel is open
const now = Date.now();
if (now - this.lastCleanup > 15 * 60 * 1000) {
this.timerManager.cleanup();
this.lastCleanup = now;
}
}
private render() {
this.containerEl.empty();
// Get all active timers
const allTimers = this.timerManager.getAllActiveTimers();
this.activeTimerSignature = this.computeTimerSignature(allTimers);
const completedTimers = this.timerManager.getRecentCompletedTimers(50);
// Header
const headerEl = this.containerEl.createDiv({
cls: "timer-stats-header",
});
headerEl.createEl("h2", {
cls: "timer-stats-title",
text: t("Timer Statistics"),
});
// Summary section
const summaryEl = this.containerEl.createDiv({
cls: "timer-stats-summary",
});
this.renderSummary(summaryEl, allTimers);
// Active timers section
const activeSection = this.containerEl.createDiv({
cls: "timer-stats-section",
});
activeSection.createEl("h3", { text: t("Active Timers") });
if (allTimers.length === 0) {
activeSection.createDiv({
cls: "timer-stats-empty",
text: t("No active timers"),
});
} else {
const timerListEl = activeSection.createDiv({
cls: "timer-stats-list",
});
// Group timers by status
const runningTimers = allTimers.filter(
(timer) => timer.status === "running"
);
const pausedTimers = allTimers.filter(
(timer) => timer.status === "paused"
);
if (runningTimers.length > 0) {
this.renderTimerGroup(
timerListEl,
t("Running"),
runningTimers,
"running"
);
}
if (pausedTimers.length > 0) {
this.renderTimerGroup(
timerListEl,
t("Paused"),
pausedTimers,
"paused"
);
}
}
// Completed timers section
const completedSection = this.containerEl.createDiv({
cls: "timer-stats-section",
});
completedSection.createEl("h3", { text: t("Completed Timers") });
if (completedTimers.length === 0) {
completedSection.createDiv({
cls: "timer-stats-empty",
text: t("No completed timers"),
});
} else {
this.renderCompletedTimers(completedSection, completedTimers);
}
}
private renderSummary(containerEl: HTMLElement, timers: TimerState[]) {
// Calculate totals
let totalDuration = 0;
let runningCount = 0;
let pausedCount = 0;
timers.forEach((timer) => {
totalDuration += this.timerManager.getCurrentDuration(timer.taskId);
if (timer.status === "running") {
runningCount++;
} else if (timer.status === "paused") {
pausedCount++;
}
});
// Summary cards
const cardsEl = containerEl.createDiv({ cls: "timer-stats-cards" });
// Total time card
const totalCard = cardsEl.createDiv({
cls: "timer-stats-card timer-stats-card-total",
});
const totalIcon = totalCard.createDiv({ cls: "timer-stats-card-icon" });
setIcon(totalIcon, "clock");
totalCard.createDiv({
cls: "timer-stats-card-value",
text: this.timerManager.formatDuration(totalDuration),
});
totalCard.createDiv({
cls: "timer-stats-card-label",
text: t("Total Time"),
});
// Running count card
const runningCard = cardsEl.createDiv({
cls: "timer-stats-card running timer-stats-card-running",
});
const runningIcon = runningCard.createDiv({
cls: "timer-stats-card-icon",
});
setIcon(runningIcon, "play");
runningCard.createDiv({
cls: "timer-stats-card-value",
text: String(runningCount),
});
runningCard.createDiv({
cls: "timer-stats-card-label",
text: t("Running"),
});
// Paused count card
const pausedCard = cardsEl.createDiv({
cls: "timer-stats-card paused timer-stats-card-paused",
});
const pausedIcon = pausedCard.createDiv({
cls: "timer-stats-card-icon",
});
setIcon(pausedIcon, "pause");
pausedCard.createDiv({
cls: "timer-stats-card-value",
text: String(pausedCount),
});
pausedCard.createDiv({
cls: "timer-stats-card-label",
text: t("Paused"),
});
// Total timers card
const totalTimersCard = cardsEl.createDiv({
cls: "timer-stats-card timer-stats-card-count",
});
const totalTimersIcon = totalTimersCard.createDiv({
cls: "timer-stats-card-icon",
});
setIcon(totalTimersIcon, "list");
totalTimersCard.createDiv({
cls: "timer-stats-card-value",
text: String(timers.length),
});
totalTimersCard.createDiv({
cls: "timer-stats-card-label",
text: t("Total Timers"),
});
}
private renderTimerGroup(
containerEl: HTMLElement,
title: string,
timers: TimerState[],
status: string
) {
const groupEl = containerEl.createDiv({
cls: `timer-group timer-group-${status}`,
});
groupEl.createDiv({ cls: "timer-group-title", text: title });
const listEl = groupEl.createDiv({ cls: "timer-group-list" });
timers.forEach((timer) => {
const timerEl = listEl.createDiv({
cls: "timer-item",
attr: { "data-task-id": timer.taskId },
});
// Find associated task
const task = this.tasks.find((t) => {
const blockId = t.metadata?.id;
return (
blockId &&
t.filePath === timer.filePath &&
blockId === timer.blockId
);
});
// Click and context menu handlers to open details or menu
if (this.onTaskClick) {
this.registerDomEvent(timerEl, "click", () => {
this.onTaskClick?.(task ?? null);
});
}
if (this.onTaskContextMenu) {
this.registerDomEvent(timerEl, "contextmenu", (event) => {
console.log("contextmenu", event);
event.preventDefault();
event.stopPropagation();
this.onTaskContextMenu?.(event, task ?? null);
});
}
// Timer info
const infoEl = timerEl.createDiv({ cls: "timer-item-info" });
// Task name or file path
const nameEl = infoEl.createDiv({ cls: "timer-item-name" });
if (task) {
nameEl.setText(
task.content || task.originalMarkdown || t("Untitled")
);
} else {
// Show file path if task not found
const fileName =
timer.filePath.split("/").pop() || timer.filePath;
nameEl.setText(fileName);
}
// File path
infoEl.createDiv({
cls: "timer-item-path",
text: timer.filePath,
});
// Duration
const duration = this.timerManager.getCurrentDuration(timer.taskId);
timerEl.createDiv({
cls: "timer-item-duration",
text: this.timerManager.formatDuration(duration),
});
// Status indicator
const statusEl = timerEl.createDiv({
cls: `timer-item-status timer-status-${timer.status}`,
});
setIcon(
statusEl,
timer.status === "running" ? "play-circle" : "pause-circle"
);
// Actions
const actionsEl = timerEl.createDiv({ cls: "timer-item-actions" });
if (timer.status === "running") {
// Pause button
const pauseBtn = actionsEl.createEl("button", {
cls: "timer-action-btn",
attr: { "aria-label": t("Pause") },
});
setIcon(pauseBtn, "pause");
this.registerDomEvent(pauseBtn, "click", (evt) => {
evt.stopPropagation();
this.timerManager.pauseTimer(timer.taskId);
this.render();
});
} else {
// Resume button
const resumeBtn = actionsEl.createEl("button", {
cls: "timer-action-btn",
attr: { "aria-label": t("Resume") },
});
setIcon(resumeBtn, "play");
this.registerDomEvent(resumeBtn, "click", (evt) => {
evt.stopPropagation();
this.timerManager.resumeTimer(timer.taskId);
this.render();
});
}
// Stop button
const stopBtn = actionsEl.createEl("button", {
cls: "timer-action-btn timer-action-stop",
attr: { "aria-label": t("Stop") },
});
setIcon(stopBtn, "square");
this.registerDomEvent(stopBtn, "click", (evt) => {
evt.stopPropagation();
this.timerManager.completeTimer(timer.taskId);
this.render();
});
});
}
private renderCompletedTimers(
containerEl: HTMLElement,
completedTimers: CompletedTimerRecord[]
) {
const listEl = containerEl.createDiv({
cls: "timer-completed-list",
});
completedTimers.forEach((record) => {
const itemEl = listEl.createDiv({ cls: "timer-completed-item" });
// Try to resolve the task to show nicer title
const task = this.tasks.find((t) => {
const blockId = t.metadata?.id;
return (
blockId &&
t.filePath === record.filePath &&
blockId === record.blockId
);
});
const infoEl = itemEl.createDiv({ cls: "timer-completed-info" });
const nameEl = infoEl.createDiv({ cls: "timer-completed-name" });
if (task) {
nameEl.setText(
task.content || task.originalMarkdown || t("Untitled")
);
} else {
const fileName =
record.filePath.split("/").pop() || record.filePath;
nameEl.setText(fileName);
}
infoEl.createDiv({
cls: "timer-completed-path",
text: record.filePath,
});
const metaEl = itemEl.createDiv({ cls: "timer-completed-meta" });
metaEl.createDiv({
cls: "timer-completed-duration",
text: this.timerManager.formatDuration(record.duration),
});
metaEl.createDiv({
cls: "timer-completed-time",
text: `${t("Completed at")}: ${new Date(
record.completedAt
).toLocaleString()}`,
});
});
}
private computeTimerSignature(timers: TimerState[]): string {
return timers
.map(
(timer) =>
`${timer.taskId}:${timer.status}:${timer.segments.length}`
)
.sort()
.join("|");
}
onunload() {
// Interval is auto-cleaned by registerInterval
this.containerEl?.remove();
}
}

View file

@ -15,6 +15,8 @@ import { t } from "@/translations/helper";
import TaskProgressBarPlugin from "@/index";
import { getInitialViewMode, saveViewMode } from "@/utils/ui/view-mode-utils";
import { TopNavigation } from "@/components/features/fluent/components/FluentTopNavigation";
import { TaskTimerManager } from "@/managers/timer-manager";
import { TimerStatisticsPanel } from "./TimerStatisticsPanel";
import "@/styles/task-list.css";
import "@/styles/tree-view.css";
@ -77,6 +79,11 @@ export class ContentComponent extends Component {
// Top Navigation reference (for registering custom buttons)
private topNavigation: TopNavigation | null = null;
private workingOnSummaryEl: HTMLElement | null = null;
// Timer Statistics Panel
private timerStatisticsPanel: TimerStatisticsPanel | null = null;
private isShowingTimerStats: boolean = false;
// State
private currentViewId: ViewMode = "inbox"; // Renamed from currentViewMode
@ -93,7 +100,7 @@ export class ContentComponent extends Component {
private parentEl: HTMLElement,
private app: App,
private plugin: TaskProgressBarPlugin,
private params: ContentComponentParams = {},
private params: ContentComponentParams = {}
) {
super();
}
@ -187,7 +194,7 @@ export class ContentComponent extends Component {
{
root: this.taskListEl, // Observe within the task list container
threshold: 0.1, // Trigger when 10% of the marker is visible
},
}
);
}
@ -198,11 +205,11 @@ export class ContentComponent extends Component {
this.isTreeView = getInitialViewMode(
this.app,
this.plugin,
this.currentViewId,
this.currentViewId
);
// Update the toggle button icon to match the initial state
const viewToggleBtn = this.headerEl?.querySelector(
".view-toggle-btn",
".view-toggle-btn"
) as HTMLElement;
if (viewToggleBtn) {
setIcon(viewToggleBtn, this.isTreeView ? "git-branch" : "list");
@ -212,7 +219,7 @@ export class ContentComponent extends Component {
private toggleViewMode() {
this.isTreeView = !this.isTreeView;
const viewToggleBtn = this.headerEl.querySelector(
".view-toggle-btn",
".view-toggle-btn"
) as HTMLElement;
if (viewToggleBtn) {
setIcon(viewToggleBtn, this.isTreeView ? "git-branch" : "list");
@ -228,7 +235,7 @@ export class ContentComponent extends Component {
if (this.isTreeView !== isTree) {
this.isTreeView = isTree;
const viewToggleBtn = this.headerEl?.querySelector(
".view-toggle-btn",
".view-toggle-btn"
) as HTMLElement;
if (viewToggleBtn) {
setIcon(viewToggleBtn, this.isTreeView ? "git-branch" : "list");
@ -284,7 +291,7 @@ export class ContentComponent extends Component {
// Show menu at button position
const buttonEl = document.querySelector(
'.fluent-nav-custom-buttons [aria-label="' + t("Group By") + '"]',
'.fluent-nav-custom-buttons [aria-label="' + t("Group By") + '"]'
) as HTMLElement;
if (buttonEl) {
@ -292,7 +299,7 @@ export class ContentComponent extends Component {
new MouseEvent("click", {
clientX: buttonEl.getBoundingClientRect().left,
clientY: buttonEl.getBoundingClientRect().bottom,
}),
})
);
} else {
// Fallback: show at current mouse position
@ -316,7 +323,7 @@ export class ContentComponent extends Component {
this.taskGroups = groupTasksBy(
this.filteredTasks,
this.groupByDimension,
this.plugin.settings,
this.plugin.settings
);
}
}
@ -350,7 +357,7 @@ export class ContentComponent extends Component {
group: TaskGroup,
containerEl: HTMLElement,
taskMap: Map<string, Task>,
level: number,
level: number
): void {
// Create section element with level-specific class
const sectionEl = containerEl.createDiv({
@ -405,13 +412,13 @@ export class ContentComponent extends Component {
setGroupExpandedState(
this.currentViewId,
group.key,
group.isExpanded,
group.isExpanded
);
// Update icon
setIcon(
toggleEl,
group.isExpanded ? "chevron-down" : "chevron-right",
group.isExpanded ? "chevron-down" : "chevron-right"
);
// Show/hide and render/destroy content
@ -441,7 +448,7 @@ export class ContentComponent extends Component {
group: TaskGroup,
containerEl: HTMLElement,
taskMap: Map<string, Task>,
level: number,
level: number
): void {
// Clear existing content
containerEl.empty();
@ -453,7 +460,7 @@ export class ContentComponent extends Component {
childGroup,
containerEl,
taskMap,
level + 1,
level + 1
);
});
} else {
@ -463,7 +470,7 @@ export class ContentComponent extends Component {
containerEl,
this.plugin,
this.app,
this.currentViewId,
this.currentViewId
);
// Set up callbacks
@ -481,7 +488,7 @@ export class ContentComponent extends Component {
// Set up task update callback
group.renderer.onTaskUpdate = async (
originalTask: Task,
updatedTask: Task,
updatedTask: Task
) => {
if (this.params.onTaskUpdate) {
await this.params.onTaskUpdate(originalTask, updatedTask);
@ -493,7 +500,7 @@ export class ContentComponent extends Component {
group.tasks,
this.isTreeView,
taskMap,
t("No tasks in this group."),
t("No tasks in this group.")
);
}
}
@ -506,7 +513,7 @@ export class ContentComponent extends Component {
*/
private destroyGroupContent(
group: TaskGroup,
containerEl: HTMLElement,
containerEl: HTMLElement
): void {
// Recursively destroy child groups (depth-first)
if (group.children && group.children.length > 0) {
@ -541,7 +548,7 @@ export class ContentComponent extends Component {
public setTasks(
tasks: Task[],
notFilteredTasks: Task[],
forceRefresh: boolean = false,
forceRefresh: boolean = false
) {
const updateSignatures = () => {
this.lastAllTasksSignature = this.computeTaskSignature(tasks);
@ -559,6 +566,10 @@ export class ContentComponent extends Component {
this.notFilteredTasks = notFilteredTasks;
updateSignatures();
this.applyFilters();
// Update statistics panel if showing
if (this.timerStatisticsPanel) {
this.timerStatisticsPanel.setTasks(this.filteredTasks);
}
this.debounceRefreshTaskList();
return;
}
@ -566,7 +577,7 @@ export class ContentComponent extends Component {
// If a force refresh is pending, skip non-forced updates
if (this.pendingForceRefresh) {
console.log(
"ContentComponent: Skipping non-forced update, force refresh is pending",
"ContentComponent: Skipping non-forced update, force refresh is pending"
);
return;
}
@ -579,7 +590,7 @@ export class ContentComponent extends Component {
nextNotFilteredSignature === this.lastNotFilteredTasksSignature
) {
console.log(
"ContentComponent: Task signatures unchanged, skipping refresh",
"ContentComponent: Task signatures unchanged, skipping refresh"
);
return;
}
@ -589,11 +600,16 @@ export class ContentComponent extends Component {
this.lastAllTasksSignature = nextAllSignature;
this.lastNotFilteredTasksSignature = nextNotFilteredSignature;
this.applyFilters();
// Update statistics panel if showing
if (this.timerStatisticsPanel) {
this.timerStatisticsPanel.setTasks(this.filteredTasks);
}
this.debounceRefreshTaskList();
}
// Updated method signature
public setViewMode(viewId: ViewMode, project?: string | null) {
const previousViewId = this.currentViewId;
this.currentViewId = viewId;
this.selectedProjectForView = project === undefined ? null : project;
@ -609,24 +625,36 @@ export class ContentComponent extends Component {
// Re-initialize Group By for the new view
this.initializeGroupBy();
// Handle working-on specific buttons
if (previousViewId === "working-on" && viewId !== "working-on") {
// Switching away from working-on
this.unregisterWorkingOnButtons();
} else if (previousViewId !== "working-on" && viewId === "working-on") {
// Switching to working-on
this.registerWorkingOnButtons();
}
this.applyFilters();
this.debounceRefreshTaskList();
}
/**
* Set the TopNavigation reference and register Group By button
* Set the TopNavigation reference and register buttons
*/
public setTopNavigation(nav: TopNavigation | null): void {
// Unregister old button if exists
// Unregister old buttons if exists
if (this.topNavigation) {
this.unregisterGroupByButton();
this.unregisterWorkingOnButtons();
}
this.topNavigation = nav;
// Register new button if nav is provided
// Register buttons if nav is provided
if (nav) {
this.registerGroupByButton();
// Register working-on buttons if applicable
this.registerWorkingOnButtons();
}
}
@ -648,7 +676,7 @@ export class ContentComponent extends Component {
});
console.log(
"[ContentComponent] Registered Group By button in TopNavigation",
"[ContentComponent] Registered Group By button in TopNavigation"
);
}
@ -662,27 +690,149 @@ export class ContentComponent extends Component {
this.topNavigation.unregisterCustomButton("content-group-by");
console.log(
"[ContentComponent] Unregistered Group By button from TopNavigation",
"[ContentComponent] Unregistered Group By button from TopNavigation"
);
}
/**
* Register Working-On specific buttons (Timer Statistics)
*/
private registerWorkingOnButtons(): void {
if (!this.topNavigation) {
return;
}
// Only register if timer is enabled and we're in working-on view
if (
this.currentViewId !== "working-on" ||
!this.plugin.settings.taskTimer?.enabled
) {
return;
}
this.topNavigation.registerCustomButton({
id: "working-on-timer-stats",
icon: "bar-chart-2",
tooltip: t("Timer Statistics"),
onClick: () => {
this.toggleTimerStatisticsPanel();
},
});
console.log(
"[ContentComponent] Registered Timer Statistics button for working-on view"
);
}
/**
* Unregister Working-On specific buttons
*/
private unregisterWorkingOnButtons(): void {
// Hide the statistics panel if showing
this.hideTimerStatisticsPanel();
if (!this.topNavigation) {
return;
}
this.topNavigation.unregisterCustomButton("working-on-timer-stats");
}
/**
* Toggle Timer Statistics Panel
*/
private toggleTimerStatisticsPanel(): void {
if (this.isShowingTimerStats) {
this.hideTimerStatisticsPanel();
} else {
this.showTimerStatisticsPanel();
}
}
/**
* Show Timer Statistics Panel
*/
private showTimerStatisticsPanel(): void {
if (this.timerStatisticsPanel) {
return; // Already showing
}
this.isShowingTimerStats = true;
// Hide the task list
this.taskListEl.hide();
// Create and show the statistics panel
this.timerStatisticsPanel = new TimerStatisticsPanel(
this.containerEl,
this.plugin,
this.filteredTasks,
{
onTaskClick: (task) => {
if (task) {
this.selectTask(task);
}
},
onTaskContextMenu: (event, task) => {
if (task && this.params.onTaskContextMenu) {
this.params.onTaskContextMenu(event, task);
}
},
}
);
this.addChild(this.timerStatisticsPanel);
this.timerStatisticsPanel.load();
// Update button state
this.updateTimerStatsButtonState();
}
/**
* Hide Timer Statistics Panel
*/
private hideTimerStatisticsPanel(): void {
if (!this.timerStatisticsPanel) {
return; // Not showing
}
this.isShowingTimerStats = false;
// Remove the statistics panel
this.removeChild(this.timerStatisticsPanel);
this.timerStatisticsPanel = null;
// Show the task list
this.taskListEl.show();
// Update button state
this.updateTimerStatsButtonState();
}
/**
* Update the timer stats button visual state
*/
private updateTimerStatsButtonState(): void {
// The button state can be indicated by adding/removing a class
// This is optional - could be used to highlight the button when panel is open
}
private applyFilters() {
// Call the centralized filter utility
this.filteredTasks = filterTasks(
this.allTasks,
this.currentViewId,
this.plugin,
{ textQuery: this.filterInput?.value }, // Pass text query from input
{ textQuery: this.filterInput?.value } // Pass text query from input
);
const sortCriteria = this.plugin.settings.viewConfiguration.find(
(view) => view.id === this.currentViewId,
(view) => view.id === this.currentViewId
)?.sortCriteria;
if (sortCriteria && sortCriteria.length > 0) {
this.filteredTasks = sortTasks(
this.filteredTasks,
sortCriteria,
this.plugin.settings,
this.plugin.settings
);
} else {
// Default sorting: completed tasks last, then by priority, due date, content,
@ -710,7 +860,7 @@ export class ContentComponent extends Component {
});
const contentCmp = collator.compare(
a.content ?? "",
b.content ?? "",
b.content ?? ""
);
if (contentCmp !== 0) return contentCmp;
// Lowest-priority tie-breakers to ensure stability across files
@ -725,6 +875,11 @@ export class ContentComponent extends Component {
// Update the task count display
this.countEl.setText(`${this.filteredTasks.length} ${t("tasks")}`);
// Keep timer statistics panel in sync with current filters
if (this.timerStatisticsPanel) {
this.timerStatisticsPanel.setTasks(this.filteredTasks);
}
}
private filterTasks(query: string) {
@ -742,7 +897,7 @@ export class ContentComponent extends Component {
task.originalMarkdown ?? "",
task.content ?? "",
task.metadata ? JSON.stringify(task.metadata) : "",
].join("|"),
].join("|")
)
.join(";");
}
@ -808,7 +963,7 @@ export class ContentComponent extends Component {
// If a render is already in progress, queue a refresh instead of skipping
if (this.isRendering) {
console.log(
"ContentComponent: Already rendering, queueing a refresh",
"ContentComponent: Already rendering, queueing a refresh"
);
this.pendingForceRefresh = true;
return;
@ -833,6 +988,9 @@ export class ContentComponent extends Component {
this.nextRootTaskIndex = 0;
this.rootTasks = [];
// Update working-on summary (if applicable)
this.renderWorkingOnSummary();
if (this.filteredTasks.length === 0) {
this.taskListEl.replaceChildren();
this.disposeComponentList(previousTaskComponents);
@ -861,19 +1019,19 @@ export class ContentComponent extends Component {
const taskMap = new Map<string, Task>();
// Add all non-filtered tasks to the taskMap
this.notFilteredTasks.forEach((task) =>
taskMap.set(task.id, task),
taskMap.set(task.id, task)
);
this.rootTasks = tasksToTree(this.filteredTasks); // Calculate root tasks
// Sort roots according to view's sort criteria (fallback to sensible defaults)
const viewSortCriteria =
this.plugin.settings.viewConfiguration.find(
(view) => view.id === this.currentViewId,
(view) => view.id === this.currentViewId
)?.sortCriteria;
if (viewSortCriteria && viewSortCriteria.length > 0) {
this.rootTasks = sortTasks(
this.rootTasks,
viewSortCriteria,
this.plugin.settings,
this.plugin.settings
);
} else {
// Default sorting: completed tasks last, then by priority, due date, content,
@ -902,12 +1060,12 @@ export class ContentComponent extends Component {
});
const contentCmp = collator.compare(
a.content ?? "",
b.content ?? "",
b.content ?? ""
);
if (contentCmp !== 0) return contentCmp;
// Lowest-priority tie-breakers to ensure stability across files
const fp = (a.filePath || "").localeCompare(
b.filePath || "",
b.filePath || ""
);
if (fp !== 0) return fp;
return (a.line ?? 0) - (b.line ?? 0);
@ -954,7 +1112,7 @@ export class ContentComponent extends Component {
const containerRect = container.getBoundingClientRect();
// Find first visible task item
const items = Array.from(
container.querySelectorAll<HTMLElement>(".task-item"),
container.querySelectorAll<HTMLElement>(".task-item")
);
for (const el of items) {
const rect = el.getBoundingClientRect();
@ -979,7 +1137,7 @@ export class ContentComponent extends Component {
// Try anchor-based restoration first
if (state.anchorId) {
const anchorEl = container.querySelector<HTMLElement>(
`[data-task-id="${state.anchorId}"]`,
`[data-task-id="${state.anchorId}"]`
);
if (anchorEl) {
const desiredOffset = state.anchorOffset;
@ -996,7 +1154,7 @@ export class ContentComponent extends Component {
}
private loadTaskBatch(
target: DocumentFragment | HTMLElement = this.taskListEl,
target: DocumentFragment | HTMLElement = this.taskListEl
): number {
const fragment = document.createDocumentFragment();
const countToLoad = this.taskPageSize;
@ -1012,7 +1170,7 @@ export class ContentComponent extends Component {
this.currentViewId, // Pass currentViewId
this.app,
this.plugin,
this.params.selectionManager, // Pass selection manager
this.params.selectionManager // Pass selection manager
);
// Attach event handlers
@ -1043,7 +1201,7 @@ export class ContentComponent extends Component {
private loadRootTaskBatch(
taskMap: Map<string, Task>,
target: DocumentFragment | HTMLElement = this.taskListEl,
target: DocumentFragment | HTMLElement = this.taskListEl
): number {
const fragment = document.createDocumentFragment();
const countToLoad = this.taskPageSize;
@ -1060,7 +1218,7 @@ export class ContentComponent extends Component {
for (let i = start; i < end; i++) {
const rootTask = this.rootTasks[i];
const childTasks = this.notFilteredTasks.filter(
(task) => task.metadata.parent === rootTask.id,
(task) => task.metadata.parent === rootTask.id
);
const treeComponent = new TaskTreeItemComponent(
@ -1071,7 +1229,7 @@ export class ContentComponent extends Component {
childTasks,
taskMap,
this.plugin,
this.params.selectionManager, // Pass selection manager
this.params.selectionManager // Pass selection manager
);
// Attach event handlers
@ -1134,6 +1292,73 @@ export class ContentComponent extends Component {
}
}
private renderWorkingOnSummary(): void {
// Only render for working-on view when timer is enabled
if (this.currentViewId !== "working-on") {
if (this.workingOnSummaryEl) {
this.workingOnSummaryEl.remove();
this.workingOnSummaryEl = null;
}
return;
}
if (!this.plugin.settings.taskTimer?.enabled) {
return;
}
if (!this.workingOnSummaryEl) {
this.workingOnSummaryEl = this.taskListEl.createDiv({
cls: "working-on-summary",
});
}
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer
);
let activeDuration = 0;
let completedDuration = 0;
let activeCount = 0;
const accumulate = (task: Task, includeInActive: boolean) => {
const blockId = task.metadata?.id;
if (!blockId) return;
const timer = timerManager.getTimerByFileAndBlock(
task.filePath,
blockId
);
if (!timer) return;
const duration = timerManager.getCurrentDuration(timer.taskId);
if (includeInActive) {
activeDuration += duration;
activeCount++;
} else {
completedDuration += duration;
}
};
// Active timers from current filtered working-on tasks
this.filteredTasks.forEach((task) => accumulate(task, true));
// Completed timers (if any) from all tasks
this.notFilteredTasks
.filter((t) => t.completed)
.forEach((task) => accumulate(task, false));
const activeText = timerManager.formatDuration(activeDuration);
const completedText = timerManager.formatDuration(completedDuration);
this.workingOnSummaryEl.setText(
`${t("Active timers")}: ${activeCount} · ${t(
"Total time"
)}: ${activeText} · ${t("Completed time")}: ${completedText}`
);
// Ensure the summary stays at the top
if (this.workingOnSummaryEl.parentElement !== this.taskListEl) {
this.taskListEl.prepend(this.workingOnSummaryEl);
}
}
private loadMoreTasks() {
// console.log("Load more tasks triggered...");
this.removeLoadMarker(); // Remove the current marker
@ -1145,7 +1370,7 @@ export class ContentComponent extends Component {
// );
const taskMap = new Map<string, Task>();
this.filteredTasks.forEach((task) =>
taskMap.set(task.id, task),
taskMap.set(task.id, task)
);
this.loadRootTaskBatch(taskMap);
} else {
@ -1206,12 +1431,12 @@ export class ContentComponent extends Component {
public updateTask(updatedTask: Task) {
// 1) Update sources
const taskIndexAll = this.allTasks.findIndex(
(t) => t.id === updatedTask.id,
(t) => t.id === updatedTask.id
);
if (taskIndexAll !== -1)
this.allTasks[taskIndexAll] = { ...updatedTask };
const taskIndexNotFiltered = this.notFilteredTasks.findIndex(
(t) => t.id === updatedTask.id,
(t) => t.id === updatedTask.id
);
if (taskIndexNotFiltered !== -1)
this.notFilteredTasks[taskIndexNotFiltered] = { ...updatedTask };
@ -1222,17 +1447,17 @@ export class ContentComponent extends Component {
const prevLen = this.filteredTasks.length;
this.applyFilters();
const taskFromFiltered = this.filteredTasks.find(
(t) => t.id === updatedTask.id,
(t) => t.id === updatedTask.id
);
const taskStillVisible = !!taskFromFiltered;
// Helper: insert list item at correct position (list view)
const insertListItem = (taskToInsert: Task) => {
const compIds = new Set(
this.taskComponents.map((c) => c.getTask().id),
this.taskComponents.map((c) => c.getTask().id)
);
const sortedIndex = this.filteredTasks.findIndex(
(t) => t.id === taskToInsert.id,
(t) => t.id === taskToInsert.id
);
// Find the next rendered neighbor after sortedIndex
let nextComp: any = null;
@ -1240,7 +1465,7 @@ export class ContentComponent extends Component {
const id = this.filteredTasks[i].id;
if (compIds.has(id)) {
nextComp = this.taskComponents.find(
(c) => c.getTask().id === id,
(c) => c.getTask().id === id
);
break;
}
@ -1251,7 +1476,7 @@ export class ContentComponent extends Component {
this.currentViewId,
this.app,
this.plugin,
this.params.selectionManager, // Pass selection manager
this.params.selectionManager // Pass selection manager
);
// Attach events
taskComponent.onTaskSelected = this.selectTask.bind(this);
@ -1272,7 +1497,7 @@ export class ContentComponent extends Component {
if (nextComp) {
this.taskListEl.insertBefore(
taskComponent.element,
nextComp.element,
nextComp.element
);
const idx = this.taskComponents.indexOf(nextComp);
this.taskComponents.splice(idx, 0, taskComponent);
@ -1285,7 +1510,7 @@ export class ContentComponent extends Component {
// Helper: remove list item
const removeListItem = (taskId: string) => {
const idx = this.taskComponents.findIndex(
(c) => c.getTask().id === taskId,
(c) => c.getTask().id === taskId
);
if (idx >= 0) {
const comp = this.taskComponents[idx];
@ -1306,7 +1531,7 @@ export class ContentComponent extends Component {
if (!this.isTreeView) {
// List view: update in place or insert if new to view
const comp = this.taskComponents.find(
(c) => c.getTask().id === updatedTask.id,
(c) => c.getTask().id === updatedTask.id
);
if (comp) {
comp.updateTask(taskFromFiltered!);
@ -1316,7 +1541,7 @@ export class ContentComponent extends Component {
} else {
// Tree view: update existing subtree or insert to parent/root
const comp = this.treeComponents.find(
(c) => c.getTask().id === updatedTask.id,
(c) => c.getTask().id === updatedTask.id
);
if (comp) {
comp.updateTask(taskFromFiltered!);
@ -1341,7 +1566,7 @@ export class ContentComponent extends Component {
const newChildren =
this.notFilteredTasks.filter(
(t) =>
t.metadata.parent === parentId,
t.metadata.parent === parentId
);
parentComp.updateChildTasks(newChildren);
updated = true;
@ -1352,11 +1577,11 @@ export class ContentComponent extends Component {
// Root insertion
const taskMap = new Map<string, Task>();
this.notFilteredTasks.forEach((t) =>
taskMap.set(t.id, t),
taskMap.set(t.id, t)
);
const childTasks = this.notFilteredTasks.filter(
(t) =>
t.metadata.parent === taskFromFiltered!.id,
t.metadata.parent === taskFromFiltered!.id
);
const newRoot = new TaskTreeItemComponent(
taskFromFiltered!,
@ -1366,7 +1591,7 @@ export class ContentComponent extends Component {
childTasks,
taskMap,
this.plugin,
this.params.selectionManager, // Pass selection manager
this.params.selectionManager // Pass selection manager
);
newRoot.onTaskSelected = this.selectTask.bind(this);
newRoot.onTaskCompleted = (t) => {
@ -1391,7 +1616,7 @@ export class ContentComponent extends Component {
if (
rootComparator(
taskFromFiltered!,
this.treeComponents[i].getTask(),
this.treeComponents[i].getTask()
) < 0
) {
insertAt = i;
@ -1401,12 +1626,12 @@ export class ContentComponent extends Component {
if (insertAt < this.treeComponents.length) {
this.taskListEl.insertBefore(
newRoot.element,
this.treeComponents[insertAt].element,
this.treeComponents[insertAt].element
);
this.treeComponents.splice(
insertAt,
0,
newRoot,
newRoot
);
} else {
this.taskListEl.appendChild(newRoot.element);
@ -1428,7 +1653,7 @@ export class ContentComponent extends Component {
// Tree view removal
// If root component exists, remove it
const idx = this.treeComponents.findIndex(
(c) => c.getTask().id === updatedTask.id,
(c) => c.getTask().id === updatedTask.id
);
if (idx >= 0) {
const comp = this.treeComponents[idx];

View file

@ -12,6 +12,7 @@ import { MarkdownRendererComponent } from "@/components/ui/renderers/MarkdownRen
import "@/styles/task-status-indicator.css";
import { createTaskCheckbox } from "./details";
import { TaskTimerManager } from "@/managers/timer-manager";
import { getRelativeTimeString } from "@/utils/date/date-formatter";
import { t } from "@/translations/helper";
import TaskProgressBarPlugin from "@/index";
@ -45,6 +46,10 @@ export class TaskListItemComponent extends Component {
private settings: TaskProgressBarSettings;
// Timer update interval (managed by registerInterval, auto-cleaned on unload)
private timerEl: HTMLElement | null = null;
private hasActiveTimerInterval: boolean = false;
// Use shared editor manager instead of individual editors
private static editorManager: InlineEditorManager | null = null;
@ -65,7 +70,7 @@ export class TaskListItemComponent extends Component {
private viewMode: string,
private app: App,
private plugin: TaskProgressBarPlugin,
selectionManager?: TaskSelectionManager,
selectionManager?: TaskSelectionManager
) {
super();
@ -83,7 +88,7 @@ export class TaskListItemComponent extends Component {
if (!TaskListItemComponent.editorManager) {
TaskListItemComponent.editorManager = new InlineEditorManager(
this.app,
this.plugin,
this.plugin
);
}
}
@ -99,7 +104,7 @@ export class TaskListItemComponent extends Component {
try {
await this.onTaskUpdate(originalTask, updatedTask);
console.log(
"listItem onTaskUpdate completed successfully",
"listItem onTaskUpdate completed successfully"
);
// Don't update task reference here - let onContentEditFinished handle it
} catch (error) {
@ -112,7 +117,7 @@ export class TaskListItemComponent extends Component {
},
onContentEditFinished: (
targetEl: HTMLElement,
updatedTask: Task,
updatedTask: Task
) => {
// Update the task reference with the saved task
this.task = updatedTask;
@ -125,13 +130,13 @@ export class TaskListItemComponent extends Component {
// Release the editor from the manager
TaskListItemComponent.editorManager?.releaseEditor(
this.task.id,
this.task.id
);
},
onMetadataEditFinished: (
targetEl: HTMLElement,
updatedTask: Task,
fieldType: string,
fieldType: string
) => {
// Update the task reference with the saved task
this.task = updatedTask;
@ -141,7 +146,7 @@ export class TaskListItemComponent extends Component {
// Release the editor from the manager
TaskListItemComponent.editorManager?.releaseEditor(
this.task.id,
this.task.id
);
},
useEmbeddedEditor: true, // Enable Obsidian's embedded editor
@ -149,7 +154,7 @@ export class TaskListItemComponent extends Component {
return TaskListItemComponent.editorManager!.getEditor(
this.task,
editorOptions,
editorOptions
);
}
@ -159,7 +164,7 @@ export class TaskListItemComponent extends Component {
private isCurrentlyEditing(): boolean {
return (
TaskListItemComponent.editorManager?.hasActiveEditor(
this.task.id,
this.task.id
) || false
);
}
@ -172,8 +177,8 @@ export class TaskListItemComponent extends Component {
"task-genius:selection-changed",
() => {
this.updateSelectionVisualState();
},
),
}
)
);
// Setup long press detection for mobile
@ -185,7 +190,7 @@ export class TaskListItemComponent extends Component {
this.selectionManager?.enterSelectionMode();
this.handleMultiSelect();
},
},
}
);
}
}
@ -210,11 +215,11 @@ export class TaskListItemComponent extends Component {
() => {
// Refresh view after operation
// The parent view should handle this via task updates
},
}
).catch((error) => {
console.error(
"Failed to show bulk operations menu:",
error,
error
);
});
return;
@ -253,7 +258,7 @@ export class TaskListItemComponent extends Component {
const checkbox = createTaskCheckbox(
this.task.status,
this.task,
el,
el
);
this.checkboxInput = checkbox;
@ -279,7 +284,7 @@ export class TaskListItemComponent extends Component {
}
}
});
},
}
);
this.element.appendChild(checkboxEl);
@ -371,7 +376,7 @@ export class TaskListItemComponent extends Component {
}
const priorityConfig = TaskListItemComponent.PRIORITY_CONFIG.find(
(config) => config.value === numericPriority,
(config) => config.value === numericPriority
);
const classes = ["task-priority"];
if (priorityConfig) {
@ -445,13 +450,24 @@ export class TaskListItemComponent extends Component {
}
private renderMetadata() {
// Clear timer element reference before re-rendering
this.timerEl = null;
this.metadataEl.empty();
// Working-on timer display: show elapsed time if timer exists
if (
this.viewMode === "working-on" &&
this.plugin.settings.taskTimer?.enabled
) {
this.renderTimerMetadata();
}
// For cancelled tasks, show cancelled date (independent of completion status)
if (this.task.metadata.cancelledDate) {
this.renderDateMetadata(
"cancelled",
this.task.metadata.cancelledDate,
this.task.metadata.cancelledDate
);
}
@ -468,7 +484,7 @@ export class TaskListItemComponent extends Component {
if (this.task.metadata.scheduledDate) {
this.renderDateMetadata(
"scheduled",
this.task.metadata.scheduledDate,
this.task.metadata.scheduledDate
);
}
@ -486,7 +502,7 @@ export class TaskListItemComponent extends Component {
if (this.task.metadata.completedDate) {
this.renderDateMetadata(
"completed",
this.task.metadata.completedDate,
this.task.metadata.completedDate
);
}
@ -494,7 +510,7 @@ export class TaskListItemComponent extends Component {
if (this.task.metadata.createdDate) {
this.renderDateMetadata(
"created",
this.task.metadata.createdDate,
this.task.metadata.createdDate
);
}
}
@ -534,6 +550,123 @@ export class TaskListItemComponent extends Component {
this.renderAddMetadataButton();
}
private renderTimerMetadata() {
const blockId = this.task.metadata?.id;
if (!blockId) {
return;
}
try {
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer
);
const timer = timerManager.getTimerByFileAndBlock(
this.task.filePath,
blockId
);
if (!timer) {
return;
}
const duration = timerManager.getCurrentDuration(timer.taskId);
const formatted = timerManager.formatDuration(duration);
this.timerEl = this.metadataEl.createDiv({
cls: "task-timer-meta",
});
this.timerEl.createSpan({
cls: "task-timer-duration",
text: "⏱ " + formatted,
});
this.timerEl.createSpan({
cls: "task-timer-status",
text: ` (${t(
timer.status === "running" ? "Running" : "Paused"
)})`,
});
// Start periodic updates if timer is running
if (timer.status === "running") {
this.startTimerUpdateInterval();
}
} catch (e) {
console.warn("[TaskListItem] Failed to render timer metadata", e);
}
}
/**
* Start periodic timer updates using registerInterval (auto-cleaned on unload)
*/
private startTimerUpdateInterval() {
// Only register once - registerInterval handles cleanup automatically
if (this.hasActiveTimerInterval) {
return;
}
this.hasActiveTimerInterval = true;
// Update every second - registerInterval auto-cleans on component unload
this.registerInterval(
window.setInterval(() => {
this.updateTimerDisplay();
}, 1000)
);
}
/**
* Update the timer display without re-rendering the entire metadata
*/
private updateTimerDisplay() {
const blockId = this.task.metadata?.id;
if (!blockId || !this.timerEl) {
return;
}
try {
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer
);
const timer = timerManager.getTimerByFileAndBlock(
this.task.filePath,
blockId
);
if (!timer) {
// Timer was stopped/completed - show "Stopped" status with last known duration
const statusSpan =
this.timerEl.querySelector(".task-timer-status");
if (statusSpan) {
statusSpan.textContent = ` (${t("Stopped")})`;
statusSpan.addClass("task-timer-stopped");
}
// Keep the element visible with last duration shown
return;
}
// Update duration display
const duration = timerManager.getCurrentDuration(timer.taskId);
const formatted = timerManager.formatDuration(duration);
const durationSpan = this.timerEl.querySelector(
".task-timer-duration"
);
if (durationSpan) {
durationSpan.textContent = "⏱ " + formatted;
}
// Update status display
const statusSpan = this.timerEl.querySelector(".task-timer-status");
if (statusSpan) {
statusSpan.textContent = ` (${t(
timer.status === "running" ? "Running" : "Paused"
)})`;
statusSpan.removeClass("task-timer-stopped");
}
} catch (e) {
console.warn("[TaskListItem] Failed to update timer display", e);
}
}
private renderDateMetadata(
type:
| "due"
@ -542,7 +675,7 @@ export class TaskListItemComponent extends Component {
| "completed"
| "cancelled"
| "created",
dateValue: number,
dateValue: number
) {
const dateEl = this.metadataEl.createEl("div", {
cls: ["task-date", `task-${type}-date`],
@ -593,7 +726,7 @@ export class TaskListItemComponent extends Component {
year: "numeric",
month: "long",
day: "numeric",
});
});
}
if (cssClass) {
@ -613,20 +746,20 @@ export class TaskListItemComponent extends Component {
type === "due"
? "dueDate"
: type === "scheduled"
? "scheduledDate"
: type === "start"
? "startDate"
: type === "cancelled"
? "cancelledDate"
: type === "completed"
? "completedDate"
: null;
? "scheduledDate"
: type === "start"
? "startDate"
: type === "cancelled"
? "cancelledDate"
: type === "completed"
? "completedDate"
: null;
if (fieldType) {
this.getInlineEditor().showMetadataEditor(
dateEl,
fieldType,
dateString,
dateString
);
}
}
@ -672,7 +805,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
projectEl,
"project",
this.task.metadata.project || "",
this.task.metadata.project || ""
);
}
});
@ -702,7 +835,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
tagsContainer,
"tags",
tagsString,
tagsString
);
}
});
@ -724,7 +857,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
recurrenceEl,
"recurrence",
this.task.metadata.recurrence || "",
this.task.metadata.recurrence || ""
);
}
});
@ -745,7 +878,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
onCompletionEl,
"onCompletion",
this.task.metadata.onCompletion || "",
this.task.metadata.onCompletion || ""
);
}
});
@ -757,7 +890,7 @@ export class TaskListItemComponent extends Component {
cls: "task-dependson",
});
dependsOnEl.textContent = `${this.task.metadata.dependsOn?.join(
", ",
", "
)}`;
// Make dependsOn clickable for editing only if inline editor is enabled
@ -768,7 +901,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
dependsOnEl,
"dependsOn",
this.task.metadata.dependsOn?.join(", ") || "",
this.task.metadata.dependsOn?.join(", ") || ""
);
}
});
@ -789,7 +922,7 @@ export class TaskListItemComponent extends Component {
this.getInlineEditor().showMetadataEditor(
idEl,
"id",
this.task.metadata.id || "",
this.task.metadata.id || ""
);
}
});
@ -886,7 +1019,7 @@ export class TaskListItemComponent extends Component {
if (fieldsToShow.length === 0) {
menu.addItem((item) => {
item.setTitle(
"All metadata fields are already set",
"All metadata fields are already set"
).setDisabled(true);
});
} else {
@ -903,7 +1036,7 @@ export class TaskListItemComponent extends Component {
editor.showMetadataEditor(
tempContainer,
field.key as any,
field.key as any
);
});
});
@ -994,7 +1127,7 @@ export class TaskListItemComponent extends Component {
this.markdownRenderer = new MarkdownRendererComponent(
this.app,
this.contentEl,
this.task.filePath,
this.task.filePath
);
this.addChild(this.markdownRenderer);
@ -1021,11 +1154,11 @@ export class TaskListItemComponent extends Component {
// If disabled, always use multi-line (traditional) layout
this.contentMetadataContainer.toggleClass(
"multi-line-content",
true,
true
);
this.contentMetadataContainer.toggleClass(
"single-line-content",
false,
false
);
return;
}
@ -1036,11 +1169,11 @@ export class TaskListItemComponent extends Component {
// Apply appropriate layout class using Obsidian's toggleClass method
this.contentMetadataContainer.toggleClass(
"multi-line-content",
isMultiLine,
isMultiLine
);
this.contentMetadataContainer.toggleClass(
"single-line-content",
!isMultiLine,
!isMultiLine
);
}
@ -1069,7 +1202,7 @@ export class TaskListItemComponent extends Component {
// Method 4: Check for elements that typically cause multi-line layout
const hasBlockElements = this.contentEl.querySelector(
"br, div, p, ul, ol, li, blockquote",
"br, div, p, ul, ol, li, blockquote"
);
if (hasBlockElements) {
return true;
@ -1203,6 +1336,9 @@ export class TaskListItemComponent extends Component {
}
onunload() {
// Timer interval is auto-cleaned by registerInterval
this.timerEl = null;
// Release editor from manager if this task was being edited
if (
TaskListItemComponent.editorManager?.hasActiveEditor(this.task.id)

View file

@ -22,6 +22,7 @@ import { sortTasks } from "@/commands/sortTaskCommands";
import { TaskSelectionManager } from "@/components/features/task/selection/TaskSelectionManager";
import { showBulkOperationsMenu } from "./BulkOperationsMenu";
import { TaskStatusIndicator } from "./TaskStatusIndicator";
import { TaskTimerManager } from "@/managers/timer-manager";
export class TaskTreeItemComponent extends Component {
public element: HTMLElement;
@ -50,6 +51,10 @@ export class TaskTreeItemComponent extends Component {
private taskMap: Map<string, Task>;
private statusIndicator: TaskStatusIndicator | null = null;
// Timer update interval (managed by registerInterval, auto-cleaned on unload)
private timerEl: HTMLElement | null = null;
private hasActiveTimerInterval: boolean = false;
// Use shared editor manager instead of individual editors
private static editorManager: InlineEditorManager | null = null;
@ -503,8 +508,19 @@ export class TaskTreeItemComponent extends Component {
}
private renderMetadata(metadataEl: HTMLElement) {
// Clear timer element reference before re-rendering
this.timerEl = null;
metadataEl.empty();
// Working-on timer display: show elapsed time if timer exists
if (
this.viewMode === "working-on" &&
this.plugin.settings.taskTimer?.enabled
) {
this.renderTimerMetadata(metadataEl);
}
// For cancelled tasks, show cancelled date (independent of completion status)
if (this.task.metadata.cancelledDate) {
this.renderDateMetadata(
@ -898,6 +914,123 @@ export class TaskTreeItemComponent extends Component {
});
}
private renderTimerMetadata(metadataEl: HTMLElement) {
const blockId = this.task.metadata?.id;
if (!blockId) {
return;
}
try {
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer
);
const timer = timerManager.getTimerByFileAndBlock(
this.task.filePath,
blockId
);
if (!timer) {
return;
}
const duration = timerManager.getCurrentDuration(timer.taskId);
const formatted = timerManager.formatDuration(duration);
this.timerEl = metadataEl.createDiv({
cls: "task-timer-meta",
});
this.timerEl.createSpan({
cls: "task-timer-duration",
text: "⏱ " + formatted,
});
this.timerEl.createSpan({
cls: "task-timer-status",
text: ` (${t(
timer.status === "running" ? "Running" : "Paused"
)})`,
});
// Start periodic updates if timer is running
if (timer.status === "running") {
this.startTimerUpdateInterval();
}
} catch (e) {
console.warn("[TaskTreeItem] Failed to render timer metadata", e);
}
}
/**
* Start periodic timer updates using registerInterval (auto-cleaned on unload)
*/
private startTimerUpdateInterval() {
// Only register once - registerInterval handles cleanup automatically
if (this.hasActiveTimerInterval) {
return;
}
this.hasActiveTimerInterval = true;
// Update every second - registerInterval auto-cleans on component unload
this.registerInterval(
window.setInterval(() => {
this.updateTimerDisplay();
}, 1000)
);
}
/**
* Update the timer display without re-rendering the entire metadata
*/
private updateTimerDisplay() {
const blockId = this.task.metadata?.id;
if (!blockId || !this.timerEl) {
return;
}
try {
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer
);
const timer = timerManager.getTimerByFileAndBlock(
this.task.filePath,
blockId
);
if (!timer) {
// Timer was stopped/completed - show "Stopped" status with last known duration
const statusSpan =
this.timerEl.querySelector(".task-timer-status");
if (statusSpan) {
statusSpan.textContent = ` (${t("Stopped")})`;
statusSpan.addClass("task-timer-stopped");
}
// Keep the element visible with last duration shown
return;
}
// Update duration display
const duration = timerManager.getCurrentDuration(timer.taskId);
const formatted = timerManager.formatDuration(duration);
const durationSpan = this.timerEl.querySelector(
".task-timer-duration"
);
if (durationSpan) {
durationSpan.textContent = "⏱ " + formatted;
}
// Update status display
const statusSpan = this.timerEl.querySelector(".task-timer-status");
if (statusSpan) {
statusSpan.textContent = ` (${t(
timer.status === "running" ? "Running" : "Paused"
)})`;
statusSpan.removeClass("task-timer-stopped");
}
} catch (e) {
console.warn("[TaskTreeItem] Failed to update timer display", e);
}
}
private showMetadataMenu(buttonEl: HTMLElement): void {
const editor = this.getInlineEditor();
@ -1585,6 +1718,9 @@ export class TaskTreeItemComponent extends Component {
}
onunload() {
// Timer interval is auto-cleaned by registerInterval
this.timerEl = null;
// Release editor from manager if this task was being edited
if (
TaskTreeItemComponent.editorManager?.hasActiveEditor(this.task.id)

File diff suppressed because it is too large Load diff

View file

@ -230,7 +230,7 @@ export default class TaskProgressBarPlugin extends Plugin {
this.changelogManager = new ChangelogManager(this);
this.registerView(
CHANGELOG_VIEW_TYPE,
(leaf) => new ChangelogView(leaf, this),
(leaf) => new ChangelogView(leaf, this)
);
// Initialize onboarding config manager
@ -273,18 +273,18 @@ export default class TaskProgressBarPlugin extends Plugin {
item.setTitle(
`${t("Set priority")}: ${
priority.text
}`,
}`
);
item.setIcon("arrow-big-up-dash");
item.onClick(() => {
setPriorityAtCursor(
editor,
priority.emoji,
priority.emoji
);
});
});
}
},
}
);
submenu.addSeparator();
@ -294,17 +294,17 @@ export default class TaskProgressBarPlugin extends Plugin {
([key, priority]) => {
submenu.addItem((item) => {
item.setTitle(
`${t("Set priority")}: ${key}`,
`${t("Set priority")}: ${key}`
);
item.setIcon("a-arrow-up");
item.onClick(() => {
setPriorityAtCursor(
editor,
`[#${key}]`,
`[#${key}]`
);
});
});
},
}
);
// Remove priority command
@ -324,7 +324,7 @@ export default class TaskProgressBarPlugin extends Plugin {
if (this.settings.workflow.enableWorkflow) {
updateWorkflowContextMenu(menu, editor, this);
}
}),
})
);
this.app.workspace.onLayoutReady(async () => {
@ -336,7 +336,7 @@ export default class TaskProgressBarPlugin extends Plugin {
const deferWorkspaceLeaves =
this.app.workspace.getLeavesOfType(TASK_VIEW_TYPE);
const deferSpecificLeaves = this.app.workspace.getLeavesOfType(
TASK_SPECIFIC_VIEW_TYPE,
TASK_SPECIFIC_VIEW_TYPE
);
const deferTaskGeniusLeaves =
this.app.workspace.getLeavesOfType(FLUENT_TASK_VIEW);
@ -364,8 +364,8 @@ export default class TaskProgressBarPlugin extends Plugin {
this.registerEvent(
this.app.workspace.on(
Events.TASK_CACHE_UPDATED as any,
() => this.notificationManager?.onTaskCacheUpdated(),
),
() => this.notificationManager?.onTaskCacheUpdated()
)
);
}
@ -414,7 +414,7 @@ export default class TaskProgressBarPlugin extends Plugin {
this.icsManager = new IcsManager(
this.settings.icsIntegration,
this.settings,
this,
this
);
this.addChild(this.icsManager);
@ -434,7 +434,7 @@ export default class TaskProgressBarPlugin extends Plugin {
this.activateTimelineSidebarView().catch((error) => {
console.error(
"Failed to auto-open timeline sidebar:",
error,
error
);
});
}, 1000);
@ -483,7 +483,7 @@ export default class TaskProgressBarPlugin extends Plugin {
} catch (error) {
console.error(
"[Plugin] Dataflow version check failed during startup:",
error,
error
);
}
@ -521,12 +521,12 @@ export default class TaskProgressBarPlugin extends Plugin {
this.registerView(
TASK_SPECIFIC_VIEW_TYPE,
(leaf) => new TaskSpecificView(leaf, this),
(leaf) => new TaskSpecificView(leaf, this)
);
this.registerView(
TIMELINE_SIDEBAR_VIEW_TYPE,
(leaf) => new TimelineSidebarView(leaf, this),
(leaf) => new TimelineSidebarView(leaf, this)
);
try {
@ -548,7 +548,7 @@ export default class TaskProgressBarPlugin extends Plugin {
new OnboardingView(leaf, this, () => {
console.log("Onboarding completed successfully");
leaf.detach();
}),
})
);
}
@ -623,7 +623,7 @@ export default class TaskProgressBarPlugin extends Plugin {
t("Open Task Genius view"),
() => {
this.activateTaskView();
},
}
);
};
@ -681,16 +681,16 @@ export default class TaskProgressBarPlugin extends Plugin {
detectionMethods:
this.settings.projectConfig?.metadataConfig
?.detectionMethods || [],
},
}
);
return true;
} catch (error) {
console.error(
"[Plugin] Failed to initialize dataflow orchestrator:",
error,
error
);
new Notice(
t("Failed to initialize task system. Please restart Obsidian."),
t("Failed to initialize task system. Please restart Obsidian.")
);
this.dataflowOrchestrator = undefined;
return false;
@ -720,7 +720,7 @@ export default class TaskProgressBarPlugin extends Plugin {
this.app.vault,
this.app.metadataCache,
this,
getTaskById,
getTaskById
);
}
@ -758,7 +758,7 @@ export default class TaskProgressBarPlugin extends Plugin {
} catch (error) {
console.error(
"[Plugin] Failed registering deferred commands:",
error,
error
);
}
}, 100);
@ -815,7 +815,7 @@ export default class TaskProgressBarPlugin extends Plugin {
}
view.dispatch({
effects: toggleTaskFilter.of(
!view.state.field(taskFilterState),
!view.state.field(taskFilterState)
),
});
},
@ -835,7 +835,7 @@ export default class TaskProgressBarPlugin extends Plugin {
preset.options = migrateOldFilterOptions(preset.options);
}
return preset;
},
}
);
await this.saveSettings();
console.timeEnd("[Task Genius] migratePresetTaskFilters");
@ -853,14 +853,14 @@ export default class TaskProgressBarPlugin extends Plugin {
const changes = sortTasksInDocument(
editorView,
this,
false,
false
);
if (changes) {
new Notice(
t(
"Tasks sorted (using settings). Change application needs refinement.",
),
"Tasks sorted (using settings). Change application needs refinement."
)
);
} else {
// Notice is already handled within sortTasksInDocument if no changes or sorting disabled
@ -884,11 +884,11 @@ export default class TaskProgressBarPlugin extends Plugin {
return changes;
});
new Notice(
t("Entire document sorted (using settings)."),
t("Entire document sorted (using settings).")
);
} else {
new Notice(
t("Tasks already sorted or no tasks found."),
t("Tasks already sorted or no tasks found.")
);
}
},
@ -989,12 +989,10 @@ export default class TaskProgressBarPlugin extends Plugin {
) {
// Use dataflow orchestrator for force reindex
console.log(
"[Command] Force reindexing via dataflow",
"[Command] Force reindexing via dataflow"
);
new Notice(
t(
"Clearing task cache and rebuilding index...",
),
t("Clearing task cache and rebuilding index...")
);
// Clear all caches and rebuild from scratch
@ -1094,7 +1092,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"allCompleted",
"allCompleted"
);
},
});
@ -1109,7 +1107,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"directChildren",
"directChildren"
);
},
});
@ -1124,7 +1122,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"all",
"all"
);
},
});
@ -1140,7 +1138,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"allCompleted",
"allCompleted"
);
},
});
@ -1148,7 +1146,7 @@ export default class TaskProgressBarPlugin extends Plugin {
this.addCommand({
id: "auto-move-direct-completed-subtasks",
name: t(
"Auto-move direct completed subtasks to default file",
"Auto-move direct completed subtasks to default file"
),
editorCheckCallback: (checking, editor, ctx) => {
return autoMoveCompletedTasksCommand(
@ -1156,7 +1154,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"directChildren",
"directChildren"
);
},
});
@ -1170,7 +1168,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"all",
"all"
);
},
});
@ -1189,7 +1187,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"allIncompleted",
"allIncompleted"
);
},
});
@ -1204,7 +1202,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"directIncompletedChildren",
"directIncompletedChildren"
);
},
});
@ -1220,7 +1218,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"allIncompleted",
"allIncompleted"
);
},
});
@ -1228,7 +1226,7 @@ export default class TaskProgressBarPlugin extends Plugin {
this.addCommand({
id: "auto-move-direct-incomplete-subtasks",
name: t(
"Auto-move direct incomplete subtasks to default file",
"Auto-move direct incomplete subtasks to default file"
),
editorCheckCallback: (checking, editor, ctx) => {
return autoMoveCompletedTasksCommand(
@ -1236,7 +1234,7 @@ export default class TaskProgressBarPlugin extends Plugin {
editor,
ctx,
this,
"directIncompletedChildren",
"directIncompletedChildren"
);
},
});
@ -1302,8 +1300,8 @@ export default class TaskProgressBarPlugin extends Plugin {
} catch (e) {
new Notice(
t(
"Could not open quick capture panel in the current editor",
),
"Could not open quick capture panel in the current editor"
)
);
}
}, 100);
@ -1322,7 +1320,7 @@ export default class TaskProgressBarPlugin extends Plugin {
checking,
editor,
ctx,
this,
this
);
},
});
@ -1335,7 +1333,7 @@ export default class TaskProgressBarPlugin extends Plugin {
checking,
editor,
ctx,
this,
this
);
},
});
@ -1348,7 +1346,7 @@ export default class TaskProgressBarPlugin extends Plugin {
checking,
editor,
ctx,
this,
this
);
},
});
@ -1361,7 +1359,7 @@ export default class TaskProgressBarPlugin extends Plugin {
checking,
editor,
ctx,
this,
this
);
},
});
@ -1374,7 +1372,7 @@ export default class TaskProgressBarPlugin extends Plugin {
checking,
editor,
ctx,
this,
this
);
},
});
@ -1387,13 +1385,26 @@ export default class TaskProgressBarPlugin extends Plugin {
checking,
editor,
ctx,
this,
this
);
},
});
}
// Task timer export/import commands
// Ensure timer manager and exporter are initialized if timer is enabled
if (this.settings.taskTimer?.enabled) {
if (!this.taskTimerManager) {
this.taskTimerManager = new TaskTimerManager(
this.settings.taskTimer
);
}
if (!this.taskTimerExporter) {
this.taskTimerExporter = new TaskTimerExporter(
this.taskTimerManager
);
}
}
if (this.settings.taskTimer?.enabled && this.taskTimerExporter) {
this.addCommand({
id: "export-task-timer-data",
@ -1425,7 +1436,7 @@ export default class TaskProgressBarPlugin extends Plugin {
URL.revokeObjectURL(url);
new Notice(
`Exported ${stats.activeTimers} timer records`,
`Exported ${stats.activeTimers} timer records`
);
} catch (error) {
console.error("Error exporting timer data:", error);
@ -1456,17 +1467,17 @@ export default class TaskProgressBarPlugin extends Plugin {
if (success) {
new Notice(
"Timer data imported successfully",
"Timer data imported successfully"
);
} else {
new Notice(
"Failed to import timer data - invalid format",
"Failed to import timer data - invalid format"
);
}
} catch (error) {
console.error(
"Error importing timer data:",
error,
error
);
new Notice("Failed to import timer data");
}
@ -1510,12 +1521,12 @@ export default class TaskProgressBarPlugin extends Plugin {
URL.revokeObjectURL(url);
new Notice(
`Exported ${stats.activeTimers} timer records to YAML`,
`Exported ${stats.activeTimers} timer records to YAML`
);
} catch (error) {
console.error(
"Error exporting timer data to YAML:",
error,
error
);
new Notice("Failed to export timer data to YAML");
}
@ -1563,7 +1574,7 @@ export default class TaskProgressBarPlugin extends Plugin {
let message = `Task Timer Statistics:\n`;
message += `Active timers: ${stats.activeTimers}\n`;
message += `Total duration: ${Math.round(
stats.totalDuration / 60000,
stats.totalDuration / 60000
)} minutes\n`;
if (stats.oldestTimer) {
@ -1593,12 +1604,12 @@ export default class TaskProgressBarPlugin extends Plugin {
// Initialize task timer manager and exporter
if (!this.taskTimerManager) {
this.taskTimerManager = new TaskTimerManager(
this.settings.taskTimer,
this.settings.taskTimer
);
}
if (!this.taskTimerExporter) {
this.taskTimerExporter = new TaskTimerExporter(
this.taskTimerManager,
this.taskTimerManager
);
}
@ -1672,7 +1683,7 @@ export default class TaskProgressBarPlugin extends Plugin {
if (this.settings.quickCapture.enableMinimalMode) {
this.minimalQuickCaptureSuggest = new MinimalQuickCaptureSuggest(
this.app,
this,
this
);
this.registerEditorSuggest(this.minimalQuickCaptureSuggest);
}
@ -1699,7 +1710,7 @@ export default class TaskProgressBarPlugin extends Plugin {
this.dataflowOrchestrator.cleanup().catch((error) => {
console.error(
"Error cleaning up dataflow orchestrator:",
error,
error
);
});
// Set to undefined to prevent any further access
@ -1768,11 +1779,11 @@ export default class TaskProgressBarPlugin extends Plugin {
const v2Leaves = workspace.getLeavesOfType(FLUENT_TASK_VIEW);
v2Leaves.forEach((leaf) => leaf.detach());
const specificLeaves = workspace.getLeavesOfType(
TASK_SPECIFIC_VIEW_TYPE,
TASK_SPECIFIC_VIEW_TYPE
);
specificLeaves.forEach((leaf) => leaf.detach());
const timelineLeaves = workspace.getLeavesOfType(
TIMELINE_SIDEBAR_VIEW_TYPE,
TIMELINE_SIDEBAR_VIEW_TYPE
);
timelineLeaves.forEach((leaf) => leaf.detach());
const changelogLeaves = workspace.getLeavesOfType(CHANGELOG_VIEW_TYPE);
@ -1831,7 +1842,7 @@ export default class TaskProgressBarPlugin extends Plugin {
lastVersion !== "9.9.0"
) {
console.log(
`[Task Genius] Migration detected: ${previousVersion} -> ${currentVersion}, opening onboarding`,
`[Task Genius] Migration detected: ${previousVersion} -> ${currentVersion}, opening onboarding`
);
// Directly open onboarding view (same pattern as maybeShowChangelog)
@ -1844,7 +1855,7 @@ export default class TaskProgressBarPlugin extends Plugin {
} catch (error) {
console.error(
"[Task Genius] Failed to check migration onboarding:",
error,
error
);
}
}
@ -1885,16 +1896,16 @@ export default class TaskProgressBarPlugin extends Plugin {
enabled: true,
lastVersion: "",
},
this.settings.changelog ?? {},
this.settings.changelog ?? {}
);
try {
console.debug(
"[Plugin][loadSettings] fileMetadataInheritance (raw):",
savedData?.fileMetadataInheritance,
savedData?.fileMetadataInheritance
);
console.debug(
"[Plugin][loadSettings] fileMetadataInheritance (effective):",
this.settings.fileMetadataInheritance,
this.settings.fileMetadataInheritance
);
} catch {}
@ -1904,7 +1915,7 @@ export default class TaskProgressBarPlugin extends Plugin {
// Repair and validate status cycles
if (this.settings.statusCycles) {
this.settings.statusCycles = repairStatusCycles(
this.settings.statusCycles,
this.settings.statusCycles
);
}
@ -1946,7 +1957,7 @@ export default class TaskProgressBarPlugin extends Plugin {
try {
console.debug(
"[Plugin][saveSettings] fileMetadataInheritance:",
this.settings?.fileMetadataInheritance,
this.settings?.fileMetadataInheritance
);
} catch {}
await this.saveData(this.settings);
@ -1963,7 +1974,7 @@ export default class TaskProgressBarPlugin extends Plugin {
// Add any missing default views to user settings
defaultViews.forEach((defaultView) => {
const existingView = this.settings.viewConfiguration.find(
(v) => v.id === defaultView.id,
(v) => v.id === defaultView.id
);
if (!existingView) {
this.settings.viewConfiguration.push({ ...defaultView });
@ -2027,7 +2038,7 @@ export default class TaskProgressBarPlugin extends Plugin {
// Check if view is already open
const existingLeaves = workspace.getLeavesOfType(
TIMELINE_SIDEBAR_VIEW_TYPE,
TIMELINE_SIDEBAR_VIEW_TYPE
);
if (existingLeaves.length > 0) {
@ -2073,7 +2084,7 @@ export default class TaskProgressBarPlugin extends Plugin {
// Update Timeline Sidebar Views
const timelineViewLeaves = this.app.workspace.getLeavesOfType(
TIMELINE_SIDEBAR_VIEW_TYPE,
TIMELINE_SIDEBAR_VIEW_TYPE
);
if (timelineViewLeaves.length > 0) {
for (const leaf of timelineViewLeaves) {
@ -2107,7 +2118,7 @@ export default class TaskProgressBarPlugin extends Plugin {
if (!diagnosticInfo.canWrite) {
throw new Error(
"Cannot write to version storage - storage may be corrupted",
"Cannot write to version storage - storage may be corrupted"
);
}
@ -2116,7 +2127,7 @@ export default class TaskProgressBarPlugin extends Plugin {
diagnosticInfo.previousVersion
) {
console.warn(
"Invalid version data detected, attempting recovery",
"Invalid version data detected, attempting recovery"
);
await this.versionManager.recoverFromCorruptedVersion();
}
@ -2127,7 +2138,7 @@ export default class TaskProgressBarPlugin extends Plugin {
if (versionResult.requiresRebuild) {
console.log(
`Task Genius (Dataflow): ${versionResult.rebuildReason}`,
`Task Genius (Dataflow): ${versionResult.rebuildReason}`
);
// Get all supported files for progress tracking
@ -2136,13 +2147,13 @@ export default class TaskProgressBarPlugin extends Plugin {
.filter(
(file) =>
file.extension === "md" ||
file.extension === "canvas",
file.extension === "canvas"
);
// Start rebuild progress tracking
this.rebuildProgressManager.startRebuild(
allFiles.length,
versionResult.rebuildReason,
versionResult.rebuildReason
);
// After dataflow rebuild, refresh habits to keep in sync
@ -2168,20 +2179,20 @@ export default class TaskProgressBarPlugin extends Plugin {
} else {
// No rebuild needed, dataflow already initialized during creation
console.log(
"Task Genius (Dataflow): No rebuild needed, using existing cache",
"Task Genius (Dataflow): No rebuild needed, using existing cache"
);
}
} catch (error) {
console.error(
"Error during dataflow initialization with version check:",
error,
error
);
// Trigger emergency rebuild for dataflow
try {
const emergencyResult =
await this.versionManager.handleEmergencyRebuild(
`Dataflow initialization failed: ${error.message}`,
`Dataflow initialization failed: ${error.message}`
);
// Get all supported files for progress tracking
@ -2190,13 +2201,13 @@ export default class TaskProgressBarPlugin extends Plugin {
.filter(
(file) =>
file.extension === "md" ||
file.extension === "canvas",
file.extension === "canvas"
);
// Start emergency rebuild
this.rebuildProgressManager.startRebuild(
allFiles.length,
emergencyResult.rebuildReason,
emergencyResult.rebuildReason
);
// Force rebuild dataflow
@ -2214,12 +2225,12 @@ export default class TaskProgressBarPlugin extends Plugin {
await this.versionManager.markVersionProcessed();
console.log(
"Emergency dataflow rebuild completed successfully",
"Emergency dataflow rebuild completed successfully"
);
} catch (emergencyError) {
console.error(
"Emergency dataflow rebuild failed:",
emergencyError,
emergencyError
);
throw emergencyError;
}
@ -2234,7 +2245,7 @@ export default class TaskProgressBarPlugin extends Plugin {
private async initializeTaskManagerWithVersionCheck(): Promise<void> {
// This method is deprecated and should not be called
console.warn(
"initializeTaskManagerWithVersionCheck is deprecated and should not be used",
"initializeTaskManagerWithVersionCheck is deprecated and should not be used"
);
return Promise.resolve();
}

View file

@ -1,4 +1,4 @@
import { Component, App } from "obsidian";
import { Component, App, Notice } from "obsidian";
import { Task } from "../types/task";
import {
OnCompletionConfig,
@ -15,11 +15,16 @@ import { CompleteActionExecutor } from "../executors/completion/complete-executo
import { MoveActionExecutor } from "../executors/completion/move-executor";
import { ArchiveActionExecutor } from "../executors/completion/archive-executor";
import { DuplicateActionExecutor } from "../executors/completion/duplicate-executor";
import { TaskTimerManager } from "./timer-manager";
import { t } from "@/translations/helper";
export class OnCompletionManager extends Component {
private executors: Map<OnCompletionActionType, BaseActionExecutor>;
constructor(private app: App, private plugin: TaskProgressBarPlugin) {
constructor(
private app: App,
private plugin: TaskProgressBarPlugin,
) {
super();
this.executors = new Map();
this.initializeExecutors();
@ -30,8 +35,8 @@ export class OnCompletionManager extends Component {
this.plugin.registerEvent(
this.app.workspace.on(
"task-genius:task-completed",
this.handleTaskCompleted.bind(this)
)
this.handleTaskCompleted.bind(this),
),
);
console.log("OnCompletionManager loaded");
@ -40,32 +45,36 @@ export class OnCompletionManager extends Component {
private initializeExecutors() {
this.executors.set(
OnCompletionActionType.DELETE,
new DeleteActionExecutor()
new DeleteActionExecutor(),
);
this.executors.set(
OnCompletionActionType.KEEP,
new KeepActionExecutor()
new KeepActionExecutor(),
);
this.executors.set(
OnCompletionActionType.COMPLETE,
new CompleteActionExecutor()
new CompleteActionExecutor(),
);
this.executors.set(
OnCompletionActionType.MOVE,
new MoveActionExecutor()
new MoveActionExecutor(),
);
this.executors.set(
OnCompletionActionType.ARCHIVE,
new ArchiveActionExecutor()
new ArchiveActionExecutor(),
);
this.executors.set(
OnCompletionActionType.DUPLICATE,
new DuplicateActionExecutor()
new DuplicateActionExecutor(),
);
}
private async handleTaskCompleted(task: Task) {
console.log("handleTaskCompleted", task);
// Auto-stop timer when task is completed (if timer is enabled)
await this.autoStopTimerForCompletedTask(task);
// 检查是否存在 onCompletion 属性,但允许空值进入解析逻辑
if (!task.metadata.hasOwnProperty("onCompletion")) {
return;
@ -73,7 +82,7 @@ export class OnCompletionManager extends Component {
try {
const parseResult = this.parseOnCompletion(
task.metadata.onCompletion || ""
task.metadata.onCompletion || "",
);
console.log("parseResult", parseResult);
@ -81,7 +90,7 @@ export class OnCompletionManager extends Component {
if (!parseResult.isValid || !parseResult.config) {
console.warn(
"Invalid onCompletion configuration:",
parseResult.error
parseResult.error,
);
return;
}
@ -93,7 +102,7 @@ export class OnCompletionManager extends Component {
}
public parseOnCompletion(
onCompletionValue: string
onCompletionValue: string,
): OnCompletionParseResult {
if (!onCompletionValue || typeof onCompletionValue !== "string") {
return {
@ -110,7 +119,7 @@ export class OnCompletionManager extends Component {
// Try to parse as JSON first (structured format)
if (trimmedValue.startsWith("{")) {
const config = JSON.parse(
onCompletionValue
onCompletionValue,
) as OnCompletionConfig;
return {
config,
@ -216,7 +225,7 @@ export class OnCompletionManager extends Component {
public async executeOnCompletion(
task: Task,
config: OnCompletionConfig
config: OnCompletionConfig,
): Promise<OnCompletionExecutionResult> {
const executor = this.executors.get(config.type);
@ -243,6 +252,55 @@ export class OnCompletionManager extends Component {
}
}
/**
* Auto-stop timer when a task is completed
* Checks if the task has an active timer and completes it
*/
private async autoStopTimerForCompletedTask(task: Task): Promise<void> {
// Check if timer feature is enabled
if (!this.plugin.settings.taskTimer?.enabled) {
return;
}
// Get block ID from task metadata
const blockId = task.metadata?.id;
if (!blockId) {
return;
}
try {
const timerManager = new TaskTimerManager(
this.plugin.settings.taskTimer,
);
// Check if there's an active timer for this task
const timer = timerManager.getTimerByFileAndBlock(
task.filePath,
blockId,
);
if (
timer &&
(timer.status === "running" || timer.status === "paused")
) {
// Complete the timer and get duration
const duration = timerManager.completeTimer(timer.taskId);
if (duration) {
console.log(
`[OnCompletionManager] Timer auto-stopped for completed task. Duration: ${duration}`,
);
new Notice(t("Timer stopped") + `: ${duration}`);
}
}
} catch (error) {
console.error(
"[OnCompletionManager] Error auto-stopping timer:",
error,
);
}
}
onunload() {
this.executors.clear();
console.log("OnCompletionManager unloaded");

View file

@ -40,6 +40,19 @@ export interface LegacyTimerState {
createdAt: number;
}
/**
* Completed timer record for history display
*/
export interface CompletedTimerRecord {
taskId: string;
filePath: string;
blockId: string;
duration: number;
completedAt: number;
createdAt: number;
segments: TimeSegment[];
}
/**
* Manager for task timer state and localStorage operations
*/
@ -47,11 +60,39 @@ export class TaskTimerManager {
private settings: TaskTimerSettings;
private readonly STORAGE_PREFIX = "taskTimer_";
private readonly TIMER_LIST_KEY = "taskTimer_activeList";
private readonly COMPLETED_LIST_KEY = "taskTimer_completedList";
private readonly MAX_COMPLETED_HISTORY = 200;
constructor(settings: TaskTimerSettings) {
this.settings = settings;
}
private loadFromStorage(key: string): string | null {
const appInstance = (window as any).app as App | undefined;
if (appInstance?.loadLocalStorage) {
return appInstance.loadLocalStorage(key);
}
if (typeof localStorage !== "undefined") {
return localStorage.getItem(key);
}
return null;
}
private saveToStorage(key: string, value: string | null): void {
const appInstance = (window as any).app as App | undefined;
if (appInstance?.saveLocalStorage) {
appInstance.saveLocalStorage(key, value);
return;
}
if (typeof localStorage !== "undefined") {
if (value === null) {
localStorage.removeItem(key);
} else {
localStorage.setItem(key, value);
}
}
}
/**
* Generate a unique block reference ID
* @param prefix Optional prefix to use (defaults to settings)
@ -85,7 +126,9 @@ export class TaskTimerManager {
startTimer(filePath: string, existingBlockId?: string): string {
try {
console.log(
`[TaskTimerManager] Starting timer for file: ${filePath}, blockId: ${existingBlockId || "new"}`,
`[TaskTimerManager] Starting timer for file: ${filePath}, blockId: ${
existingBlockId || "new"
}`
);
const blockId = existingBlockId || this.generateBlockId();
@ -94,7 +137,7 @@ export class TaskTimerManager {
if (!blockId) {
console.error(
"[TaskTimerManager] Failed to generate or use block ID",
"[TaskTimerManager] Failed to generate or use block ID"
);
throw new Error("Block ID generation failed");
}
@ -104,7 +147,7 @@ export class TaskTimerManager {
if (existingTimer) {
console.log(
`[TaskTimerManager] Found existing timer with status: ${existingTimer.status}`,
`[TaskTimerManager] Found existing timer with status: ${existingTimer.status}`
);
// Resume existing timer
if (existingTimer.status === "paused") {
@ -129,21 +172,18 @@ export class TaskTimerManager {
// Save timer state
try {
((window as any).app as App).saveLocalStorage(
taskId,
JSON.stringify(timerState),
);
this.saveToStorage(taskId, JSON.stringify(timerState));
this.addToActiveList(taskId);
console.log(
`[TaskTimerManager] Successfully created new timer: ${taskId}`,
`[TaskTimerManager] Successfully created new timer: ${taskId}`
);
} catch (storageError) {
console.error(
"[TaskTimerManager] Failed to save timer to localStorage:",
storageError,
storageError
);
throw new Error(
"Failed to save timer state - localStorage may be full or unavailable",
"Failed to save timer state - localStorage may be full or unavailable"
);
}
@ -151,7 +191,7 @@ export class TaskTimerManager {
} catch (error) {
console.error(
"[TaskTimerManager] Critical error starting timer:",
error,
error
);
throw error; // Re-throw to let caller handle
}
@ -179,10 +219,7 @@ export class TaskTimerManager {
timerState.status = "paused";
(window as any).app.saveLocalStorage(
taskId,
JSON.stringify(timerState),
);
this.saveToStorage(taskId, JSON.stringify(timerState));
}
/**
@ -204,10 +241,7 @@ export class TaskTimerManager {
timerState.status = "running";
(window as any).app.saveLocalStorage(
taskId,
JSON.stringify(timerState),
);
this.saveToStorage(taskId, JSON.stringify(timerState));
}
/**
@ -230,10 +264,7 @@ export class TaskTimerManager {
];
timerState.status = "running";
(window as any).app.saveLocalStorage(
taskId,
JSON.stringify(timerState),
);
this.saveToStorage(taskId, JSON.stringify(timerState));
}
/**
@ -248,7 +279,7 @@ export class TaskTimerManager {
const timerState = this.getTimerState(taskId);
if (!timerState) {
console.warn(
`[TaskTimerManager] Timer not found for completion: ${taskId}`,
`[TaskTimerManager] Timer not found for completion: ${taskId}`
);
return "";
}
@ -268,27 +299,38 @@ export class TaskTimerManager {
// Calculate total duration from all segments
const totalDuration = this.calculateTotalDuration(timerState);
console.log(
`[TaskTimerManager] Total duration from ${timerState.segments.length} segments: ${totalDuration}ms`,
`[TaskTimerManager] Total duration from ${timerState.segments.length} segments: ${totalDuration}ms`
);
// Validate duration
if (totalDuration < 0) {
console.error(
`[TaskTimerManager] Invalid duration calculated: ${totalDuration}ms`,
`[TaskTimerManager] Invalid duration calculated: ${totalDuration}ms`
);
return this.formatDuration(0);
}
// Record completion history before removing timer
this.recordCompletedTimer({
taskId: timerState.taskId,
filePath: timerState.filePath,
blockId: timerState.blockId,
duration: totalDuration,
completedAt: now,
createdAt: timerState.createdAt,
segments: timerState.segments,
});
// Remove from storage
try {
this.removeTimer(taskId);
console.log(
`[TaskTimerManager] Successfully removed completed timer from storage`,
`[TaskTimerManager] Successfully removed completed timer from storage`
);
} catch (removalError) {
console.error(
"[TaskTimerManager] Failed to remove timer from storage:",
removalError,
removalError
);
// Continue anyway - we can still return the duration
}
@ -296,13 +338,13 @@ export class TaskTimerManager {
// Format and return duration
const formattedDuration = this.formatDuration(totalDuration);
console.log(
`[TaskTimerManager] Timer completed successfully, duration: ${formattedDuration}`,
`[TaskTimerManager] Timer completed successfully, duration: ${formattedDuration}`
);
return formattedDuration;
} catch (error) {
console.error(
"[TaskTimerManager] Critical error completing timer:",
error,
error
);
// Return empty string to prevent crashes, but log the issue
return "";
@ -316,7 +358,7 @@ export class TaskTimerManager {
*/
getTimerState(taskId: string): TimerState | null {
try {
const stored = (window as any).app.localStorage(taskId);
const stored = this.loadFromStorage(taskId);
if (!stored) {
return null;
}
@ -326,16 +368,13 @@ export class TaskTimerManager {
// Check if this is a legacy format that needs migration
if (this.isLegacyFormat(parsed)) {
console.log(
`[TaskTimerManager] Migrating legacy timer state for ${taskId}`,
`[TaskTimerManager] Migrating legacy timer state for ${taskId}`
);
const migrated = this.migrateLegacyState(
parsed as LegacyTimerState,
parsed as LegacyTimerState
);
// Save migrated state
(window as any).app.saveLocalStorage(
taskId,
JSON.stringify(migrated),
);
this.saveToStorage(taskId, JSON.stringify(migrated));
return migrated;
}
@ -343,10 +382,10 @@ export class TaskTimerManager {
if (!this.validateTimerState(parsed)) {
console.error(
`[TaskTimerManager] Invalid timer state structure for ${taskId}:`,
parsed,
parsed
);
// Clean up corrupted data
(window as any).app.saveLocalStorage(taskId, null);
this.saveToStorage(taskId, null);
return null;
}
@ -354,15 +393,15 @@ export class TaskTimerManager {
} catch (error) {
console.error(
`[TaskTimerManager] Error retrieving timer state for ${taskId}:`,
error,
error
);
// Clean up corrupted data
try {
(window as any).app.saveLocalStorage(taskId, null);
this.saveToStorage(taskId, null);
} catch (cleanupError) {
console.error(
"[TaskTimerManager] Failed to clean up corrupted timer data:",
cleanupError,
cleanupError
);
}
return null;
@ -469,7 +508,7 @@ export class TaskTimerManager {
*/
getTimerByFileAndBlock(
filePath: string,
blockId: string,
blockId: string
): TimerState | null {
const taskId = this.getStorageKey(filePath, blockId);
return this.getTimerState(taskId);
@ -480,7 +519,7 @@ export class TaskTimerManager {
* @param taskId Timer task ID
*/
removeTimer(taskId: string): void {
(window as any).app.saveLocalStorage(taskId, null);
this.saveToStorage(taskId, null);
this.removeFromActiveList(taskId);
}
@ -590,9 +629,7 @@ export class TaskTimerManager {
* @returns Array of active timer task IDs
*/
private getActiveList(): string[] {
const stored = (window as any).app.loadLocalStorage(
this.TIMER_LIST_KEY,
);
const stored = this.loadFromStorage(this.TIMER_LIST_KEY);
if (!stored) {
return [];
}
@ -613,10 +650,7 @@ export class TaskTimerManager {
const activeList = this.getActiveList();
if (!activeList.includes(taskId)) {
activeList.push(taskId);
(window as any).app.saveLocalStorage(
this.TIMER_LIST_KEY,
JSON.stringify(activeList),
);
this.saveToStorage(this.TIMER_LIST_KEY, JSON.stringify(activeList));
}
}
@ -627,10 +661,53 @@ export class TaskTimerManager {
private removeFromActiveList(taskId: string): void {
const activeList = this.getActiveList();
const filtered = activeList.filter((id) => id !== taskId);
(window as any).app.saveLocalStorage(
this.TIMER_LIST_KEY,
JSON.stringify(filtered),
);
this.saveToStorage(this.TIMER_LIST_KEY, JSON.stringify(filtered));
}
/**
* Persist a completed timer into history (bounded list)
*/
private recordCompletedTimer(record: CompletedTimerRecord): void {
const completed = this.getCompletedList();
// Newest first
completed.unshift(record);
// Trim history to avoid unbounded growth
if (completed.length > this.MAX_COMPLETED_HISTORY) {
completed.length = this.MAX_COMPLETED_HISTORY;
}
this.saveToStorage(this.COMPLETED_LIST_KEY, JSON.stringify(completed));
}
/**
* Retrieve completed timer history
* @returns Ordered list (newest first)
*/
getRecentCompletedTimers(limit: number = 50): CompletedTimerRecord[] {
const completed = this.getCompletedList();
if (limit && limit > 0) {
return completed.slice(0, limit);
}
return completed;
}
private getCompletedList(): CompletedTimerRecord[] {
const stored = this.loadFromStorage(this.COMPLETED_LIST_KEY);
if (!stored) {
return [];
}
try {
const parsed = JSON.parse(stored) as CompletedTimerRecord[];
if (!Array.isArray(parsed)) {
return [];
}
return parsed;
} catch (error) {
console.error("Error parsing completed timer list:", error);
return [];
}
}
/**

View file

@ -570,11 +570,13 @@ export class FluentTaskView extends ItemView {
this.updateView();
},
onNavigateToView: (viewId) => {
console.log(`[Task Genius] onNavigateToView called with viewId: ${viewId}, previous: ${this.currentViewId}`);
this.recordViewModeForView(
this.currentViewId,
this.viewState.viewMode
);
this.currentViewId = viewId;
console.log(`[Task Genius] currentViewId updated to: ${this.currentViewId}`);
// When navigating to projects view directly, clear project selection
// This enables the full project overview mode

View file

@ -93,7 +93,7 @@ export class TaskTimerExporter {
activeTimers: activeTimers.map((timer) => ({
...timer,
currentDuration: this.timerManager.getCurrentDuration(
timer.taskId,
timer.taskId
),
})),
};
@ -114,19 +114,35 @@ export class TaskTimerExporter {
return false;
}
// Get current active list or create empty array
const activeListKey = "taskTimer_activeList";
const existingListRaw = (window as any).app.loadLocalStorage(
activeListKey
);
let activeList: string[] = [];
try {
activeList = existingListRaw ? JSON.parse(existingListRaw) : [];
} catch {
activeList = [];
}
// Restore each timer
for (const timerData of backup.activeTimers) {
// Recreate timer state in localStorage
// Convert old format to new segments format
const segments = [];
if (timerData.startTime) {
// Handle both old format and new segments format
let segments = [];
if (timerData.segments && Array.isArray(timerData.segments)) {
// New format - use segments directly
segments = timerData.segments;
} else if (timerData.startTime) {
// Convert old format to new segments format
segments.push({
startTime: timerData.startTime,
endTime: timerData.pausedTime,
duration: timerData.pausedTime
? timerData.pausedTime -
timerData.startTime -
(timerData.totalPausedDuration || 0)
timerData.startTime -
(timerData.totalPausedDuration || 0)
: undefined,
});
}
@ -145,12 +161,27 @@ export class TaskTimerExporter {
timerData.totalPausedDuration || 0,
};
// Save timer to storage
(window as any).app.saveLocalStorage(
timerData.taskId,
JSON.stringify(restoredTimer),
JSON.stringify(restoredTimer)
);
// Add to active list if not already present
if (!activeList.includes(timerData.taskId)) {
activeList.push(timerData.taskId);
}
}
// Save updated active list
(window as any).app.saveLocalStorage(
activeListKey,
JSON.stringify(activeList)
);
console.log(
`Restored ${backup.activeTimers.length} timers from backup`
);
return true;
} catch (error) {
console.error("Error restoring from backup:", error);
@ -218,7 +249,7 @@ export class TaskTimerExporter {
}
const currentDuration = this.timerManager.getCurrentDuration(
timer.taskId,
timer.taskId
);
// Get the first and last segments for export
@ -260,20 +291,77 @@ export class TaskTimerExporter {
return false;
}
// Get current active list or create empty array
const activeListKey = "taskTimer_activeList";
const existingListRaw = (window as any).app.loadLocalStorage(
activeListKey
);
let activeList: string[] = [];
try {
activeList = existingListRaw ? JSON.parse(existingListRaw) : [];
} catch {
activeList = [];
}
let importedCount = 0;
let activeCount = 0;
for (const timerData of data.timers) {
try {
// Only import completed timers to avoid conflicts
if (timerData.status === "idle" || timerData.endTime) {
// Store as historical data (could be extended for analytics)
// Import all timers, not just completed ones
if (
timerData.status === "running" ||
timerData.status === "paused"
) {
// Convert to TimerState format and restore as active timer
const segments = [];
if (timerData.startTime) {
segments.push({
startTime: timerData.startTime,
endTime: timerData.endTime,
duration: timerData.endTime
? timerData.endTime -
timerData.startTime -
(timerData.totalPausedDuration || 0)
: undefined,
});
}
const restoredTimer: TimerState = {
taskId: timerData.taskId,
filePath: timerData.filePath,
blockId: timerData.blockId,
segments: segments,
status: timerData.status as "running" | "paused",
createdAt: timerData.createdAt || Date.now(),
legacyStartTime: timerData.startTime,
legacyPausedTime: timerData.endTime,
legacyTotalPausedDuration:
timerData.totalPausedDuration || 0,
};
// Save timer to storage
(window as any).app.saveLocalStorage(
timerData.taskId,
JSON.stringify(restoredTimer)
);
// Add to active list if not already present
if (!activeList.includes(timerData.taskId)) {
activeList.push(timerData.taskId);
}
activeCount++;
importedCount++;
} else if (timerData.status === "idle" || timerData.endTime) {
// Store completed timers as historical data
const historyKey = `taskTimer_history_${timerData.blockId}_${timerData.startTime}`;
(window as any).app.saveLocalStorage(
historyKey,
JSON.stringify({
...timerData,
importedAt: Date.now(),
}),
})
);
importedCount++;
}
@ -281,12 +369,20 @@ export class TaskTimerExporter {
console.warn(
"Failed to import timer:",
timerData.taskId,
error,
error
);
}
}
console.log(`Successfully imported ${importedCount} timer records`);
// Save updated active list
(window as any).app.saveLocalStorage(
activeListKey,
JSON.stringify(activeList)
);
console.log(
`Successfully imported ${importedCount} timer records (${activeCount} active)`
);
return importedCount > 0;
}
@ -331,7 +427,10 @@ export class TaskTimerExporter {
if (Array.isArray(obj)) {
for (const item of obj) {
yaml += `${spaces}- ${this.convertToYAML(item, indent + 1).trim()}\n`;
yaml += `${spaces}- ${this.convertToYAML(
item,
indent + 1
).trim()}\n`;
}
} else if (obj !== null && typeof obj === "object") {
for (const [key, value] of Object.entries(obj)) {

View file

@ -0,0 +1,335 @@
/* Timer Statistics Panel Styles */
.timer-statistics-panel {
--timer-card-bg: var(--background-secondary);
--timer-running-color: var(--color-green);
--timer-paused-color: var(--color-orange);
--timer-stopped-color: var(--color-red);
padding: var(--size-4-4);
overflow-y: auto;
height: 100%;
}
.timer-stats-header {
margin-bottom: var(--size-4-4);
}
.timer-stats-title {
font-size: var(--font-ui-large);
font-weight: var(--font-semibold);
color: var(--text-normal);
margin: 0;
}
/* Summary Cards */
.timer-stats-summary {
margin-bottom: var(--size-4-6);
}
.timer-stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: var(--size-4-3);
}
.timer-stats-card {
background: var(--timer-card-bg);
border-radius: var(--radius-m);
padding: var(--size-4-4);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.timer-stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.timer-stats-card-icon {
color: var(--text-muted);
margin-bottom: var(--size-4-2);
}
.timer-stats-card.running .timer-stats-card-icon {
color: var(--timer-running-color);
}
.timer-stats-card.paused .timer-stats-card-icon {
color: var(--timer-paused-color);
}
.timer-stats-card-value {
font-size: var(--font-ui-large);
font-weight: var(--font-semibold);
color: var(--text-normal);
margin-bottom: var(--size-4-1);
}
.timer-stats-card-label {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
}
/* Sections */
.timer-stats-section {
margin-bottom: var(--size-4-6);
}
.timer-stats-section h3 {
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
color: var(--text-normal);
margin: 0 0 var(--size-4-3) 0;
padding-bottom: var(--size-4-2);
border-bottom: 1px solid var(--background-modifier-border);
}
.timer-stats-empty {
color: var(--text-muted);
font-style: italic;
padding: var(--size-4-4);
text-align: center;
background: var(--background-secondary);
border-radius: var(--radius-m);
}
/* Timer Groups */
.timer-group {
margin-bottom: var(--size-4-4);
}
.timer-group-title {
font-size: var(--font-ui-small);
font-weight: var(--font-medium);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--size-4-2);
}
.timer-group-running .timer-group-title {
color: var(--timer-running-color);
}
.timer-group-paused .timer-group-title {
color: var(--timer-paused-color);
}
/* Timer Items */
.timer-group-list {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.timer-item {
display: flex;
align-items: center;
gap: var(--size-4-3);
padding: var(--size-4-3);
background: var(--background-secondary);
border-radius: var(--radius-m);
transition: background-color 0.15s ease;
}
.timer-item:hover {
background: var(--background-modifier-hover);
}
.timer-item-info {
flex: 1;
min-width: 0;
}
.timer-item-name {
font-weight: var(--font-medium);
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timer-item-path {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timer-item-duration {
font-family: var(--font-monospace);
font-size: var(--font-ui-medium);
font-weight: var(--font-semibold);
color: var(--text-normal);
min-width: 80px;
text-align: right;
}
.timer-item-status {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
}
.timer-status-running {
color: var(--timer-running-color);
}
.timer-status-paused {
color: var(--timer-paused-color);
}
/* Timer Actions */
.timer-item-actions {
display: flex;
gap: var(--size-4-1);
}
button.timer-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
cursor: pointer;
color: var(--text-muted);
transition: all 0.15s ease;
box-shadow: unset;
}
button.timer-action-btn:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
border-color: var(--text-muted);
}
.timer-action-stop:hover {
background: var(--background-modifier-error);
color: var(--text-on-accent);
border-color: transparent;
}
/* Task Timer List */
.timer-stats-task-list {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.timer-task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--size-4-3);
background: var(--background-secondary);
border-radius: var(--radius-m);
}
.timer-task-content {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-normal);
}
.timer-task-timer {
display: flex;
align-items: center;
gap: var(--size-4-2);
margin-left: var(--size-4-3);
}
.timer-task-duration {
font-family: var(--font-monospace);
font-weight: var(--font-medium);
color: var(--text-normal);
}
.timer-task-status {
font-size: var(--font-ui-smaller);
}
.timer-task-status.timer-status-running {
color: var(--timer-running-color);
}
.timer-task-status.timer-status-paused {
color: var(--timer-paused-color);
}
/* Task Timer Meta Stopped State */
.task-timer-stopped {
color: var(--text-muted);
font-style: italic;
}
/* Completed Timers */
.timer-completed-list {
display: flex;
flex-direction: column;
gap: var(--size-4-2);
}
.timer-completed-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--size-4-3);
background: var(--background-secondary);
border-radius: var(--radius-m);
gap: var(--size-4-3);
}
.timer-completed-info {
flex: 1;
min-width: 0;
}
.timer-completed-name {
font-weight: var(--font-medium);
color: var(--text-normal);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timer-completed-path {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.timer-completed-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--size-2-2);
min-width: 160px;
}
.timer-completed-duration {
font-family: var(--font-monospace);
font-weight: var(--font-semibold);
color: var(--text-normal);
}
.timer-completed-time {
font-size: var(--font-ui-smaller);
color: var(--text-muted);
text-align: right;
}

View file

@ -2590,6 +2590,46 @@ const translations = {
"Columns that are currently hidden from view. Click the eye icon to show them again.":
"Columns that are currently hidden from view. Click the eye icon to show them again.",
"Unhide column": "Unhide column",
// Timer feature
"Timer Statistics": "Timer Statistics",
"Active Timers": "Active Timers",
"Active timers": "Active timers",
"No active timers": "No active timers",
"Tasks in Current View": "Tasks in Current View",
"No tasks with active timers in this view":
"No tasks with active timers in this view",
"Total Time": "Total Time",
"Total Timers": "Total Timers",
Paused: "Paused",
"Completed Timers": "Completed Timers",
"No completed timers": "No completed timers",
"Completed at": "Completed at",
Pause: "Pause",
Resume: "Resume",
Stop: "Stop",
Untitled: "Untitled",
"Start Timer": "Start Timer",
"Pause Timer": "Pause Timer",
"Resume Timer": "Resume Timer",
"Stop Timer": "Stop Timer",
"Timer started": "Timer started",
"Timer paused": "Timer paused",
"Timer resumed": "Timer resumed",
"Timer stopped": "Timer stopped",
"Failed to start timer": "Failed to start timer",
"Block ID added": "Block ID added",
"Task status updated": "Task status updated",
// Working On view
"Working On": "Working On",
// Quick Capture Timer Integration
"Timer Integration": "Timer Integration",
"Auto-start timer": "Auto-start timer",
"Automatically start the timer when creating a new task via quick capture (checkbox mode only)":
"Automatically start the timer when creating a new task via quick capture (checkbox mode only)",
"Timer started for new task": "Timer started for new task",
};
export default translations;

View file

@ -2381,6 +2381,45 @@ const translations = {
"Columns that are currently hidden from view. Click the eye icon to show them again.":
"当前隐藏的列。点击眼睛图标可重新显示。",
"Unhide column": "显示列",
// Timer feature
"Timer Statistics": "计时统计",
"Active Timers": "活跃计时器",
"Active timers": "活跃计时器",
"No active timers": "暂无活跃计时器",
"Tasks in Current View": "当前视图中的任务",
"No tasks with active timers in this view": "此视图中没有正在计时的任务",
"Total Time": "总计时",
"Total Timers": "计时器总数",
Paused: "已暂停",
"Completed Timers": "已完成的计时",
"No completed timers": "暂无已完成的计时",
"Completed at": "完成于",
Pause: "暂停",
Resume: "继续",
Stop: "停止",
Untitled: "未命名",
"Start Timer": "开始计时",
"Pause Timer": "暂停计时",
"Resume Timer": "继续计时",
"Stop Timer": "停止计时",
"Timer started": "计时已开始",
"Timer paused": "计时已暂停",
"Timer resumed": "计时已继续",
"Timer stopped": "计时已停止",
"Failed to start timer": "启动计时器失败",
"Block ID added": "块 ID 已添加",
"Task status updated": "任务状态已更新",
// Working On view
"Working On": "进行中",
// Quick Capture Timer Integration
"Timer Integration": "计时器集成",
"Auto-start timer": "自动启动计时器",
"Automatically start the timer when creating a new task via quick capture (checkbox mode only)":
"通过快速捕获创建新任务时自动启动计时器(仅限复选框模式)",
"Timer started for new task": "已为新任务启动计时器",
};
export default translations;

File diff suppressed because one or more lines are too long