v0.1.2: address ObsidianReviewBot findings

Why:
- Fix unhandled promises (void-prefixed calls in event listeners, setTimeout
  callbacks, and sync handlers).
- Replace async-without-await signatures (loadTasks, onunload, onClose,
  countTasksInBucket) with sync equivalents.
- Narrow nullish-coalesce stringify of keyof TaskItem to exclude TFile /
  objects from "[object Object]" stringification.
- Replace innerHTML with DOM API in the empty-state renderer.
- Replace createEl("h2", ...) with setHeading() via Setting API for
  settings sections.
- Move inline element.style.* assignments into styles.css classes.
- Use FileManager.trashFile() instead of Vault.delete() for stub cleanup.
- UI strings use sentence case ("Open tasks", "New bucket", "Secondary
  grouping", "Getting started").
- Remove unused imports/bindings (BucketGroup, TaskRowCallbacks,
  BucketConfig, originalOnComplete).

Co-authored-by: Isaac
This commit is contained in:
Art Malanok 2026-04-16 18:38:53 -07:00
parent b1a966534a
commit c58cdf5638
12 changed files with 146 additions and 137 deletions

View file

@ -1,7 +1,7 @@
{
"id": "minimal-task-board",
"name": "Minimal Task Board",
"version": "0.1.1",
"version": "0.1.2",
"minAppVersion": "1.10.0",
"description": "A clean, minimal task board with customizable buckets, secondary grouping, and inline task tracking.",
"author": "Art Malanok",

View file

@ -1,6 +1,6 @@
{
"name": "minimal-task-board",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"scripts": {
"build": "node esbuild.config.mjs",

View file

@ -29,8 +29,8 @@ export class InlineTaskWatcher {
}
start(): void {
const ref = this.app.metadataCache.on("changed", (file, data, cache) => {
this.onFileChanged(file, cache);
const ref = this.app.metadataCache.on("changed", (file, _data, cache) => {
void this.onFileChanged(file, cache);
});
this.eventRefs.push(ref);
}
@ -168,7 +168,7 @@ export class InlineTaskWatcher {
// Delete stubs that no longer have a matching #task line
for (const stub of existingStubs) {
if (!matchedStubs.has(stub.path)) {
await this.app.vault.delete(stub);
await this.app.fileManager.trashFile(stub);
}
}
}

View file

@ -83,7 +83,7 @@ export class TaskStore {
this.completingPaths.delete(path);
}
async loadTasks(): Promise<TaskItem[]> {
loadTasks(): TaskItem[] {
const settings = this.getSettings();
const folder = settings.taskFolder;
const bucketProp = settings.bucketProperty.toLowerCase();
@ -210,7 +210,13 @@ export class TaskStore {
const groupMap = new Map<string, TaskItem[]>();
for (const task of tasks) {
const key = String(task[prop] ?? "");
const rawValue = task[prop];
const key =
typeof rawValue === "string"
? rawValue
: typeof rawValue === "number" || typeof rawValue === "boolean"
? String(rawValue)
: "";
if (!groupMap.has(key)) {
groupMap.set(key, []);
}

View file

@ -62,11 +62,12 @@ export class CheckboxHandler {
// Step 4: write frontmatter after 1700ms
timeouts.push(
window.setTimeout(async () => {
window.setTimeout(() => {
this.pendingTimeouts.delete(key);
await this.callbacks.onToggleDone(task.file);
// Signal animation end so the view can resume re-renders
this.callbacks.onAnimationEnd?.();
void this.callbacks.onToggleDone(task.file).then(() => {
// Signal animation end so the view can resume re-renders
this.callbacks.onAnimationEnd?.();
});
}, 1700)
);
@ -79,7 +80,7 @@ export class CheckboxHandler {
rowElement.classList.remove("done", "completing");
checkbox?.classList.remove("checked");
this.callbacks.onToggleDone(task.file);
void this.callbacks.onToggleDone(task.file);
}
private cancelPending(key: string): void {

View file

@ -61,16 +61,17 @@ export class InlineCreator {
wrapper.appendChild(datalist);
// Keyboard handlers
const handleKeydown = async (e: KeyboardEvent) => {
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Enter" && actionInput.value.trim()) {
await this.createTask(
void this.createTask(
actionInput.value.trim(),
bucketName,
groupInput.value.trim()
);
actionInput.value = "";
groupInput.value = "";
actionInput.focus();
).then(() => {
actionInput.value = "";
groupInput.value = "";
actionInput.focus();
});
} else if (e.key === "Escape") {
wrapper.remove();
}

View file

@ -13,106 +13,91 @@ export default class TasksPlugin extends Plugin {
async onload(): Promise<void> {
await this.loadSettings();
// Initialize the task store
this.store = new TaskStore(this.app, () => this.settings);
// Register the custom view
this.registerView(TASKS_VIEW_TYPE, (leaf: WorkspaceLeaf) => {
return new TaskView(leaf, () => this.settings, this.store!);
});
// Add ribbon icon to open the task view
this.addRibbonIcon("square-check-big", "Open Tasks", () => {
this.activateView();
this.addRibbonIcon("square-check-big", "Open tasks", () => {
void this.activateView();
});
// Add command to open task view
this.addCommand({
id: "open-tasks-view",
name: "Open task board",
callback: () => {
this.activateView();
void this.activateView();
},
});
// Add command to toggle between focus and board mode
this.addCommand({
id: "toggle-tasks-mode",
name: "Toggle focus/board mode",
callback: () => {
// Toggle is handled within the view
this.activateView();
void this.activateView();
},
});
// Register settings tab
this.addSettingTab(new TasksSettingTab(this.app, this));
// Start inline task watcher
this.watcher = new InlineTaskWatcher(this.app, () => this.settings);
this.watcher.start();
// Clean up orphaned stubs when source files are deleted
this.registerEvent(
this.app.vault.on("delete", async (file: TAbstractFile) => {
this.app.vault.on("delete", (file: TAbstractFile) => {
if (!(file instanceof TFile)) return;
const taskFolder = this.settings.taskFolder;
// Only act on non-stub files (files outside the task folder)
if (file.path.startsWith(taskFolder + "/")) return;
const stubs = this.store?.getStubsForSource(file.path) ?? [];
for (const stub of stubs) {
await this.app.vault.delete(stub);
void this.app.fileManager.trashFile(stub);
}
})
);
// Update source_file in stubs when source files are renamed
this.registerEvent(
this.app.vault.on("rename", async (file: TAbstractFile, oldPath: string) => {
this.app.vault.on("rename", (file: TAbstractFile, oldPath: string) => {
if (!(file instanceof TFile)) return;
const taskFolder = this.settings.taskFolder;
// Only act on non-stub files (files outside the task folder)
if (file.path.startsWith(taskFolder + "/")) return;
const stubs = this.store?.getStubsForSource(oldPath) ?? [];
for (const stub of stubs) {
await this.app.fileManager.processFrontMatter(stub, (fm) => {
void this.app.fileManager.processFrontMatter(stub, (fm) => {
fm.source_file = file.path;
});
}
})
);
// After vault is ready: ensure folder exists, then load tasks
this.app.workspace.onLayoutReady(async () => {
await this.ensureTaskFolder();
await this.store?.loadTasks();
this.app.workspace.onLayoutReady(() => {
void (async () => {
await this.ensureTaskFolder();
this.store?.loadTasks();
})();
});
}
async onunload(): Promise<void> {
onunload(): void {
this.watcher?.destroy();
this.store?.destroy();
}
private async ensureTaskFolder(): Promise<void> {
let folder = this.settings.taskFolder;
const folder = this.settings.taskFolder;
// Check if the configured folder already exists
if (this.app.vault.getAbstractFileByPath(folder)) {
return;
}
// Try the default name at vault root
const defaultName = "_Tasks";
if (folder === defaultName || !this.app.vault.getAbstractFileByPath(defaultName)) {
// Use the default name
await this.app.vault.createFolder(defaultName);
this.settings.taskFolder = defaultName;
await this.saveSettings();
return;
}
// Default name is taken — append timestamp
const now = new Date();
const stamp = [
now.getFullYear(),
@ -131,12 +116,10 @@ export default class TasksPlugin extends Plugin {
private async activateView(): Promise<void> {
const existing = this.app.workspace.getLeavesOfType(TASKS_VIEW_TYPE);
if (existing.length > 0) {
// Focus existing view
this.app.workspace.revealLeaf(existing[0]);
return;
}
// Open in a new leaf
const leaf = this.app.workspace.getLeaf("tab");
await leaf.setViewState({
type: TASKS_VIEW_TYPE,
@ -148,7 +131,6 @@ export default class TasksPlugin extends Plugin {
async loadSettings(): Promise<void> {
const loaded = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);
// Ensure nested objects are merged properly
if (loaded?.display) {
this.settings.display = Object.assign(
{},
@ -163,7 +145,6 @@ export default class TasksPlugin extends Plugin {
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
// Reload tasks to pick up any config changes
this.store?.loadTasks();
}
}

View file

@ -18,8 +18,7 @@ export class TasksSettingTab extends PluginSettingTab {
const { containerEl } = this;
containerEl.empty();
// ── General ──────────────────────────────────────────────
containerEl.createEl("h2", { text: "General" });
new Setting(containerEl).setName("General").setHeading();
new Setting(containerEl)
.setName("Task folder")
@ -61,8 +60,7 @@ export class TasksSettingTab extends PluginSettingTab {
})
);
// ── Buckets ──────────────────────────────────────────────
containerEl.createEl("h2", { text: "Buckets" });
new Setting(containerEl).setName("Buckets").setHeading();
const bucketPropertySetting = new Setting(containerEl)
.setName("Group by property")
@ -87,7 +85,6 @@ export class TasksSettingTab extends PluginSettingTab {
text: "Property names are case-sensitive in frontmatter. Use lowercase.",
});
// Bucket list (custom DOM)
const bucketSection = containerEl.createDiv({ cls: "tasks-bucket-list" });
bucketSection.createEl("div", {
text: "Buckets",
@ -102,58 +99,54 @@ export class TasksSettingTab extends PluginSettingTab {
this.plugin.settings.buckets.forEach((bucket, index) => {
const row = listEl.createDiv({ cls: "tasks-bucket-row" });
row.style.display = "flex";
row.style.alignItems = "center";
row.style.gap = "6px";
row.style.marginBottom = "4px";
// Up arrow
const upBtn = row.createEl("button", { text: "\u2191" });
upBtn.setAttribute("aria-label", "Move up");
upBtn.disabled = index === 0;
upBtn.addEventListener("click", async () => {
this.swapBuckets(index, index - 1);
await this.plugin.saveSettings();
this.display();
upBtn.addEventListener("click", () => {
void (async () => {
this.swapBuckets(index, index - 1);
await this.plugin.saveSettings();
this.display();
})();
});
// Down arrow
const downBtn = row.createEl("button", { text: "\u2193" });
downBtn.setAttribute("aria-label", "Move down");
downBtn.disabled = index === this.plugin.settings.buckets.length - 1;
downBtn.addEventListener("click", async () => {
this.swapBuckets(index, index + 1);
await this.plugin.saveSettings();
this.display();
downBtn.addEventListener("click", () => {
void (async () => {
this.swapBuckets(index, index + 1);
await this.plugin.saveSettings();
this.display();
})();
});
// Name input
const input = row.createEl("input", { type: "text" });
input.value = bucket.name;
input.style.flex = "1";
input.addEventListener("change", async () => {
const newName = input.value.trim();
const duplicate = this.plugin.settings.buckets.some(
(b, i) => i !== index && b.name === newName
);
if (duplicate) {
new Notice("A bucket with that name already exists.");
input.value = bucket.name;
return;
}
bucket.name = newName;
await this.plugin.saveSettings();
input.addEventListener("change", () => {
void (async () => {
const newName = input.value.trim();
const duplicate = this.plugin.settings.buckets.some(
(b, i) => i !== index && b.name === newName
);
if (duplicate) {
new Notice("A bucket with that name already exists.");
input.value = bucket.name;
return;
}
bucket.name = newName;
await this.plugin.saveSettings();
})();
});
// Delete button with confirmation
const delBtn = row.createEl("button", { text: "\u00d7" });
delBtn.setAttribute("aria-label", "Delete bucket");
let confirmPending = false;
let confirmTimeout: ReturnType<typeof setTimeout> | null = null;
delBtn.addEventListener("click", async () => {
delBtn.addEventListener("click", () => {
if (!confirmPending) {
// First click: count tasks and show confirmation state
const taskCount = await this.countTasksInBucket(bucket);
const taskCount = this.countTasksInBucket(bucket);
delBtn.textContent = `Confirm? (${taskCount} task${taskCount !== 1 ? "s" : ""})`;
confirmPending = true;
confirmTimeout = setTimeout(() => {
@ -162,7 +155,6 @@ export class TasksSettingTab extends PluginSettingTab {
}, 3000);
return;
}
// Second click: actually delete
if (confirmTimeout) clearTimeout(confirmTimeout);
this.plugin.settings.buckets.splice(index, 1);
this.plugin.settings.buckets.forEach((b, i) => (b.sortOrder = i));
@ -172,26 +164,30 @@ export class TasksSettingTab extends PluginSettingTab {
? this.plugin.settings.buckets[0].id
: "";
}
void (async () => {
await this.plugin.saveSettings();
this.display();
})();
});
});
const addBtn = bucketSection.createEl("button", {
text: "+ Add bucket",
cls: "tasks-bucket-add-btn",
});
addBtn.addEventListener("click", () => {
void (async () => {
const id = "bucket_" + Date.now().toString(36);
this.plugin.settings.buckets.push({
id,
name: "New bucket",
sortOrder: this.plugin.settings.buckets.length,
});
await this.plugin.saveSettings();
this.display();
});
})();
});
// Add bucket button
const addBtn = bucketSection.createEl("button", { text: "+ Add bucket" });
addBtn.style.marginTop = "6px";
addBtn.addEventListener("click", async () => {
const id = "bucket_" + Date.now().toString(36);
this.plugin.settings.buckets.push({
id,
name: "New Bucket",
sortOrder: this.plugin.settings.buckets.length,
});
await this.plugin.saveSettings();
this.display();
});
// Default bucket dropdown
new Setting(containerEl)
.setName("Default bucket for new tasks")
.setDesc("Bucket assigned to tasks that don't match any other.")
@ -218,8 +214,7 @@ export class TasksSettingTab extends PluginSettingTab {
})
);
// ── Secondary Grouping ───────────────────────────────────
containerEl.createEl("h2", { text: "Secondary Grouping" });
new Setting(containerEl).setName("Secondary grouping").setHeading();
new Setting(containerEl)
.setName("Enable sub-groups")
@ -265,8 +260,7 @@ export class TasksSettingTab extends PluginSettingTab {
);
}
// ── Display ──────────────────────────────────────────────
containerEl.createEl("h2", { text: "Display" });
new Setting(containerEl).setName("Display").setHeading();
new Setting(containerEl)
.setName("Show due date")
@ -334,11 +328,10 @@ export class TasksSettingTab extends PluginSettingTab {
})
);
// ── Getting Started ──────────────────────────────────────
containerEl.createEl("h2", { text: "Getting Started" });
new Setting(containerEl).setName("Getting started").setHeading();
containerEl.createEl("p", {
text: 'Add #task to any checkbox in your notes: - [ ] Do something #task',
text: "Add #task to any checkbox in your notes: - [ ] Do something #task",
cls: "setting-item-description",
});
containerEl.createEl("p", {
@ -361,11 +354,7 @@ export class TasksSettingTab extends PluginSettingTab {
buckets.forEach((b, i) => (b.sortOrder = i));
}
/**
* Count how many task files in the _Tasks/ folder have a bucket property
* matching the given bucket's name.
*/
private async countTasksInBucket(bucket: BucketConfig): Promise<number> {
private countTasksInBucket(bucket: BucketConfig): number {
const folder = this.plugin.settings.taskFolder;
const property = this.plugin.settings.bucketProperty;
const files = this.app.vault.getFiles().filter(

View file

@ -1,4 +1,4 @@
import { BucketGroup, DisplayConfig, BucketConfig, CLS } from "../types";
import { BucketGroup, DisplayConfig, CLS } from "../types";
import { createBucket, BucketCallbacks } from "./bucketComponent";
export interface FocusRendererConfig {

View file

@ -4,14 +4,12 @@ import {
CLS,
TaskItem,
TasksPluginSettings,
BucketGroup,
} from "../types";
import { TaskStore } from "../data/taskStore";
import { FocusRenderer } from "./focusRenderer";
import { BoardRenderer } from "./boardRenderer";
import { DragManager } from "../interactions/dragManager";
import { CheckboxHandler } from "../interactions/checkboxHandler";
import { TaskRowCallbacks } from "./taskRow";
import { BucketCallbacks } from "./bucketComponent";
import { InlineCreator } from "../interactions/inlineCreator";
@ -38,8 +36,12 @@ export class TaskView extends ItemView {
this.currentMode = settings().defaultView;
this.dragManager = new DragManager({
onMoveToBucket: (file, newBucket) => this.store.moveToBucket(file, newBucket),
onReorder: (file, order) => this.store.reorder(file, order),
onMoveToBucket: (file, newBucket) => {
void this.store.moveToBucket(file, newBucket);
},
onReorder: (file, order) => {
void this.store.reorder(file, order);
},
});
this.checkboxHandler = new CheckboxHandler({
@ -90,7 +92,7 @@ export class TaskView extends ItemView {
const contentArea = this.rootEl.createDiv({ cls: `${CLS}-content` });
// Load tasks first, then render
await this.store.loadTasks();
this.store.loadTasks();
this.renderView(contentArea);
// React to data changes (debounced to avoid re-render storms)
@ -135,12 +137,22 @@ export class TaskView extends ItemView {
}
const emptyState = document.createElement("div");
emptyState.className = `${CLS}-empty-state`;
emptyState.innerHTML = [
`<div class="${CLS}-empty-icon">\u2610</div>`,
`<div class="${CLS}-empty-title">No tasks yet</div>`,
`<div class="${CLS}-empty-hint">Add #task to any checkbox in your notes to see it here.</div>`,
`<div class="${CLS}-empty-hint">Example: - [ ] Send report [[Project Alpha]] #task</div>`,
].join("");
const iconEl = document.createElement("div");
iconEl.className = `${CLS}-empty-icon`;
iconEl.textContent = "\u2610";
emptyState.appendChild(iconEl);
const titleEl = document.createElement("div");
titleEl.className = `${CLS}-empty-title`;
titleEl.textContent = "No tasks yet";
emptyState.appendChild(titleEl);
const hint1 = document.createElement("div");
hint1.className = `${CLS}-empty-hint`;
hint1.textContent = "Add #task to any checkbox in your notes to see it here.";
emptyState.appendChild(hint1);
const hint2 = document.createElement("div");
hint2.className = `${CLS}-empty-hint`;
hint2.textContent = "Example: - [ ] Send report [[Project Alpha]] #task";
emptyState.appendChild(hint2);
contentArea.appendChild(emptyState);
return;
}
@ -211,7 +223,6 @@ export class TaskView extends ItemView {
if (row) {
// P0: Block re-renders during the completion animation
this.animating = true;
const originalOnComplete = this.checkboxHandler.handleToggle.bind(this.checkboxHandler);
this.checkboxHandler.handleToggle(task, row);
// The checkbox handler uses a 1700ms delay; set a timeout to clear the flag
setTimeout(() => {
@ -223,11 +234,9 @@ export class TaskView extends ItemView {
this.dragManager.handleDragStart(task, event);
},
onClick: (task: TaskItem) => {
// Open the source note or the task file in a new leaf
const targetPath = task.sourceNote || task.file.path;
const file = this.app.vault.getAbstractFileByPath(task.file.path);
if (file instanceof TFile) {
this.app.workspace.getLeaf("tab").openFile(file);
void this.app.workspace.getLeaf("tab").openFile(file);
}
},
onAddTask: (bucketName: string) => {
@ -243,7 +252,9 @@ export class TaskView extends ItemView {
const menu = new Menu();
for (const bucket of this.settings().buckets) {
menu.addItem((item) =>
item.setTitle(bucket.name).onClick(() => this.store.moveToBucket(task.file, bucket.name))
item.setTitle(bucket.name).onClick(() => {
void this.store.moveToBucket(task.file, bucket.name);
})
);
}
menu.showAtMouseEvent(event);
@ -265,9 +276,10 @@ export class TaskView extends ItemView {
this.inlineCreator.showInput(contentArea, bucketName, existingGroups);
}
async onClose(): Promise<void> {
onClose(): Promise<void> {
this.focusRenderer?.destroy();
this.boardRenderer?.destroy();
this.dragManager.destroy();
return Promise.resolve();
}
}

View file

@ -541,6 +541,24 @@
}
}
/* ============================================================
SETTINGS BUCKET LIST
============================================================ */
.tasks-bucket-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.tasks-bucket-row input[type="text"] {
flex: 1;
}
.tasks-bucket-add-btn {
margin-top: 6px;
}
/* ============================================================
INLINE CREATION SECONDARY GROUP INPUT
============================================================ */

View file

@ -1,4 +1,5 @@
{
"0.1.0": "1.10.0",
"0.1.1": "1.10.0"
"0.1.1": "1.10.0",
"0.1.2": "1.10.0"
}