From 317ebbf0df967532cb408fbe8b56c97c35fe4378 Mon Sep 17 00:00:00 2001 From: renatomen Date: Thu, 23 Jul 2026 12:53:07 +1200 Subject: [PATCH 01/24] feat(status): add lifecycle category to StatusConfig Add an optional StatusCategory (planned | in-progress | completed) to StatusConfig, kept in sync with the load-bearing isCompleted flag rather than replacing it. A load-time normalization backfills category=completed for already-completed statuses and leaves everything else uncategorized (no guess). StatusManager gains isStarted/isFinished/getCategory so the dependency cache can evaluate start-anchored constraints. --- src/services/StatusManager.ts | 23 +++- src/settings/defaults.ts | 21 ++++ src/settings/settingsPersistence.ts | 6 +- src/types.ts | 11 +- .../services/statusManager.lifecycle.test.ts | 105 ++++++++++++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 tests/unit/services/statusManager.lifecycle.test.ts diff --git a/src/services/StatusManager.ts b/src/services/StatusManager.ts index c94604f39..c26d01412 100644 --- a/src/services/StatusManager.ts +++ b/src/services/StatusManager.ts @@ -1,4 +1,4 @@ -import { StatusConfig } from "../types"; +import { StatusConfig, StatusCategory } from "../types"; import { normalizeStatusConfigValue } from "../core/fieldMapping"; import { isSupportedColorValue, normalizeThemeColor } from "../utils/themeColors"; @@ -137,6 +137,27 @@ export class StatusManager { return status?.isCompleted || false; } + getCategory(statusValue: string): StatusCategory | undefined { + return this.getStatusConfig(statusValue)?.category; + } + + /** + * Whether the status counts as started — the constraint anchor for STARTTOSTART / + * STARTTOFINISH edges. Uncategorized and planned are not started. + */ + isStarted(statusValue: string): boolean { + const category = this.getCategory(statusValue); + return category === "in-progress" || category === "completed"; + } + + /** + * Whether the status counts as finished — the certain anchor for FINISHTOSTART / + * FINISHTOFINISH edges. Equivalent to isCompleted. + */ + isFinished(statusValue: string): boolean { + return this.isCompletedStatus(statusValue); + } + /** * Get status order for sorting */ diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 106920c5d..78a99dddd 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -32,6 +32,7 @@ export const DEFAULT_STATUSES: StatusConfig[] = [ label: "None", color: "#cccccc", isCompleted: false, + category: "planned", excludeFromCycle: false, order: 0, autoArchive: false, @@ -43,6 +44,7 @@ export const DEFAULT_STATUSES: StatusConfig[] = [ label: "Open", color: "#808080", isCompleted: false, + category: "planned", excludeFromCycle: false, order: 1, autoArchive: false, @@ -54,6 +56,7 @@ export const DEFAULT_STATUSES: StatusConfig[] = [ label: "In progress", color: "#0066cc", isCompleted: false, + category: "in-progress", excludeFromCycle: false, order: 2, autoArchive: false, @@ -65,6 +68,7 @@ export const DEFAULT_STATUSES: StatusConfig[] = [ label: "Done", color: "#00aa00", isCompleted: true, + category: "completed", excludeFromCycle: false, order: 3, autoArchive: false, @@ -72,6 +76,23 @@ export const DEFAULT_STATUSES: StatusConfig[] = [ }, ]; +/** + * Backfills lifecycle `category` on load: an already-`isCompleted` status becomes + * `completed` (and stays in sync); every other status is left uncategorized — no + * guess of planned vs in-progress. Idempotent. + */ +export function normalizeStatusCategories(statuses: StatusConfig[]): StatusConfig[] { + return statuses.map((status) => { + if (status.category === "completed") { + return status.isCompleted ? status : { ...status, isCompleted: true }; + } + if (status.category !== undefined) { + return status; + } + return status.isCompleted ? { ...status, category: "completed" } : status; + }); +} + // Default priority configuration matches current hardcoded behavior export const DEFAULT_PRIORITIES: PriorityConfig[] = [ { diff --git a/src/settings/settingsPersistence.ts b/src/settings/settingsPersistence.ts index a04d3c5bf..fd7e235b6 100644 --- a/src/settings/settingsPersistence.ts +++ b/src/settings/settingsPersistence.ts @@ -1,5 +1,5 @@ import { normalizePath } from "obsidian"; -import { DEFAULT_NLP_TRIGGERS, DEFAULT_SETTINGS } from "./defaults"; +import { DEFAULT_NLP_TRIGGERS, DEFAULT_SETTINGS, normalizeStatusCategories } from "./defaults"; import { hasMissingMigratedSettings } from "./settingsMigration"; import type { TaskCreationDefaults, TaskNotesSettings } from "../types/settings"; import { initializeFieldConfig } from "../utils/fieldConfigDefaults"; @@ -231,7 +231,9 @@ export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): Se loadedData?.modalFieldsConfig, loadedData?.userFields ), - customStatuses: loadedData?.customStatuses || DEFAULT_SETTINGS.customStatuses, + customStatuses: normalizeStatusCategories( + loadedData?.customStatuses || DEFAULT_SETTINGS.customStatuses + ), customPriorities: loadedData?.customPriorities || DEFAULT_SETTINGS.customPriorities, savedViews: loadedData?.savedViews || DEFAULT_SETTINGS.savedViews, }; diff --git a/src/types.ts b/src/types.ts index c968e6bea..a13037111 100644 --- a/src/types.ts +++ b/src/types.ts @@ -728,13 +728,22 @@ export interface FieldMapping { sortOrder: string; // Numeric ordering within column (lower = higher) } +/** + * Lifecycle stage of a status. An absent category means uncategorized — it computes as + * not-started/not-finished (like planned) but records that the user has not set it yet. + */ +export type StatusCategory = "planned" | "in-progress" | "completed"; + +export const STATUS_CATEGORIES: readonly StatusCategory[] = ["planned", "in-progress", "completed"]; + export interface StatusConfig { id: string; // Unique identifier value: string; // What gets written to YAML label: string; // What displays in UI color: string; // Hex color for UI elements icon?: string; // Optional Lucide icon name (e.g., "circle", "check", "clock") - isCompleted: boolean; // Whether this counts as "done" + isCompleted: boolean; // Whether this counts as "done"; kept in sync with category === "completed" + category?: StatusCategory; // Lifecycle stage; absent = uncategorized (computes as not-started) isSkipped?: boolean; // Whether this counts as a skipped occurrence excludeFromCycle?: boolean; // Whether status-dot cycling should skip this status nextStatus?: string; // Optional status value to use when cycling forward from this status diff --git a/tests/unit/services/statusManager.lifecycle.test.ts b/tests/unit/services/statusManager.lifecycle.test.ts new file mode 100644 index 000000000..0c9a3522c --- /dev/null +++ b/tests/unit/services/statusManager.lifecycle.test.ts @@ -0,0 +1,105 @@ +import { StatusManager } from "../../../src/services/StatusManager"; +import { StatusConfig, StatusCategory } from "../../../src/types"; +import { normalizeStatusCategories, DEFAULT_STATUSES } from "../../../src/settings/defaults"; + +const createStatus = ( + value: string, + overrides: Partial = {} +): StatusConfig => ({ + id: value, + value, + label: value, + color: "#808080", + isCompleted: value === "done", + order: 0, + autoArchive: false, + autoArchiveDelay: 5, + ...overrides, +}); + +describe("StatusManager status lifecycle (U1)", () => { + describe("isCompletedStatus characterization (must stay unchanged)", () => { + it("keeps the default statuses' completion behavior", () => { + const manager = new StatusManager(DEFAULT_STATUSES); + expect(manager.isCompletedStatus("done")).toBe(true); + expect(manager.isCompletedStatus("open")).toBe(false); + expect(manager.isCompletedStatus("in-progress")).toBe(false); + expect(manager.isCompletedStatus("none")).toBe(false); + }); + }); + + describe("normalizeStatusCategories migration", () => { + it("derives category 'completed' only for statuses already flagged isCompleted", () => { + const [done] = normalizeStatusCategories([createStatus("done", { isCompleted: true })]); + expect(done.category).toBe("completed"); + expect(done.isCompleted).toBe(true); + }); + + it("leaves a non-completed status uncategorized (category absent), isCompleted unchanged", () => { + const [open] = normalizeStatusCategories([createStatus("open", { isCompleted: false })]); + expect(open.category).toBeUndefined(); + expect(open.isCompleted).toBe(false); + }); + + it("leaves a status that already carries a category as-is", () => { + const [inProgress] = normalizeStatusCategories([ + createStatus("doing", { isCompleted: false, category: "in-progress" }), + ]); + expect(inProgress.category).toBe("in-progress"); + expect(inProgress.isCompleted).toBe(false); + }); + + it("enforces isCompleted for the completed case (category 'completed' implies isCompleted)", () => { + const [finished] = normalizeStatusCategories([ + createStatus("shipped", { isCompleted: false, category: "completed" }), + ]); + expect(finished.category).toBe("completed"); + expect(finished.isCompleted).toBe(true); + }); + + it("is idempotent", () => { + const once = normalizeStatusCategories(DEFAULT_STATUSES); + const twice = normalizeStatusCategories(once); + expect(twice).toEqual(once); + }); + }); + + describe("isStarted / isFinished predicates", () => { + const buildManager = () => + new StatusManager([ + createStatus("planned", { category: "planned", isCompleted: false }), + createStatus("uncategorized", { isCompleted: false }), + createStatus("doing", { category: "in-progress", isCompleted: false }), + createStatus("done", { category: "completed", isCompleted: true }), + ]); + + it("isStarted is true for in-progress and completed, false for planned and uncategorized", () => { + const manager = buildManager(); + expect(manager.isStarted("doing")).toBe(true); + expect(manager.isStarted("done")).toBe(true); + expect(manager.isStarted("planned")).toBe(false); + expect(manager.isStarted("uncategorized")).toBe(false); + }); + + it("isFinished is true only for completed and equals isCompleted for every status", () => { + const manager = buildManager(); + for (const value of ["planned", "uncategorized", "doing", "done"]) { + expect(manager.isFinished(value)).toBe(manager.isCompletedStatus(value)); + } + expect(manager.isFinished("done")).toBe(true); + expect(manager.isFinished("doing")).toBe(false); + }); + + it("getCategory returns the configured category or undefined when uncategorized", () => { + const manager = buildManager(); + expect(manager.getCategory("doing")).toBe("in-progress"); + expect(manager.getCategory("uncategorized")).toBeUndefined(); + }); + + it("treats an unknown status as neither started nor finished", () => { + const manager = buildManager(); + expect(manager.isStarted("missing")).toBe(false); + expect(manager.isFinished("missing")).toBe(false); + }); + }); +}); From 94fd16b0d437eac4f842a7a8571bd2473eaab013 Mon Sep 17 00:00:00 2001 From: renatomen Date: Thu, 23 Jul 2026 13:09:49 +1200 Subject: [PATCH 02/24] feat(status): category dropdown and lifecycle pill in status settings Replace the binary completed toggle in the status config card with a category dropdown (Uncategorized / Planned / In progress / Completed) and show the chosen category on the card pill, mirroring how the completed badge rendered before. Selecting Completed keeps isCompleted in sync; migrated statuses render a neutral Uncategorized nudge. Adds category pill styles and the i18n strings. --- i18n.manifest.json | 9 ++ i18n.state.json | 72 ++++++++++ src/i18n/resources/en.ts | 12 ++ src/settings/components/CardComponent.ts | 9 +- .../tabs/taskProperties/statusPropertyCard.ts | 65 ++++++--- styles/settings-view.css | 21 +++ .../statusPropertyCard.lifecycle.test.ts | 131 ++++++++++++++++++ 7 files changed, 297 insertions(+), 22 deletions(-) create mode 100644 tests/unit/settings/statusPropertyCard.lifecycle.test.ts diff --git a/i18n.manifest.json b/i18n.manifest.json index a30b9bbec..bc0aebc0c 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -705,6 +705,7 @@ "settings.taskProperties.taskStatuses.howTheyWork.color": "b3a4e0aed552d59293608963c489a5ab0e627c33", "settings.taskProperties.taskStatuses.howTheyWork.icon": "990a304e39a65c9cab8405d690b593f3b01008f0", "settings.taskProperties.taskStatuses.howTheyWork.completed": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", + "settings.taskProperties.taskStatuses.howTheyWork.category": "2b8da0273c7362094590a0758422ecf689b8fe6c", "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "settings.taskProperties.taskStatuses.howTheyWork.orderNote": "622251b5df80b62ec75e854fb074594cf7cee6cb", "settings.taskProperties.taskStatuses.addNew.name": "d8f65cf551e5eeb9b07f3a1279ba4ed8b0172a46", @@ -718,6 +719,7 @@ "settings.taskProperties.taskStatuses.fields.color": "511a000614ad52294121be47cb86170db16ba777", "settings.taskProperties.taskStatuses.fields.icon": "b6d7019b1817040a55577652ed26649af8ca0183", "settings.taskProperties.taskStatuses.fields.completed": "208409ffed559a03204711e8976b06e3710deca5", + "settings.taskProperties.taskStatuses.fields.category": "61b9204fa30ab100d2a82ad42f62e455b1d2df3a", "settings.taskProperties.taskStatuses.fields.excludeFromCycle": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "settings.taskProperties.taskStatuses.fields.nextStatus": "b8d1a252e8580a447b2108ebb362443b15ba73f7", "settings.taskProperties.taskStatuses.fields.autoArchive": "ebba9937a4826ac159c5c5fd87a98562f3aff675", @@ -727,6 +729,13 @@ "settings.taskProperties.taskStatuses.placeholders.icon": "6c7a4a074ff62754681360bda7c3994c8029742b", "settings.taskProperties.taskStatuses.placeholders.nextStatusDefault": "0516a99c523425cc4a3eee70cab344e056e41db6", "settings.taskProperties.taskStatuses.badges.completed": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", + "settings.taskProperties.taskStatuses.badges.planned": "9cbe42aaff6033b7c9c52581f0c42a0598b76109", + "settings.taskProperties.taskStatuses.badges.inProgress": "b6bd42e4e7302e5e9a15930c8fcdb73fc2c0cf18", + "settings.taskProperties.taskStatuses.badges.uncategorized": "4ef7d73c1a83464f50c8d1b29b43e85facfdf3ee", + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": "4ef7d73c1a83464f50c8d1b29b43e85facfdf3ee", + "settings.taskProperties.taskStatuses.categoryOptions.planned": "9cbe42aaff6033b7c9c52581f0c42a0598b76109", + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": "b6bd42e4e7302e5e9a15930c8fcdb73fc2c0cf18", + "settings.taskProperties.taskStatuses.categoryOptions.completed": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "settings.taskProperties.taskStatuses.deleteConfirm": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "settings.taskProperties.taskPriorities.header": "44c22d1630a67a7d465d56ddea064d62259dc9dc", "settings.taskProperties.taskPriorities.description": "27e335b697e148e2ff9d76108bbb76d7081f0836", diff --git a/i18n.state.json b/i18n.state.json index 01d90a504..d41bb9195 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2824,6 +2824,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "cb9ab4cdcceb9e2aeb360e177c24b02d93f30ac8" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "692e1e30972b496ecd275c59b3bdef98fc743fd4" @@ -2876,6 +2877,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "a138e2592f8e86664bee5d2261abd967cb1e1024" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "977a68aff1290a7f18c23491f18fd496635c4704" @@ -2912,6 +2914,13 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "be274c6e71f4a5837b1f8124da980ac92a56ac7e" }, + "settings.taskProperties.taskStatuses.badges.planned": null, + "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.badges.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.planned": null, + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, + "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "bd5e5868fd22e54aa9b5018245a7dac8304bd1f6" @@ -11690,6 +11699,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "ef5bc07ca67951bfd0227f7cde1f9d628e93da09" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "3d6f9cec0a3f2f016e0d1d5d97750710f924e41a" @@ -11742,6 +11752,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "49bcf96e290b8b04ab476892243e71fa2f3c486a" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "9b8011e3aa98c24cba7a3abd3cfd47fa59d3f447" @@ -11778,6 +11789,13 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "76a3d55ad062a9d4590170afbe43b600933f564d" }, + "settings.taskProperties.taskStatuses.badges.planned": null, + "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.badges.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.planned": null, + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, + "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "6181bea9a3b42002b16368c96c4b7ec405424a9d" @@ -20556,6 +20574,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "8df3b29da68c74050b5af7233aa7f88b32cba2cc" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "5a24d44f681e6afe77c73cff479d5c7828569810" @@ -20608,6 +20627,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "6edcf9c3483f8a2577d3a8175dac1e55219a8241" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "042f017c2648c2cd227ff2b0cf95a821e19cea54" @@ -20644,6 +20664,13 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "3bc5972000ab5d6e76d30edba6b124eaf7f3bc15" }, + "settings.taskProperties.taskStatuses.badges.planned": null, + "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.badges.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.planned": null, + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, + "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "b9270228ed590ded12e0538f71a4adaa6702b218" @@ -29422,6 +29449,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "9e0eaf71298108654fd791ac948dd6c8dd1b439e" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "c7ecc4029c4dd7625ebca6fe44c596c26338d981" @@ -29474,6 +29502,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "886556b844ad628bf72491209c1e20304e58c722" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "8d3d57a8b4f55f55dcc1d748c73d05ff110619cf" @@ -29510,6 +29539,13 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "1cae18a9a401bd5c4ec499d517e0c653ad6a5a1b" }, + "settings.taskProperties.taskStatuses.badges.planned": null, + "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.badges.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.planned": null, + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, + "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "d7a8c7b2efbbd6760f19259c206a211ffc3b0674" @@ -38288,6 +38324,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "92fc9c57e556f7b81989589101b7e3b4cf77244e" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "f410df49c842cee9f0fd04207f66a9f7d084835e" @@ -38340,6 +38377,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "18bafeed301d12d6ecf88b2ad49467af8a9f3498" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "3b2c81ef0aabc2b45ca597bfc52ff601abaa9540" @@ -38376,6 +38414,13 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "1f74613ee3eeed953e1aa998a0b081df5eb6949f" }, + "settings.taskProperties.taskStatuses.badges.planned": null, + "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.badges.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.planned": null, + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, + "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "f6882f9122f9b4e5f620c536e1d062ebc3225517" @@ -47154,6 +47199,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "c041d0942c21612e1c4fd36563380da2781424bc" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "a5d6f43e85055d22939a19a1f69673400925464e" @@ -47206,6 +47252,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "8fbc8d895e59321d9d909fd12d9322147afddc98" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "f9a26783183369f7753926a8c50f866faffaddfc" @@ -47242,6 +47289,13 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "dbeb4db1889042371ea9e0acdbed584b61f923dc" }, + "settings.taskProperties.taskStatuses.badges.planned": null, + "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.badges.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.planned": null, + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, + "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "5a9415cfc781eb37f56b19b6451cd9da046630de" @@ -56020,6 +56074,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "652c5354d61a3dd8ee6f00b351a77d0d86d34a79" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "58d500bcc42fe10e16454b41f6c9caf89a3ec23e" @@ -56072,6 +56127,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "bb155f6460bf0b86f81e40d4cb310624c008ea76" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "7ca1e438ff083d0121ab00f1abc9c81aed81e5cf" @@ -56108,6 +56164,13 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "016bc923f7ff6c70ac895a10fe7d5205b7fb35f0" }, + "settings.taskProperties.taskStatuses.badges.planned": null, + "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.badges.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.planned": null, + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, + "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "0c211b5422e7e0d78ec042fbd5154781a79bcd90" @@ -64886,6 +64949,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "2b7120e8e1d1fdc14d8013fb0f784020d4179f3e" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "bcea4aa6f70e5baa25c94c14da65f9473fcf697c" @@ -64938,6 +65002,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "4fc47c7b6e532be9bca0e26fb998757773d99999" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "050a8fffceabd90128bd8a5b501c6dc71c67d346" @@ -64974,6 +65039,13 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "e99b48a29bdfdc828623c75b4e5f41367c3c076c" }, + "settings.taskProperties.taskStatuses.badges.planned": null, + "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.badges.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, + "settings.taskProperties.taskStatuses.categoryOptions.planned": null, + "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, + "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "20035d63eaafed20bd3b4001a0dca01e09b82186" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0926250b6..a64be33f7 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1214,6 +1214,8 @@ export const en: TranslationTree = { icon: 'Icon: Optional Lucide icon name to display instead of colored dot (e.g., "check", "circle", "clock"). Browse icons at lucide.dev', completed: "Completed: When checked, tasks with this status are considered finished and may be filtered differently", + category: + "Category: Sets the lifecycle stage so dependencies can tell when a task has started or finished. A completed category counts as done, the same as the old completed toggle.", autoArchive: "Auto-archive: When enabled, tasks will be automatically archived after the specified delay (1-1440 minutes)", orderNote: @@ -1234,6 +1236,7 @@ export const en: TranslationTree = { color: "Color:", icon: "Icon:", completed: "Completed:", + category: "Category:", excludeFromCycle: "Skip when cycling:", nextStatus: "Next status:", autoArchive: "Auto-archive:", @@ -1247,6 +1250,15 @@ export const en: TranslationTree = { }, badges: { completed: "Completed", + planned: "Planned", + inProgress: "In progress", + uncategorized: "Uncategorized", + }, + categoryOptions: { + uncategorized: "Uncategorized", + planned: "Planned", + inProgress: "In progress", + completed: "Completed", }, deleteConfirm: 'Are you sure you want to delete the status "{label}"?', }, diff --git a/src/settings/components/CardComponent.ts b/src/settings/components/CardComponent.ts index bed55dbe6..0f4038b2d 100644 --- a/src/settings/components/CardComponent.ts +++ b/src/settings/components/CardComponent.ts @@ -516,7 +516,14 @@ export function createCard(container: HTMLElement, config: CardConfig): HTMLElem */ export function createStatusBadge( text: string, - variant: "active" | "inactive" | "completed" | "default" = "default" + variant: + | "active" + | "inactive" + | "completed" + | "planned" + | "in-progress" + | "uncategorized" + | "default" = "default" ): HTMLElement { const badge = activeDocument.createElement("span"); badge.addClass("tasknotes-settings__card-status-badge"); diff --git a/src/settings/tabs/taskProperties/statusPropertyCard.ts b/src/settings/tabs/taskProperties/statusPropertyCard.ts index 5c0a3314c..2f562cd5d 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -18,6 +18,23 @@ import { } from "../../components/CardComponent"; import { createIconInput } from "../../components/IconSuggest"; import { createNLPTriggerRows, createPropertyDescription, TranslateFn } from "./helpers"; +import { StatusCategory, STATUS_CATEGORIES } from "../../../types"; + +// The badge i18n keys are camelCase, but the stored category value for "In progress" is +// the hyphenated "in-progress" (also the CSS variant); map the one that differs. +const categoryI18nKey = (category: StatusCategory | "uncategorized"): string => + category === "in-progress" ? "inProgress" : category; + +function createCategoryBadge( + category: StatusCategory | undefined, + translate: TranslateFn +): HTMLElement { + const variant = category ?? "uncategorized"; + return createStatusBadge( + translate(`settings.taskProperties.taskStatuses.badges.${categoryI18nKey(variant)}`), + variant + ); +} /** * Renders the Status property card with nested status value cards @@ -102,7 +119,7 @@ export function renderStatusPropertyCard( text: translate("settings.taskProperties.taskStatuses.howTheyWork.icon"), }); statusHelpList.createEl("li", { - text: translate("settings.taskProperties.taskStatuses.howTheyWork.completed"), + text: translate("settings.taskProperties.taskStatuses.howTheyWork.category"), }); statusHelpList.createEl("li", { text: translate("settings.taskProperties.taskStatuses.howTheyWork.autoArchive"), @@ -254,19 +271,32 @@ function renderStatusList( status.icon || "" ); - const completedToggle = createCardToggle(status.isCompleted || false, (value) => { - status.isCompleted = value; + const categorySelect = createCardSelect( + [ + { + value: "", + label: translate( + "settings.taskProperties.taskStatuses.categoryOptions.uncategorized" + ), + }, + ...STATUS_CATEGORIES.map((category) => ({ + value: category, + label: translate( + `settings.taskProperties.taskStatuses.categoryOptions.${categoryI18nKey(category)}` + ), + })), + ], + status.category ?? "" + ); + + categorySelect.addEventListener("change", () => { + const value = categorySelect.value; + status.category = value ? (value as StatusCategory) : undefined; + status.isCompleted = value === "completed"; const metaContainer = statusCard?.querySelector(".tasknotes-settings__card-meta"); if (metaContainer) { metaContainer.empty(); - if (status.isCompleted) { - metaContainer.appendChild( - createStatusBadge( - translate("settings.taskProperties.taskStatuses.badges.completed"), - "completed" - ) - ); - } + metaContainer.appendChild(createCategoryBadge(status.category, translate)); } save(); }); @@ -310,14 +340,7 @@ function renderStatusList( status.autoArchiveDelay || 5 ); - const metaElements = status.isCompleted - ? [ - createStatusBadge( - translate("settings.taskProperties.taskStatuses.badges.completed"), - "completed" - ), - ] - : []; + const metaElements = [createCategoryBadge(status.category, translate)]; let statusCard: HTMLElement; @@ -408,9 +431,9 @@ function renderStatusList( }, { label: translate( - "settings.taskProperties.taskStatuses.fields.completed" + "settings.taskProperties.taskStatuses.fields.category" ), - input: completedToggle, + input: categorySelect, }, { label: translate( diff --git a/styles/settings-view.css b/styles/settings-view.css index 983ac055e..42d6aadaa 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -482,6 +482,27 @@ body.is-mobile .tasknotes-plugin .tasknotes-settings__card-action-btn { color: var(--text-on-accent); } +.tasknotes-settings__card-status-badge--completed { + background: var(--color-green); + color: var(--text-on-accent); +} + +.tasknotes-settings__card-status-badge--in-progress { + background: var(--color-blue); + color: var(--text-on-accent); +} + +.tasknotes-settings__card-status-badge--planned { + background: var(--background-modifier-border); + color: var(--text-normal); +} + +.tasknotes-settings__card-status-badge--uncategorized { + background: transparent; + color: var(--text-muted); + border: 1px dashed var(--background-modifier-border); +} + .tasknotes-settings__card-info-badge { padding: 3px 6px; border-radius: 10px; diff --git a/tests/unit/settings/statusPropertyCard.lifecycle.test.ts b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts new file mode 100644 index 000000000..a3d4b7015 --- /dev/null +++ b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts @@ -0,0 +1,131 @@ +import TaskNotesPlugin from "../../../src/main"; +import { DEFAULT_SETTINGS } from "../../../src/settings/defaults"; +import { createI18nService } from "../../../src/i18n"; +import { renderStatusPropertyCard } from "../../../src/settings/tabs/taskProperties/statusPropertyCard"; +import { StatusConfig } from "../../../src/types"; + +(HTMLElement.prototype as any).appendText ??= function (text: string) { + this.appendChild(document.createTextNode(text)); +}; +(HTMLElement.prototype as any).setAttr ??= function (name: string, value: string) { + this.setAttribute(name, value); +}; + +// Deliberately named "todo" (not a category name) so findCategorySelect can key on the +// "planned" option to disambiguate the category dropdown from the default/next-status selects. +function createStatus(overrides: Partial = {}): StatusConfig { + return { + id: "todo", + value: "todo", + label: "Todo", + color: "#808080", + isCompleted: false, + order: 0, + autoArchive: false, + autoArchiveDelay: 5, + ...overrides, + }; +} + +function renderCard(status: StatusConfig) { + (global as any).Platform = { isMobile: false }; + const app: any = { + workspace: { onLayoutReady: (fn: any) => fn() }, + metadataCache: {}, + vault: { getConfig: jest.fn().mockReturnValue(false) }, + }; + const plugin = new TaskNotesPlugin(app); + (plugin as any).settings = { + ...DEFAULT_SETTINGS, + customStatuses: [status], + defaultTaskStatus: status.value, + }; + (plugin as any).registerEvent = jest.fn(); + (plugin as any).manifest = { version: "0.0.0" }; + const i18n = createI18nService(); + (plugin as any).i18n = i18n; + const translate = (key: string, params?: Record) => + i18n.translate(key, params); + const container = document.createElement("div"); + renderStatusPropertyCard(container, plugin, jest.fn(), translate); + return { container, status }; +} + +function categorySelect(container: HTMLElement): HTMLSelectElement { + const select = Array.from(container.querySelectorAll("select")).find((s) => + Array.from(s.options).some((o) => o.value === "planned") + ); + if (!select) throw new Error("category dropdown not found"); + return select as HTMLSelectElement; +} + +function choose(select: HTMLSelectElement, value: string): void { + select.value = value; + select.dispatchEvent(new Event("change")); +} + +function badge(container: HTMLElement): HTMLElement | null { + return container.querySelector(".tasknotes-settings__card-status-badge"); +} + +describe("Status config card — category dropdown + pill (U2)", () => { + it("selecting Completed sets category and keeps isCompleted true", () => { + const { container, status } = renderCard(createStatus()); + choose(categorySelect(container), "completed"); + expect(status.category).toBe("completed"); + expect(status.isCompleted).toBe(true); + }); + + it("selecting Planned or In progress sets isCompleted false", () => { + const { container, status } = renderCard( + createStatus({ isCompleted: true, category: "completed" }) + ); + const select = categorySelect(container); + choose(select, "in-progress"); + expect(status.category).toBe("in-progress"); + expect(status.isCompleted).toBe(false); + choose(select, "planned"); + expect(status.category).toBe("planned"); + expect(status.isCompleted).toBe(false); + }); + + it("selecting Uncategorized clears both category and isCompleted", () => { + const { container, status } = renderCard( + createStatus({ isCompleted: true, category: "completed" }) + ); + choose(categorySelect(container), ""); + expect(status.category).toBeUndefined(); + expect(status.isCompleted).toBe(false); + }); + + it("updates the pill text and variant class to match the selected category", () => { + const { container } = renderCard(createStatus()); + const select = categorySelect(container); + + choose(select, "in-progress"); + expect(badge(container)?.textContent).toBe("In progress"); + expect( + badge(container)?.classList.contains( + "tasknotes-settings__card-status-badge--in-progress" + ) + ).toBe(true); + + choose(select, ""); + expect(badge(container)?.textContent).toBe("Uncategorized"); + expect( + badge(container)?.classList.contains( + "tasknotes-settings__card-status-badge--uncategorized" + ) + ).toBe(true); + }); + + it("shows a neutral Uncategorized pill for a migrated (categoryless) status", () => { + const { container } = renderCard(createStatus()); + expect(badge(container)?.textContent).toBe("Uncategorized"); + expect( + badge(container)?.classList.contains( + "tasknotes-settings__card-status-badge--uncategorized" + ) + ).toBe(true); + }); +}); From ec4246482031bf69d2bebfb2a90936a5a6454668 Mon Sep 17 00:00:00 2001 From: renatomen Date: Thu, 23 Jul 2026 13:44:30 +1200 Subject: [PATCH 03/24] fix(review): address status-lifecycle review findings - Reconcile the isCompleted <-> category invariant in both directions in normalizeStatusCategories, so a completed status can never read as finished-but-not-started once the lifecycle predicates gain consumers. - Fold the duplicated categoryOptions strings into the shared badges keys the pill already uses; drop the orphaned completed toggle strings. - Cover the migration through the real settings load path, and add the completed-with-stale-category reconciliation case. - Record the status settings change in the unreleased changelog. --- docs/releases/unreleased.md | 4 + i18n.manifest.json | 6 -- i18n.state.json | 96 ------------------- src/i18n/resources/en.ts | 9 -- src/settings/defaults.ts | 18 ++-- .../tabs/taskProperties/statusPropertyCard.ts | 4 +- .../services/statusManager.lifecycle.test.ts | 10 +- .../unit/settings/settingsPersistence.test.ts | 31 ++++++ .../statusPropertyCard.lifecycle.test.ts | 4 +- 9 files changed, 57 insertions(+), 125 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..536ee7dd1 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Changed + +- Task statuses now have a lifecycle category — Planned, In progress, or Completed — shown as a pill in the status settings, replacing the previous "Completed" toggle. Existing statuses keep their completed state; other statuses stay uncategorized until you pick a category. diff --git a/i18n.manifest.json b/i18n.manifest.json index bc0aebc0c..0270930ae 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -704,7 +704,6 @@ "settings.taskProperties.taskStatuses.howTheyWork.label": "c601b207de14ad4111101e2639e1ddb4d2c58d73", "settings.taskProperties.taskStatuses.howTheyWork.color": "b3a4e0aed552d59293608963c489a5ab0e627c33", "settings.taskProperties.taskStatuses.howTheyWork.icon": "990a304e39a65c9cab8405d690b593f3b01008f0", - "settings.taskProperties.taskStatuses.howTheyWork.completed": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "settings.taskProperties.taskStatuses.howTheyWork.category": "2b8da0273c7362094590a0758422ecf689b8fe6c", "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "settings.taskProperties.taskStatuses.howTheyWork.orderNote": "622251b5df80b62ec75e854fb074594cf7cee6cb", @@ -718,7 +717,6 @@ "settings.taskProperties.taskStatuses.fields.label": "f70a9228e736a5c3a698e6037eed2dda9de1b40f", "settings.taskProperties.taskStatuses.fields.color": "511a000614ad52294121be47cb86170db16ba777", "settings.taskProperties.taskStatuses.fields.icon": "b6d7019b1817040a55577652ed26649af8ca0183", - "settings.taskProperties.taskStatuses.fields.completed": "208409ffed559a03204711e8976b06e3710deca5", "settings.taskProperties.taskStatuses.fields.category": "61b9204fa30ab100d2a82ad42f62e455b1d2df3a", "settings.taskProperties.taskStatuses.fields.excludeFromCycle": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "settings.taskProperties.taskStatuses.fields.nextStatus": "b8d1a252e8580a447b2108ebb362443b15ba73f7", @@ -732,10 +730,6 @@ "settings.taskProperties.taskStatuses.badges.planned": "9cbe42aaff6033b7c9c52581f0c42a0598b76109", "settings.taskProperties.taskStatuses.badges.inProgress": "b6bd42e4e7302e5e9a15930c8fcdb73fc2c0cf18", "settings.taskProperties.taskStatuses.badges.uncategorized": "4ef7d73c1a83464f50c8d1b29b43e85facfdf3ee", - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": "4ef7d73c1a83464f50c8d1b29b43e85facfdf3ee", - "settings.taskProperties.taskStatuses.categoryOptions.planned": "9cbe42aaff6033b7c9c52581f0c42a0598b76109", - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": "b6bd42e4e7302e5e9a15930c8fcdb73fc2c0cf18", - "settings.taskProperties.taskStatuses.categoryOptions.completed": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "settings.taskProperties.taskStatuses.deleteConfirm": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "settings.taskProperties.taskPriorities.header": "44c22d1630a67a7d465d56ddea064d62259dc9dc", "settings.taskProperties.taskPriorities.description": "27e335b697e148e2ff9d76108bbb76d7081f0836", diff --git a/i18n.state.json b/i18n.state.json index d41bb9195..f38c49631 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2820,10 +2820,6 @@ "source": "990a304e39a65c9cab8405d690b593f3b01008f0", "translation": "4cf9f51cb08699e219d05c3b783ff75ccd270ce3" }, - "settings.taskProperties.taskStatuses.howTheyWork.completed": { - "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", - "translation": "cb9ab4cdcceb9e2aeb360e177c24b02d93f30ac8" - }, "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", @@ -2873,10 +2869,6 @@ "source": "b6d7019b1817040a55577652ed26649af8ca0183", "translation": "b2af9436849ecfbfd8e9d4c69a1537ed842d7515" }, - "settings.taskProperties.taskStatuses.fields.completed": { - "source": "208409ffed559a03204711e8976b06e3710deca5", - "translation": "a138e2592f8e86664bee5d2261abd967cb1e1024" - }, "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", @@ -2917,10 +2909,6 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.badges.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.planned": null, - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, - "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "bd5e5868fd22e54aa9b5018245a7dac8304bd1f6" @@ -11695,10 +11683,6 @@ "source": "990a304e39a65c9cab8405d690b593f3b01008f0", "translation": "7f42ef4d037747fa4be95ea81f78ca3ab7886400" }, - "settings.taskProperties.taskStatuses.howTheyWork.completed": { - "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", - "translation": "ef5bc07ca67951bfd0227f7cde1f9d628e93da09" - }, "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", @@ -11748,10 +11732,6 @@ "source": "b6d7019b1817040a55577652ed26649af8ca0183", "translation": "4ff6bb16bf847f710ce6ab1f5ab96cfa19a56abb" }, - "settings.taskProperties.taskStatuses.fields.completed": { - "source": "208409ffed559a03204711e8976b06e3710deca5", - "translation": "49bcf96e290b8b04ab476892243e71fa2f3c486a" - }, "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", @@ -11792,10 +11772,6 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.badges.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.planned": null, - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, - "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "6181bea9a3b42002b16368c96c4b7ec405424a9d" @@ -20570,10 +20546,6 @@ "source": "990a304e39a65c9cab8405d690b593f3b01008f0", "translation": "8ca91686f38ce234088d76cefff944259783bd51" }, - "settings.taskProperties.taskStatuses.howTheyWork.completed": { - "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", - "translation": "8df3b29da68c74050b5af7233aa7f88b32cba2cc" - }, "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", @@ -20623,10 +20595,6 @@ "source": "b6d7019b1817040a55577652ed26649af8ca0183", "translation": "848ae1f94e4e70aa81a6de9f6da98d95f8b23207" }, - "settings.taskProperties.taskStatuses.fields.completed": { - "source": "208409ffed559a03204711e8976b06e3710deca5", - "translation": "6edcf9c3483f8a2577d3a8175dac1e55219a8241" - }, "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", @@ -20667,10 +20635,6 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.badges.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.planned": null, - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, - "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "b9270228ed590ded12e0538f71a4adaa6702b218" @@ -29445,10 +29409,6 @@ "source": "990a304e39a65c9cab8405d690b593f3b01008f0", "translation": "dfd006f3bbf958682d3af7295f764fbfb49a21ab" }, - "settings.taskProperties.taskStatuses.howTheyWork.completed": { - "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", - "translation": "9e0eaf71298108654fd791ac948dd6c8dd1b439e" - }, "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", @@ -29498,10 +29458,6 @@ "source": "b6d7019b1817040a55577652ed26649af8ca0183", "translation": "11ff66a9cdcd7973941b40ec95b667f437eba6f1" }, - "settings.taskProperties.taskStatuses.fields.completed": { - "source": "208409ffed559a03204711e8976b06e3710deca5", - "translation": "886556b844ad628bf72491209c1e20304e58c722" - }, "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", @@ -29542,10 +29498,6 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.badges.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.planned": null, - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, - "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "d7a8c7b2efbbd6760f19259c206a211ffc3b0674" @@ -38320,10 +38272,6 @@ "source": "990a304e39a65c9cab8405d690b593f3b01008f0", "translation": "dd1a75a00e08c92320718e72a50c3292dd15c219" }, - "settings.taskProperties.taskStatuses.howTheyWork.completed": { - "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", - "translation": "92fc9c57e556f7b81989589101b7e3b4cf77244e" - }, "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", @@ -38373,10 +38321,6 @@ "source": "b6d7019b1817040a55577652ed26649af8ca0183", "translation": "0991da6678249e5fb7e0c4ef90e0ea0b35c20935" }, - "settings.taskProperties.taskStatuses.fields.completed": { - "source": "208409ffed559a03204711e8976b06e3710deca5", - "translation": "18bafeed301d12d6ecf88b2ad49467af8a9f3498" - }, "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", @@ -38417,10 +38361,6 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.badges.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.planned": null, - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, - "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "f6882f9122f9b4e5f620c536e1d062ebc3225517" @@ -47195,10 +47135,6 @@ "source": "990a304e39a65c9cab8405d690b593f3b01008f0", "translation": "60185cdd22543271b14737b1015b3c913815edd2" }, - "settings.taskProperties.taskStatuses.howTheyWork.completed": { - "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", - "translation": "c041d0942c21612e1c4fd36563380da2781424bc" - }, "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", @@ -47248,10 +47184,6 @@ "source": "b6d7019b1817040a55577652ed26649af8ca0183", "translation": "26c09f738712062bd96e16115eb7050b1b7cb3d6" }, - "settings.taskProperties.taskStatuses.fields.completed": { - "source": "208409ffed559a03204711e8976b06e3710deca5", - "translation": "8fbc8d895e59321d9d909fd12d9322147afddc98" - }, "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", @@ -47292,10 +47224,6 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.badges.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.planned": null, - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, - "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "5a9415cfc781eb37f56b19b6451cd9da046630de" @@ -56070,10 +55998,6 @@ "source": "990a304e39a65c9cab8405d690b593f3b01008f0", "translation": "a210f602680dd0cc28750ae339048bdaabc28dba" }, - "settings.taskProperties.taskStatuses.howTheyWork.completed": { - "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", - "translation": "652c5354d61a3dd8ee6f00b351a77d0d86d34a79" - }, "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", @@ -56123,10 +56047,6 @@ "source": "b6d7019b1817040a55577652ed26649af8ca0183", "translation": "1966ea773542e948e433623c2d98e6a1b02d5a11" }, - "settings.taskProperties.taskStatuses.fields.completed": { - "source": "208409ffed559a03204711e8976b06e3710deca5", - "translation": "bb155f6460bf0b86f81e40d4cb310624c008ea76" - }, "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", @@ -56167,10 +56087,6 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.badges.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.planned": null, - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, - "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "0c211b5422e7e0d78ec042fbd5154781a79bcd90" @@ -64945,10 +64861,6 @@ "source": "990a304e39a65c9cab8405d690b593f3b01008f0", "translation": "23ed20934d4de5998eef43686591068ca4589039" }, - "settings.taskProperties.taskStatuses.howTheyWork.completed": { - "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", - "translation": "2b7120e8e1d1fdc14d8013fb0f784020d4179f3e" - }, "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", @@ -64998,10 +64910,6 @@ "source": "b6d7019b1817040a55577652ed26649af8ca0183", "translation": "1071d6484b29d87d739bd13f00729bfcb6c302ad" }, - "settings.taskProperties.taskStatuses.fields.completed": { - "source": "208409ffed559a03204711e8976b06e3710deca5", - "translation": "4fc47c7b6e532be9bca0e26fb998757773d99999" - }, "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", @@ -65042,10 +64950,6 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.badges.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized": null, - "settings.taskProperties.taskStatuses.categoryOptions.planned": null, - "settings.taskProperties.taskStatuses.categoryOptions.inProgress": null, - "settings.taskProperties.taskStatuses.categoryOptions.completed": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "20035d63eaafed20bd3b4001a0dca01e09b82186" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index a64be33f7..38147d932 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1212,8 +1212,6 @@ export const en: TranslationTree = { label: 'Label: The display name shown in the interface (e.g., "In Progress")', color: "Color: Visual indicator color for the status dot and badges", icon: 'Icon: Optional Lucide icon name to display instead of colored dot (e.g., "check", "circle", "clock"). Browse icons at lucide.dev', - completed: - "Completed: When checked, tasks with this status are considered finished and may be filtered differently", category: "Category: Sets the lifecycle stage so dependencies can tell when a task has started or finished. A completed category counts as done, the same as the old completed toggle.", autoArchive: @@ -1235,7 +1233,6 @@ export const en: TranslationTree = { label: "Label:", color: "Color:", icon: "Icon:", - completed: "Completed:", category: "Category:", excludeFromCycle: "Skip when cycling:", nextStatus: "Next status:", @@ -1254,12 +1251,6 @@ export const en: TranslationTree = { inProgress: "In progress", uncategorized: "Uncategorized", }, - categoryOptions: { - uncategorized: "Uncategorized", - planned: "Planned", - inProgress: "In progress", - completed: "Completed", - }, deleteConfirm: 'Are you sure you want to delete the status "{label}"?', }, taskPriorities: { diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 78a99dddd..815a7966b 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -77,19 +77,19 @@ export const DEFAULT_STATUSES: StatusConfig[] = [ ]; /** - * Backfills lifecycle `category` on load: an already-`isCompleted` status becomes - * `completed` (and stays in sync); every other status is left uncategorized — no - * guess of planned vs in-progress. Idempotent. + * Reconciles the `isCompleted` <-> `category === "completed"` invariant on load: a status + * completed by either signal becomes category `completed`; others keep their category or + * stay uncategorized (no planned/in-progress guess). Idempotent. */ export function normalizeStatusCategories(statuses: StatusConfig[]): StatusConfig[] { return statuses.map((status) => { - if (status.category === "completed") { - return status.isCompleted ? status : { ...status, isCompleted: true }; + if (status.isCompleted || status.category === "completed") { + if (status.isCompleted && status.category === "completed") { + return status; + } + return { ...status, isCompleted: true, category: "completed" }; } - if (status.category !== undefined) { - return status; - } - return status.isCompleted ? { ...status, category: "completed" } : status; + return status; }); } diff --git a/src/settings/tabs/taskProperties/statusPropertyCard.ts b/src/settings/tabs/taskProperties/statusPropertyCard.ts index 2f562cd5d..7f8d88d78 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -276,13 +276,13 @@ function renderStatusList( { value: "", label: translate( - "settings.taskProperties.taskStatuses.categoryOptions.uncategorized" + "settings.taskProperties.taskStatuses.badges.uncategorized" ), }, ...STATUS_CATEGORIES.map((category) => ({ value: category, label: translate( - `settings.taskProperties.taskStatuses.categoryOptions.${categoryI18nKey(category)}` + `settings.taskProperties.taskStatuses.badges.${categoryI18nKey(category)}` ), })), ], diff --git a/tests/unit/services/statusManager.lifecycle.test.ts b/tests/unit/services/statusManager.lifecycle.test.ts index 0c9a3522c..e304fe9c4 100644 --- a/tests/unit/services/statusManager.lifecycle.test.ts +++ b/tests/unit/services/statusManager.lifecycle.test.ts @@ -17,7 +17,7 @@ const createStatus = ( ...overrides, }); -describe("StatusManager status lifecycle (U1)", () => { +describe("StatusManager status lifecycle", () => { describe("isCompletedStatus characterization (must stay unchanged)", () => { it("keeps the default statuses' completion behavior", () => { const manager = new StatusManager(DEFAULT_STATUSES); @@ -57,6 +57,14 @@ describe("StatusManager status lifecycle (U1)", () => { expect(finished.isCompleted).toBe(true); }); + it("reconciles a completed status carrying a non-completed category to completed", () => { + const [status] = normalizeStatusCategories([ + createStatus("legacy", { isCompleted: true, category: "planned" }), + ]); + expect(status.category).toBe("completed"); + expect(status.isCompleted).toBe(true); + }); + it("is idempotent", () => { const once = normalizeStatusCategories(DEFAULT_STATUSES); const twice = normalizeStatusCategories(once); diff --git a/tests/unit/settings/settingsPersistence.test.ts b/tests/unit/settings/settingsPersistence.test.ts index 0f3aab2f0..d6fb48017 100644 --- a/tests/unit/settings/settingsPersistence.test.ts +++ b/tests/unit/settings/settingsPersistence.test.ts @@ -8,6 +8,7 @@ import { pluginDataFileExists, } from "../../../src/settings/settingsPersistence"; import type { TaskNotesSettings } from "../../../src/types/settings"; +import type { StatusConfig } from "../../../src/types"; function createHost(options: { dir?: string; @@ -192,3 +193,33 @@ describe("settings persistence helpers", () => { ); }); }); + +describe("status category migration on load", () => { + const legacyStatus = (overrides: Partial): StatusConfig => ({ + id: "s", + value: "s", + label: "S", + color: "#808080", + isCompleted: false, + order: 0, + autoArchive: false, + autoArchiveDelay: 5, + ...overrides, + }); + + it("backfills category on a legacy completed status through the load path", () => { + const { settings } = buildSettingsFromLoadedData({ + customStatuses: [legacyStatus({ value: "done", isCompleted: true })], + }); + expect(settings.customStatuses[0].category).toBe("completed"); + expect(settings.customStatuses[0].isCompleted).toBe(true); + }); + + it("leaves a legacy non-completed status uncategorized through the load path", () => { + const { settings } = buildSettingsFromLoadedData({ + customStatuses: [legacyStatus({ value: "open", isCompleted: false })], + }); + expect(settings.customStatuses[0].category).toBeUndefined(); + expect(settings.customStatuses[0].isCompleted).toBe(false); + }); +}); diff --git a/tests/unit/settings/statusPropertyCard.lifecycle.test.ts b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts index a3d4b7015..2e5811192 100644 --- a/tests/unit/settings/statusPropertyCard.lifecycle.test.ts +++ b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts @@ -11,7 +11,7 @@ import { StatusConfig } from "../../../src/types"; this.setAttribute(name, value); }; -// Deliberately named "todo" (not a category name) so findCategorySelect can key on the +// Deliberately named "todo" (not a category name) so categorySelect can key on the // "planned" option to disambiguate the category dropdown from the default/next-status selects. function createStatus(overrides: Partial = {}): StatusConfig { return { @@ -68,7 +68,7 @@ function badge(container: HTMLElement): HTMLElement | null { return container.querySelector(".tasknotes-settings__card-status-badge"); } -describe("Status config card — category dropdown + pill (U2)", () => { +describe("Status config card — category dropdown + pill", () => { it("selecting Completed sets category and keeps isCompleted true", () => { const { container, status } = renderCard(createStatus()); choose(categorySelect(container), "completed"); From 0717329e2ed2bf6e57753e0222afb09204b5a1e6 Mon Sep 17 00:00:00 2001 From: renatomen Date: Thu, 23 Jul 2026 20:50:05 +1200 Subject: [PATCH 04/24] refactor(status): three always-on categories (Not started / Started / Completed) Drop the fourth 'Uncategorized' state per the product decision. Every status now carries one of three lifecycle categories, guaranteed at runtime: the load-time normalizer defaults any non-completed status to Not started, and the two settings write sites (add-status button, createDefaultStatus) seed a category so nothing uncategorized is reachable. Relabel the enum's user-facing strings to Not started / Started / Completed; isCompleted stays the derived Completed mirror. --- docs/releases/unreleased.md | 2 +- i18n.manifest.json | 5 ++- i18n.state.json | 8 ----- src/i18n/resources/en.ts | 5 ++- src/services/StatusManager.ts | 3 +- src/settings/components/CardComponent.ts | 1 - src/settings/defaults.ts | 11 +++--- .../tabs/taskProperties/statusPropertyCard.ts | 34 +++++++------------ src/types.ts | 7 ++-- styles/settings-view.css | 6 ---- .../services/statusManager.lifecycle.test.ts | 26 +++++++++----- .../unit/settings/settingsPersistence.test.ts | 4 +-- .../statusPropertyCard.lifecycle.test.ts | 29 +++++++--------- 13 files changed, 63 insertions(+), 78 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 536ee7dd1..395800bbf 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,4 +34,4 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Changed -- Task statuses now have a lifecycle category — Planned, In progress, or Completed — shown as a pill in the status settings, replacing the previous "Completed" toggle. Existing statuses keep their completed state; other statuses stay uncategorized until you pick a category. +- Task statuses now have a lifecycle category — Not started, Started, or Completed — shown as a pill in the status settings, replacing the previous "Completed" toggle. Existing completed statuses stay Completed; the rest default to Not started. diff --git a/i18n.manifest.json b/i18n.manifest.json index 0270930ae..eeb468dfe 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -727,9 +727,8 @@ "settings.taskProperties.taskStatuses.placeholders.icon": "6c7a4a074ff62754681360bda7c3994c8029742b", "settings.taskProperties.taskStatuses.placeholders.nextStatusDefault": "0516a99c523425cc4a3eee70cab344e056e41db6", "settings.taskProperties.taskStatuses.badges.completed": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", - "settings.taskProperties.taskStatuses.badges.planned": "9cbe42aaff6033b7c9c52581f0c42a0598b76109", - "settings.taskProperties.taskStatuses.badges.inProgress": "b6bd42e4e7302e5e9a15930c8fcdb73fc2c0cf18", - "settings.taskProperties.taskStatuses.badges.uncategorized": "4ef7d73c1a83464f50c8d1b29b43e85facfdf3ee", + "settings.taskProperties.taskStatuses.badges.planned": "db2c45d5579367438b625f4cf40cad184e39bd54", + "settings.taskProperties.taskStatuses.badges.inProgress": "faa9e7e7ef5a264c58c68a4a0b9c8bd54241450a", "settings.taskProperties.taskStatuses.deleteConfirm": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "settings.taskProperties.taskPriorities.header": "44c22d1630a67a7d465d56ddea064d62259dc9dc", "settings.taskProperties.taskPriorities.description": "27e335b697e148e2ff9d76108bbb76d7081f0836", diff --git a/i18n.state.json b/i18n.state.json index f38c49631..cfdf7fa01 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2908,7 +2908,6 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, - "settings.taskProperties.taskStatuses.badges.uncategorized": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "bd5e5868fd22e54aa9b5018245a7dac8304bd1f6" @@ -11771,7 +11770,6 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, - "settings.taskProperties.taskStatuses.badges.uncategorized": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "6181bea9a3b42002b16368c96c4b7ec405424a9d" @@ -20634,7 +20632,6 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, - "settings.taskProperties.taskStatuses.badges.uncategorized": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "b9270228ed590ded12e0538f71a4adaa6702b218" @@ -29497,7 +29494,6 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, - "settings.taskProperties.taskStatuses.badges.uncategorized": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "d7a8c7b2efbbd6760f19259c206a211ffc3b0674" @@ -38360,7 +38356,6 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, - "settings.taskProperties.taskStatuses.badges.uncategorized": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "f6882f9122f9b4e5f620c536e1d062ebc3225517" @@ -47223,7 +47218,6 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, - "settings.taskProperties.taskStatuses.badges.uncategorized": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "5a9415cfc781eb37f56b19b6451cd9da046630de" @@ -56086,7 +56080,6 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, - "settings.taskProperties.taskStatuses.badges.uncategorized": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "0c211b5422e7e0d78ec042fbd5154781a79bcd90" @@ -64949,7 +64942,6 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, - "settings.taskProperties.taskStatuses.badges.uncategorized": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "20035d63eaafed20bd3b4001a0dca01e09b82186" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 38147d932..454f3fd0d 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1247,9 +1247,8 @@ export const en: TranslationTree = { }, badges: { completed: "Completed", - planned: "Planned", - inProgress: "In progress", - uncategorized: "Uncategorized", + planned: "Not started", + inProgress: "Started", }, deleteConfirm: 'Are you sure you want to delete the status "{label}"?', }, diff --git a/src/services/StatusManager.ts b/src/services/StatusManager.ts index c26d01412..3183efd22 100644 --- a/src/services/StatusManager.ts +++ b/src/services/StatusManager.ts @@ -143,7 +143,7 @@ export class StatusManager { /** * Whether the status counts as started — the constraint anchor for STARTTOSTART / - * STARTTOFINISH edges. Uncategorized and planned are not started. + * STARTTOFINISH edges. A Not-started (planned) status is not started. */ isStarted(statusValue: string): boolean { const category = this.getCategory(statusValue); @@ -283,6 +283,7 @@ export class StatusManager { label: "New status", color: "#808080", isCompleted: false, + category: "planned", excludeFromCycle: false, order, autoArchive: false, diff --git a/src/settings/components/CardComponent.ts b/src/settings/components/CardComponent.ts index 0f4038b2d..301718353 100644 --- a/src/settings/components/CardComponent.ts +++ b/src/settings/components/CardComponent.ts @@ -522,7 +522,6 @@ export function createStatusBadge( | "completed" | "planned" | "in-progress" - | "uncategorized" | "default" = "default" ): HTMLElement { const badge = activeDocument.createElement("span"); diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 815a7966b..bf4226d09 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -77,9 +77,9 @@ export const DEFAULT_STATUSES: StatusConfig[] = [ ]; /** - * Reconciles the `isCompleted` <-> `category === "completed"` invariant on load: a status - * completed by either signal becomes category `completed`; others keep their category or - * stay uncategorized (no planned/in-progress guess). Idempotent. + * Reconciles the `isCompleted` <-> `category === "completed"` invariant on load and fills the + * always-on category: completed by either signal becomes `completed`; every other status keeps + * a valid `planned`/`in-progress` category or defaults to `planned` (Not started). Idempotent. */ export function normalizeStatusCategories(statuses: StatusConfig[]): StatusConfig[] { return statuses.map((status) => { @@ -89,7 +89,10 @@ export function normalizeStatusCategories(statuses: StatusConfig[]): StatusConfi } return { ...status, isCompleted: true, category: "completed" }; } - return status; + if (status.category === "planned" || status.category === "in-progress") { + return status; + } + return { ...status, category: "planned" }; }); } diff --git a/src/settings/tabs/taskProperties/statusPropertyCard.ts b/src/settings/tabs/taskProperties/statusPropertyCard.ts index 7f8d88d78..b63f30ff2 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -20,16 +20,16 @@ import { createIconInput } from "../../components/IconSuggest"; import { createNLPTriggerRows, createPropertyDescription, TranslateFn } from "./helpers"; import { StatusCategory, STATUS_CATEGORIES } from "../../../types"; -// The badge i18n keys are camelCase, but the stored category value for "In progress" is +// The badge i18n keys are camelCase, but the stored category value for "Started" is // the hyphenated "in-progress" (also the CSS variant); map the one that differs. -const categoryI18nKey = (category: StatusCategory | "uncategorized"): string => +const categoryI18nKey = (category: StatusCategory): string => category === "in-progress" ? "inProgress" : category; function createCategoryBadge( category: StatusCategory | undefined, translate: TranslateFn ): HTMLElement { - const variant = category ?? "uncategorized"; + const variant = category ?? "planned"; return createStatusBadge( translate(`settings.taskProperties.taskStatuses.badges.${categoryI18nKey(variant)}`), variant @@ -170,8 +170,8 @@ export function renderStatusPropertyCard( value: "", label: "", color: "#6366f1", - completed: false, isCompleted: false, + category: "planned" as StatusCategory, excludeFromCycle: false, order: plugin.settings.customStatuses.length, autoArchive: false, @@ -272,26 +272,18 @@ function renderStatusList( ); const categorySelect = createCardSelect( - [ - { - value: "", - label: translate( - "settings.taskProperties.taskStatuses.badges.uncategorized" - ), - }, - ...STATUS_CATEGORIES.map((category) => ({ - value: category, - label: translate( - `settings.taskProperties.taskStatuses.badges.${categoryI18nKey(category)}` - ), - })), - ], - status.category ?? "" + STATUS_CATEGORIES.map((category) => ({ + value: category, + label: translate( + `settings.taskProperties.taskStatuses.badges.${categoryI18nKey(category)}` + ), + })), + status.category ?? "planned" ); categorySelect.addEventListener("change", () => { - const value = categorySelect.value; - status.category = value ? (value as StatusCategory) : undefined; + const value = categorySelect.value as StatusCategory; + status.category = value; status.isCompleted = value === "completed"; const metaContainer = statusCard?.querySelector(".tasknotes-settings__card-meta"); if (metaContainer) { diff --git a/src/types.ts b/src/types.ts index a13037111..8fbf8257d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -729,8 +729,9 @@ export interface FieldMapping { } /** - * Lifecycle stage of a status. An absent category means uncategorized — it computes as - * not-started/not-finished (like planned) but records that the user has not set it yet. + * Lifecycle stage of a status (labels: Not started / Started / Completed). Always set at + * runtime — the settings-load normalizer and the status-write sites default a missing one to + * `planned`; optional in the type only to spare the many existing status literals a change. */ export type StatusCategory = "planned" | "in-progress" | "completed"; @@ -743,7 +744,7 @@ export interface StatusConfig { color: string; // Hex color for UI elements icon?: string; // Optional Lucide icon name (e.g., "circle", "check", "clock") isCompleted: boolean; // Whether this counts as "done"; kept in sync with category === "completed" - category?: StatusCategory; // Lifecycle stage; absent = uncategorized (computes as not-started) + category?: StatusCategory; // Lifecycle stage; always set at runtime (defaults to "planned") isSkipped?: boolean; // Whether this counts as a skipped occurrence excludeFromCycle?: boolean; // Whether status-dot cycling should skip this status nextStatus?: string; // Optional status value to use when cycling forward from this status diff --git a/styles/settings-view.css b/styles/settings-view.css index 42d6aadaa..787bfa180 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -497,12 +497,6 @@ body.is-mobile .tasknotes-plugin .tasknotes-settings__card-action-btn { color: var(--text-normal); } -.tasknotes-settings__card-status-badge--uncategorized { - background: transparent; - color: var(--text-muted); - border: 1px dashed var(--background-modifier-border); -} - .tasknotes-settings__card-info-badge { padding: 3px 6px; border-radius: 10px; diff --git a/tests/unit/services/statusManager.lifecycle.test.ts b/tests/unit/services/statusManager.lifecycle.test.ts index e304fe9c4..59d5d4f80 100644 --- a/tests/unit/services/statusManager.lifecycle.test.ts +++ b/tests/unit/services/statusManager.lifecycle.test.ts @@ -35,13 +35,13 @@ describe("StatusManager status lifecycle", () => { expect(done.isCompleted).toBe(true); }); - it("leaves a non-completed status uncategorized (category absent), isCompleted unchanged", () => { + it("defaults a non-completed status with no category to planned (Not started), isCompleted unchanged", () => { const [open] = normalizeStatusCategories([createStatus("open", { isCompleted: false })]); - expect(open.category).toBeUndefined(); + expect(open.category).toBe("planned"); expect(open.isCompleted).toBe(false); }); - it("leaves a status that already carries a category as-is", () => { + it("leaves a status that already carries a valid category as-is", () => { const [inProgress] = normalizeStatusCategories([ createStatus("doing", { isCompleted: false, category: "in-progress" }), ]); @@ -76,32 +76,32 @@ describe("StatusManager status lifecycle", () => { const buildManager = () => new StatusManager([ createStatus("planned", { category: "planned", isCompleted: false }), - createStatus("uncategorized", { isCompleted: false }), createStatus("doing", { category: "in-progress", isCompleted: false }), createStatus("done", { category: "completed", isCompleted: true }), + createStatus("nocat", { isCompleted: false }), ]); - it("isStarted is true for in-progress and completed, false for planned and uncategorized", () => { + it("isStarted is true for in-progress and completed, false for planned and a categoryless status", () => { const manager = buildManager(); expect(manager.isStarted("doing")).toBe(true); expect(manager.isStarted("done")).toBe(true); expect(manager.isStarted("planned")).toBe(false); - expect(manager.isStarted("uncategorized")).toBe(false); + expect(manager.isStarted("nocat")).toBe(false); }); it("isFinished is true only for completed and equals isCompleted for every status", () => { const manager = buildManager(); - for (const value of ["planned", "uncategorized", "doing", "done"]) { + for (const value of ["planned", "nocat", "doing", "done"]) { expect(manager.isFinished(value)).toBe(manager.isCompletedStatus(value)); } expect(manager.isFinished("done")).toBe(true); expect(manager.isFinished("doing")).toBe(false); }); - it("getCategory returns the configured category or undefined when uncategorized", () => { + it("getCategory returns the configured category", () => { const manager = buildManager(); expect(manager.getCategory("doing")).toBe("in-progress"); - expect(manager.getCategory("uncategorized")).toBeUndefined(); + expect(manager.getCategory("planned")).toBe("planned"); }); it("treats an unknown status as neither started nor finished", () => { @@ -110,4 +110,12 @@ describe("StatusManager status lifecycle", () => { expect(manager.isFinished("missing")).toBe(false); }); }); + + describe("createDefaultStatus", () => { + it("produces a status with a default category (Not started) and isCompleted false", () => { + const status = StatusManager.createDefaultStatus([]); + expect(status.category).toBe("planned"); + expect(status.isCompleted).toBe(false); + }); + }); }); diff --git a/tests/unit/settings/settingsPersistence.test.ts b/tests/unit/settings/settingsPersistence.test.ts index d6fb48017..38d8ed65b 100644 --- a/tests/unit/settings/settingsPersistence.test.ts +++ b/tests/unit/settings/settingsPersistence.test.ts @@ -215,11 +215,11 @@ describe("status category migration on load", () => { expect(settings.customStatuses[0].isCompleted).toBe(true); }); - it("leaves a legacy non-completed status uncategorized through the load path", () => { + it("defaults a legacy non-completed status to Not started through the load path", () => { const { settings } = buildSettingsFromLoadedData({ customStatuses: [legacyStatus({ value: "open", isCompleted: false })], }); - expect(settings.customStatuses[0].category).toBeUndefined(); + expect(settings.customStatuses[0].category).toBe("planned"); expect(settings.customStatuses[0].isCompleted).toBe(false); }); }); diff --git a/tests/unit/settings/statusPropertyCard.lifecycle.test.ts b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts index 2e5811192..8d3bba770 100644 --- a/tests/unit/settings/statusPropertyCard.lifecycle.test.ts +++ b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts @@ -76,7 +76,7 @@ describe("Status config card — category dropdown + pill", () => { expect(status.isCompleted).toBe(true); }); - it("selecting Planned or In progress sets isCompleted false", () => { + it("selecting Not started or Started sets isCompleted false", () => { const { container, status } = renderCard( createStatus({ isCompleted: true, category: "completed" }) ); @@ -89,13 +89,10 @@ describe("Status config card — category dropdown + pill", () => { expect(status.isCompleted).toBe(false); }); - it("selecting Uncategorized clears both category and isCompleted", () => { - const { container, status } = renderCard( - createStatus({ isCompleted: true, category: "completed" }) - ); - choose(categorySelect(container), ""); - expect(status.category).toBeUndefined(); - expect(status.isCompleted).toBe(false); + it("offers exactly the three categories — no uncategorized option", () => { + const { container } = renderCard(createStatus()); + const values = Array.from(categorySelect(container).options).map((o) => o.value); + expect(values).toEqual(["planned", "in-progress", "completed"]); }); it("updates the pill text and variant class to match the selected category", () => { @@ -103,28 +100,28 @@ describe("Status config card — category dropdown + pill", () => { const select = categorySelect(container); choose(select, "in-progress"); - expect(badge(container)?.textContent).toBe("In progress"); + expect(badge(container)?.textContent).toBe("Started"); expect( badge(container)?.classList.contains( "tasknotes-settings__card-status-badge--in-progress" ) ).toBe(true); - choose(select, ""); - expect(badge(container)?.textContent).toBe("Uncategorized"); + choose(select, "planned"); + expect(badge(container)?.textContent).toBe("Not started"); expect( badge(container)?.classList.contains( - "tasknotes-settings__card-status-badge--uncategorized" + "tasknotes-settings__card-status-badge--planned" ) ).toBe(true); }); - it("shows a neutral Uncategorized pill for a migrated (categoryless) status", () => { - const { container } = renderCard(createStatus()); - expect(badge(container)?.textContent).toBe("Uncategorized"); + it("shows a Not started pill for a status categorized planned", () => { + const { container } = renderCard(createStatus({ category: "planned" })); + expect(badge(container)?.textContent).toBe("Not started"); expect( badge(container)?.classList.contains( - "tasknotes-settings__card-status-badge--uncategorized" + "tasknotes-settings__card-status-badge--planned" ) ).toBe(true); }); From a2e467008e3cde3ebe52713a01ff54f726a6ca43 Mon Sep 17 00:00:00 2001 From: renatomen Date: Thu, 23 Jul 2026 22:05:22 +1200 Subject: [PATCH 05/24] feat(deps): per-endpoint start/finish blocked computation in DependencyCache Replace the reltype-blind completion-only gating with RFC 9253 per-endpoint constraints: FS/SS gate a task's start, FF/SF gate its finish, and each edge releases when its predecessor reaches its own endpoint (completed for FINISH*, started for START*). The cache stores each edge's reltype and both the finished and started lifecycle per path, computing startBlocked/finishBlocked on demand; the relationship fingerprint keys on reltype so a reltype-only edit re-indexes, and the file signature keys on the per-endpoint blocked membership so a predecessor's lifecycle move re-fires the change event. Exposes isTaskStartBlocked/isTaskFinishBlocked plus reverse per-endpoint accessors. --- src/utils/DependencyCache.ts | 242 ++++++++++-------- src/utils/dependencyUtils.ts | 15 ++ tests/helpers/mock-factories.ts | 1 + .../dependencyCache.performance.test.ts | 5 +- ...1227-relationships-project-refresh.test.ts | 5 +- .../issue-1499-excluded-folders.test.ts | 5 +- ...878-completed-blocker-not-blocking.test.ts | 5 +- .../utils/DependencyCache.constraints.test.ts | 155 +++++++++++ .../DependencyCache.optimization.test.ts | 5 +- 9 files changed, 330 insertions(+), 108 deletions(-) create mode 100644 tests/unit/utils/DependencyCache.constraints.test.ts diff --git a/src/utils/DependencyCache.ts b/src/utils/DependencyCache.ts index c2958561d..0569f1b8e 100644 --- a/src/utils/DependencyCache.ts +++ b/src/utils/DependencyCache.ts @@ -1,7 +1,13 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion -- Dependency graph traversal guards resolved task nodes before dereferencing. */ import { TFile, App, Events, EventRef } from "obsidian"; import { FieldMapper } from "../core/FieldMapper"; -import { normalizeDependencyList, resolveDependencyEntry } from "./dependencyUtils"; +import { + normalizeDependencyList, + resolveDependencyEntry, + reltypeConstrainsStart, + reltypeReleasedByPredecessorFinish, +} from "./dependencyUtils"; +import type { TaskDependencyRelType } from "../types"; import { TaskNotesSettings } from "../types/settings"; import { isPathInExcludedFolder, parseExcludedFolders } from "./pathExclusions"; import { createTaskNotesLogger } from "./tasknotesLogger"; @@ -12,6 +18,7 @@ export const EVENT_DEPENDENCY_CACHE_CHANGED = "dependency-cache-changed"; interface DependencyStatusClassifier { isCompletedStatus(statusValue: string): boolean; + isStarted(statusValue: string): boolean; } /** @@ -33,14 +40,14 @@ export class DependencyCache extends Events { // Dependency indexes private dependencySources: Map> = new Map(); // task path -> blocking task paths private dependencyTargets: Map> = new Map(); // task path -> tasks blocked by this task - private activeDependencySources: Map> = new Map(); // task path -> incomplete blocking task paths - private activeDependencyTargets: Map> = new Map(); // task path -> tasks actively blocked by this task + private edgeReltypes: Map> = new Map(); // successor path -> (predecessor path -> reltype) // Project references index private projectReferences: Map> = new Map(); // project path -> Set private projectReferenceSources: Map> = new Map(); // task path -> Set private relationshipFingerprints: Map = new Map(); // task path -> normalized dependency/project fields - private completedStatusByPath: Map = new Map(); // file path -> completion state for status-aware dependency lookups + private completedStatusByPath: Map = new Map(); // file path -> "finished" (isCompleted) per path + private startedStatusByPath: Map = new Map(); // file path -> "started" (category in-progress/completed) per path // Initialization state private initialized = false; @@ -147,7 +154,7 @@ export class DependencyCache extends Events { } const frontmatter = this.getFrontmatterFromCache(cache) ?? this.getFrontmatterForFile(file); - this.updateCompletionState(file.path, frontmatter); + this.updateLifecycleState(file.path, frontmatter); if (!frontmatter) { if (this.hasForwardRelationships(file.path)) { @@ -187,12 +194,13 @@ export class DependencyCache extends Events { private getFileRelationshipSignature(path: string): string { const blockingTasks = this.sortedSetValues(this.dependencySources.get(path)); - const blockedTasks = this.sortedSetValues(this.activeDependencyTargets.get(path)); + const { start, finish } = this.computeBlockedDependents(path); const referencedProjects = this.sortedSetValues(this.projectReferenceSources.get(path)); const projectTasks = this.sortedSetValues(this.projectReferences.get(path)); return JSON.stringify({ - blockedTasks, + blockedStart: this.sortedSetValues(start), + blockedFinish: this.sortedSetValues(finish), blockingTasks, projectTasks, referencedProjects, @@ -274,6 +282,7 @@ export class DependencyCache extends Events { this.relationshipFingerprints.set(path, this.buildRelationshipFingerprint(frontmatter)); this.completedStatusByPath.set(path, this.isCompletedFrontmatter(frontmatter)); + this.startedStatusByPath.set(path, this.isStartedFrontmatter(frontmatter)); const dependenciesField = this.fieldMapper?.toUserField("blockedBy") || "blockedBy"; const projectField = this.fieldMapper?.toUserField("projects") || "project"; @@ -288,7 +297,7 @@ export class DependencyCache extends Events { for (const dep of normalized) { const resolved = resolveDependencyEntry(this.app, path, dep); if (resolved?.path && this.isValidFile(resolved.path)) { - this.addDependencyLink(path, resolved.path, blockingTasks); + this.addDependencyLink(path, resolved.path, dep.reltype, blockingTasks); } } @@ -326,6 +335,7 @@ export class DependencyCache extends Events { private addDependencyLink( dependentPath: string, blockingPath: string, + reltype: TaskDependencyRelType, blockingTasks: Set ): void { blockingTasks.add(blockingPath); @@ -335,66 +345,66 @@ export class DependencyCache extends Events { } this.dependencyTargets.get(blockingPath)!.add(dependentPath); - if (!this.isCompletedPath(blockingPath)) { - this.addActiveDependencyLink(dependentPath, blockingPath); - } - } - - private addActiveDependencyLink(dependentPath: string, blockingPath: string): void { - if (!this.activeDependencySources.has(dependentPath)) { - this.activeDependencySources.set(dependentPath, new Set()); - } - this.activeDependencySources.get(dependentPath)!.add(blockingPath); - - if (!this.activeDependencyTargets.has(blockingPath)) { - this.activeDependencyTargets.set(blockingPath, new Set()); - } - this.activeDependencyTargets.get(blockingPath)!.add(dependentPath); - } - - private removeActiveDependencyLink(dependentPath: string, blockingPath: string): void { - const activeSources = this.activeDependencySources.get(dependentPath); - if (activeSources) { - activeSources.delete(blockingPath); - if (activeSources.size === 0) { - this.activeDependencySources.delete(dependentPath); - } - } - - const activeTargets = this.activeDependencyTargets.get(blockingPath); - if (activeTargets) { - activeTargets.delete(dependentPath); - if (activeTargets.size === 0) { - this.activeDependencyTargets.delete(blockingPath); + let edges = this.edgeReltypes.get(dependentPath); + if (!edges) { + edges = new Map(); + this.edgeReltypes.set(dependentPath, edges); + } + edges.set(blockingPath, reltype); + } + + // An edge releases when its predecessor reaches the edge's predecessor endpoint: + // FINISH* on the predecessor's completion, START* once the predecessor is started. + private isEdgeReleased(predecessorPath: string, reltype: TaskDependencyRelType): boolean { + return reltypeReleasedByPredecessorFinish(reltype) + ? (this.completedStatusByPath.get(predecessorPath) ?? false) + : (this.startedStatusByPath.get(predecessorPath) ?? false); + } + + private computeBlockedState(taskPath: string): { + startBlocked: boolean; + finishBlocked: boolean; + } { + let startBlocked = false; + let finishBlocked = false; + const edges = this.edgeReltypes.get(taskPath); + if (edges) { + for (const [predecessorPath, reltype] of edges) { + if (this.isEdgeReleased(predecessorPath, reltype)) { + continue; + } + if (reltypeConstrainsStart(reltype)) { + startBlocked = true; + } else { + finishBlocked = true; + } } } - } - - private rebuildActiveLinksForBlocker(blockingPath: string): void { - const blockedTasks = this.dependencyTargets.get(blockingPath); - this.activeDependencyTargets.delete(blockingPath); - - if (!blockedTasks) { - return; - } - - for (const dependentPath of blockedTasks) { - const activeSources = this.activeDependencySources.get(dependentPath); - if (activeSources) { - activeSources.delete(blockingPath); - if (activeSources.size === 0) { - this.activeDependencySources.delete(dependentPath); + return { startBlocked, finishBlocked }; + } + + // Reverse view: the successors this task currently blocks, split by the endpoint it gates. + private computeBlockedDependents(predecessorPath: string): { + start: Set; + finish: Set; + } { + const start = new Set(); + const finish = new Set(); + const successors = this.dependencyTargets.get(predecessorPath); + if (successors) { + for (const successorPath of successors) { + const reltype = this.edgeReltypes.get(successorPath)?.get(predecessorPath); + if (!reltype || this.isEdgeReleased(predecessorPath, reltype)) { + continue; + } + if (reltypeConstrainsStart(reltype)) { + start.add(successorPath); + } else { + finish.add(successorPath); } } } - - if (this.isCompletedPath(blockingPath)) { - return; - } - - for (const dependentPath of blockedTasks) { - this.addActiveDependencyLink(dependentPath, blockingPath); - } + return { start, finish }; } private buildRelationshipFingerprint(frontmatter: Record): string { @@ -402,8 +412,8 @@ export class DependencyCache extends Events { const projectField = this.fieldMapper?.toUserField("projects") || "project"; const dependencies = (normalizeDependencyList(frontmatter[dependenciesField]) ?? []) - .map((dependency) => dependency.uid) - .filter((uid) => uid.length > 0) + .filter((dependency) => dependency.uid.length > 0) + .map((dependency) => `${dependency.uid}|${dependency.reltype}|${dependency.gap ?? ""}`) .sort(); const projects = this.normalizeProjectFingerprintValues(frontmatter[projectField]); @@ -436,39 +446,30 @@ export class DependencyCache extends Events { ); } - private updateCompletionState(path: string, frontmatter: Record | null): void { - const oldCompleted = this.completedStatusByPath.get(path) ?? false; - const newCompleted = frontmatter ? this.isCompletedFrontmatter(frontmatter) : false; - this.completedStatusByPath.set(path, newCompleted); - - if (oldCompleted !== newCompleted) { - this.rebuildActiveLinksForBlocker(path); - } + private updateLifecycleState(path: string, frontmatter: Record | null): void { + this.completedStatusByPath.set( + path, + frontmatter ? this.isCompletedFrontmatter(frontmatter) : false + ); + this.startedStatusByPath.set( + path, + frontmatter ? this.isStartedFrontmatter(frontmatter) : false + ); } - private isCompletedPath(path: string): boolean { - const cached = this.completedStatusByPath.get(path); - if (cached !== undefined) { - return cached; - } - - const file = this.app.vault.getAbstractFileByPath(path); - if (!(file instanceof TFile)) { - this.completedStatusByPath.set(path, false); - return false; - } + private isCompletedFrontmatter(frontmatter: Record): boolean { + const statusText = this.readStatusText(frontmatter); + return Boolean(statusText && this.statusManager.isCompletedStatus(statusText)); + } - const frontmatter = this.getFrontmatterForFile(file); - const completed = frontmatter ? this.isCompletedFrontmatter(frontmatter) : false; - this.completedStatusByPath.set(path, completed); - return completed; + private isStartedFrontmatter(frontmatter: Record): boolean { + const statusText = this.readStatusText(frontmatter); + return Boolean(statusText && this.statusManager.isStarted(statusText)); } - private isCompletedFrontmatter(frontmatter: Record): boolean { + private readStatusText(frontmatter: Record): string | null { const statusField = this.fieldMapper?.toUserField("status") || "status"; - const status = frontmatter[statusField]; - const statusText = this.stringifyStatusValue(status); - return Boolean(statusText && this.statusManager.isCompletedStatus(statusText)); + return this.stringifyStatusValue(frontmatter[statusField]); } private stringifyStatusValue(status: unknown): string | null { @@ -501,11 +502,10 @@ export class DependencyCache extends Events { this.dependencyTargets.delete(blockingTask); } } - this.removeActiveDependencyLink(path, blockingTask); } this.dependencySources.delete(path); } - this.activeDependencySources.delete(path); + this.edgeReltypes.delete(path); // Also clear project references since those are stored in this task's frontmatter const referencedProjects = this.projectReferenceSources.get(path); @@ -541,11 +541,10 @@ export class DependencyCache extends Events { this.dependencyTargets.delete(blockingTask); } } - this.removeActiveDependencyLink(path, blockingTask); } this.dependencySources.delete(path); } - this.activeDependencySources.delete(path); + this.edgeReltypes.delete(path); // Clear from dependency targets const blockedTasks = this.dependencyTargets.get(path); @@ -559,11 +558,10 @@ export class DependencyCache extends Events { this.dependencySources.delete(blockedTask); } } - this.removeActiveDependencyLink(blockedTask, path); + this.edgeReltypes.get(blockedTask)?.delete(path); } this.dependencyTargets.delete(path); } - this.activeDependencyTargets.delete(path); // Clear project references declared by this file const referencedProjects = this.projectReferenceSources.get(path); @@ -596,6 +594,7 @@ export class DependencyCache extends Events { } this.relationshipFingerprints.delete(path); this.completedStatusByPath.delete(path); + this.startedStatusByPath.delete(path); } /** @@ -634,8 +633,8 @@ export class DependencyCache extends Events { this.buildIndexesSync(); } - const blocked = this.activeDependencyTargets.get(taskPath); - return blocked ? Array.from(blocked) : []; + const { start, finish } = this.computeBlockedDependents(taskPath); + return Array.from(new Set([...start, ...finish])); } /** @@ -646,7 +645,44 @@ export class DependencyCache extends Events { if (!this.indexesBuilt) { this.buildIndexesSync(); } - return (this.activeDependencySources.get(taskPath)?.size ?? 0) > 0; + const { startBlocked, finishBlocked } = this.computeBlockedState(taskPath); + return startBlocked || finishBlocked; + } + + /** + * Cannot start yet: an FS predecessor unfinished, or an SS predecessor unstarted. + */ + isTaskStartBlocked(taskPath: string): boolean { + if (!this.indexesBuilt) { + this.buildIndexesSync(); + } + return this.computeBlockedState(taskPath).startBlocked; + } + + /** + * Cannot finish yet: an FF predecessor unfinished, or an SF predecessor unstarted. + */ + isTaskFinishBlocked(taskPath: string): boolean { + if (!this.indexesBuilt) { + this.buildIndexesSync(); + } + return this.computeBlockedState(taskPath).finishBlocked; + } + + // Reverse: the successors this task currently blocks from starting. + getStartBlockedDependentPaths(taskPath: string): string[] { + if (!this.indexesBuilt) { + this.buildIndexesSync(); + } + return Array.from(this.computeBlockedDependents(taskPath).start); + } + + // Reverse: the successors this task currently blocks from finishing. + getFinishBlockedDependentPaths(taskPath: string): string[] { + if (!this.indexesBuilt) { + this.buildIndexesSync(); + } + return Array.from(this.computeBlockedDependents(taskPath).finish); } /** @@ -725,12 +761,12 @@ export class DependencyCache extends Events { private clearIndexes(): void { this.dependencySources.clear(); this.dependencyTargets.clear(); - this.activeDependencySources.clear(); - this.activeDependencyTargets.clear(); + this.edgeReltypes.clear(); this.projectReferences.clear(); this.projectReferenceSources.clear(); this.relationshipFingerprints.clear(); this.completedStatusByPath.clear(); + this.startedStatusByPath.clear(); } /** diff --git a/src/utils/dependencyUtils.ts b/src/utils/dependencyUtils.ts index 84bb7d438..23f0a7d5c 100644 --- a/src/utils/dependencyUtils.ts +++ b/src/utils/dependencyUtils.ts @@ -16,6 +16,21 @@ export function isValidDependencyRelType(value: string): value is TaskDependency return VALID_RELATIONSHIP_TYPES.includes(value as TaskDependencyRelType); } +// RFC 9253 per-endpoint reading: FS/SS constrain the successor's start, FF/SF its finish. +export function reltypeConstrainsStart(reltype: TaskDependencyRelType): boolean { + return reltype === "FINISHTOSTART" || reltype === "STARTTOSTART"; +} + +export function reltypeConstrainsFinish(reltype: TaskDependencyRelType): boolean { + return reltype === "FINISHTOFINISH" || reltype === "STARTTOFINISH"; +} + +// The edge releases when the predecessor reaches its own endpoint: FINISH* on completion, +// START* once started. +export function reltypeReleasedByPredecessorFinish(reltype: TaskDependencyRelType): boolean { + return reltype === "FINISHTOSTART" || reltype === "FINISHTOFINISH"; +} + export function extractDependencyUid(entry: unknown): string { if (typeof entry === "string") { return entry; diff --git a/tests/helpers/mock-factories.ts b/tests/helpers/mock-factories.ts index dbfa18d9b..24888bf72 100644 --- a/tests/helpers/mock-factories.ts +++ b/tests/helpers/mock-factories.ts @@ -526,6 +526,7 @@ export const PluginFactory = { }, statusManager: { isCompletedStatus: jest.fn((status) => status === 'done' || status === 'completed'), + isStarted: jest.fn((status) => status === 'done' || status === 'completed' || status === 'in-progress'), getCompletedStatuses: jest.fn(() => ['done', 'completed']), getNextStatus: jest.fn((currentStatus) => { const statusMap: Record = { diff --git a/tests/performance/dependencyCache.performance.test.ts b/tests/performance/dependencyCache.performance.test.ts index 1ef03f6ab..4033ba1b1 100644 --- a/tests/performance/dependencyCache.performance.test.ts +++ b/tests/performance/dependencyCache.performance.test.ts @@ -70,7 +70,10 @@ function createDependencyCache(app: MockApp): DependencyCache { taskTag: "task", }, new FieldMapper(DEFAULT_FIELD_MAPPING), - { isCompletedStatus: jest.fn((status: string) => status === "done") } as never, + { + isCompletedStatus: jest.fn((status: string) => status === "done"), + isStarted: jest.fn((status: string) => status === "done" || status === "in-progress"), + } as never, (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) ); } diff --git a/tests/unit/issues/issue-1227-relationships-project-refresh.test.ts b/tests/unit/issues/issue-1227-relationships-project-refresh.test.ts index beb349935..f5142361b 100644 --- a/tests/unit/issues/issue-1227-relationships-project-refresh.test.ts +++ b/tests/unit/issues/issue-1227-relationships-project-refresh.test.ts @@ -60,7 +60,10 @@ function createDependencyCache(app: MockApp): DependencyCache { taskTag: "task", }, new FieldMapper(DEFAULT_FIELD_MAPPING), - { isCompletedStatus: jest.fn((status: string) => status === "done") } as never, + { + isCompletedStatus: jest.fn((status: string) => status === "done"), + isStarted: jest.fn((status: string) => status === "done" || status === "in-progress"), + } as never, (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) ); } diff --git a/tests/unit/issues/issue-1499-excluded-folders.test.ts b/tests/unit/issues/issue-1499-excluded-folders.test.ts index d72317360..9f1bddf14 100644 --- a/tests/unit/issues/issue-1499-excluded-folders.test.ts +++ b/tests/unit/issues/issue-1499-excluded-folders.test.ts @@ -62,7 +62,10 @@ function makeDependencyCache(app: MockApp, excludedFolders: string): DependencyC excludedFolders, }, new FieldMapper(DEFAULT_FIELD_MAPPING), - { isCompletedStatus: jest.fn((status: string) => status === "done") } as never, + { + isCompletedStatus: jest.fn((status: string) => status === "done"), + isStarted: jest.fn((status: string) => status === "done" || status === "in-progress"), + } as never, (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) ); } diff --git a/tests/unit/issues/issue-1878-completed-blocker-not-blocking.test.ts b/tests/unit/issues/issue-1878-completed-blocker-not-blocking.test.ts index f0afd17c1..3b3c46231 100644 --- a/tests/unit/issues/issue-1878-completed-blocker-not-blocking.test.ts +++ b/tests/unit/issues/issue-1878-completed-blocker-not-blocking.test.ts @@ -42,7 +42,10 @@ describe("Issue #1878: completed blockers should not appear as active blockers", app, {} as never, new FieldMapper(DEFAULT_FIELD_MAPPING), - { isCompletedStatus: jest.fn((status: string) => status === "done") } as never, + { + isCompletedStatus: jest.fn((status: string) => status === "done"), + isStarted: jest.fn((status: string) => status === "done" || status === "in-progress"), + } as never, (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) ); diff --git a/tests/unit/utils/DependencyCache.constraints.test.ts b/tests/unit/utils/DependencyCache.constraints.test.ts new file mode 100644 index 000000000..bbf0e21c7 --- /dev/null +++ b/tests/unit/utils/DependencyCache.constraints.test.ts @@ -0,0 +1,155 @@ +import { FieldMapper } from "../../../src/services/FieldMapper"; +import { DEFAULT_FIELD_MAPPING } from "../../../src/settings/defaults"; +import { DependencyCache } from "../../../src/utils/DependencyCache"; +import { MockObsidian } from "../../helpers/obsidian-runtime"; + +// Mock lifecycle mirroring StatusManager: "done" is finished (=> started), "in-progress" is +// started but not finished, "open" is neither — without pulling in the settings stack. +type Edge = { uid: string; reltype: string }; +type TaskSpec = { name: string; status: string; blockedBy?: Edge[] }; + +const p = (name: string) => `Tasks/${name}.md`; +const edge = (reltype: string) => (name: string): Edge => ({ uid: `[[${name}]]`, reltype }); +const fs = edge("FINISHTOSTART"); +const ss = edge("STARTTOSTART"); +const ff = edge("FINISHTOFINISH"); +const sf = edge("STARTTOFINISH"); + +async function buildCache(tasks: TaskSpec[]): Promise { + const app = MockObsidian.createMockApp(); + const fileByName = new Map(); + for (const task of tasks) { + const path = p(task.name); + const frontmatter: Record = { + title: task.name, + status: task.status, + tags: ["task"], + }; + if (task.blockedBy) { + frontmatter.blockedBy = task.blockedBy; + } + MockObsidian.createTestFile(path, `---\ntitle: ${task.name}\n---\n`); + app.metadataCache.setCache(path, { frontmatter }); + fileByName.set(task.name, app.vault.getAbstractFileByPath(path)); + } + app.metadataCache.getFirstLinkpathDest = jest.fn( + (linkpath: string) => fileByName.get(linkpath) ?? null + ); + + const cache = new DependencyCache( + app, + {} as never, + new FieldMapper(DEFAULT_FIELD_MAPPING), + { + isCompletedStatus: jest.fn((status: string) => status === "done"), + isStarted: jest.fn((status: string) => status === "done" || status === "in-progress"), + } as never, + (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) + ); + await cache.buildIndexes(); + return cache; +} + +describe("DependencyCache per-endpoint constraints (U3)", () => { + beforeEach(() => MockObsidian.reset()); + + it("FINISHTOSTART: unfinished predecessor start-blocks; completion releases", async () => { + const blocked = await buildCache([ + { name: "pred", status: "open" }, + { name: "dep", status: "open", blockedBy: [fs("pred")] }, + ]); + expect(blocked.isTaskStartBlocked(p("dep"))).toBe(true); + expect(blocked.isTaskFinishBlocked(p("dep"))).toBe(false); + expect(blocked.isTaskBlocked(p("dep"))).toBe(true); + + const released = await buildCache([ + { name: "pred", status: "done" }, + { name: "dep", status: "open", blockedBy: [fs("pred")] }, + ]); + expect(released.isTaskStartBlocked(p("dep"))).toBe(false); + expect(released.isTaskBlocked(p("dep"))).toBe(false); + }); + + it("STARTTOSTART: unstarted predecessor start-blocks; started or completed releases", async () => { + const blocked = await buildCache([ + { name: "pred", status: "open" }, + { name: "dep", status: "open", blockedBy: [ss("pred")] }, + ]); + expect(blocked.isTaskStartBlocked(p("dep"))).toBe(true); + + const started = await buildCache([ + { name: "pred", status: "in-progress" }, + { name: "dep", status: "open", blockedBy: [ss("pred")] }, + ]); + expect(started.isTaskStartBlocked(p("dep"))).toBe(false); + + const completed = await buildCache([ + { name: "pred", status: "done" }, + { name: "dep", status: "open", blockedBy: [ss("pred")] }, + ]); + expect(completed.isTaskStartBlocked(p("dep"))).toBe(false); + }); + + it("FINISHTOFINISH: unfinished predecessor finish-blocks, not start-blocks", async () => { + const blocked = await buildCache([ + { name: "pred", status: "in-progress" }, + { name: "dep", status: "open", blockedBy: [ff("pred")] }, + ]); + expect(blocked.isTaskStartBlocked(p("dep"))).toBe(false); + expect(blocked.isTaskFinishBlocked(p("dep"))).toBe(true); + expect(blocked.isTaskBlocked(p("dep"))).toBe(true); + + const released = await buildCache([ + { name: "pred", status: "done" }, + { name: "dep", status: "open", blockedBy: [ff("pred")] }, + ]); + expect(released.isTaskFinishBlocked(p("dep"))).toBe(false); + }); + + it("STARTTOFINISH: unstarted predecessor finish-blocks; started releases", async () => { + const blocked = await buildCache([ + { name: "pred", status: "open" }, + { name: "dep", status: "open", blockedBy: [sf("pred")] }, + ]); + expect(blocked.isTaskFinishBlocked(p("dep"))).toBe(true); + expect(blocked.isTaskStartBlocked(p("dep"))).toBe(false); + + const released = await buildCache([ + { name: "pred", status: "in-progress" }, + { name: "dep", status: "open", blockedBy: [sf("pred")] }, + ]); + expect(released.isTaskFinishBlocked(p("dep"))).toBe(false); + }); + + it("computes start and finish independently for mixed edges", async () => { + const cache = await buildCache([ + { name: "a", status: "open" }, + { name: "b", status: "in-progress" }, + { name: "dep", status: "open", blockedBy: [fs("a"), ff("b")] }, + ]); + expect(cache.isTaskStartBlocked(p("dep"))).toBe(true); + expect(cache.isTaskFinishBlocked(p("dep"))).toBe(true); + }); + + it("reverse accessors split what a predecessor blocks by endpoint", async () => { + const cache = await buildCache([ + { name: "pred", status: "open" }, + { name: "s", status: "open", blockedBy: [ss("pred")] }, + { name: "f", status: "open", blockedBy: [sf("pred")] }, + ]); + expect(cache.getStartBlockedDependentPaths(p("pred"))).toEqual([p("s")]); + expect(cache.getFinishBlockedDependentPaths(p("pred"))).toEqual([p("f")]); + }); + + it("FS-only vault: isTaskBlocked matches the today's-blocked set (regression parity)", async () => { + const cache = await buildCache([ + { name: "p1", status: "open" }, + { name: "p2", status: "done" }, + { name: "d1", status: "open", blockedBy: [fs("p1")] }, + { name: "d2", status: "open", blockedBy: [fs("p2")] }, + ]); + expect(cache.isTaskBlocked(p("d1"))).toBe(true); + expect(cache.isTaskStartBlocked(p("d1"))).toBe(true); + expect(cache.isTaskBlocked(p("d2"))).toBe(false); + }); +}); diff --git a/tests/unit/utils/DependencyCache.optimization.test.ts b/tests/unit/utils/DependencyCache.optimization.test.ts index d681c91a3..eeb635104 100644 --- a/tests/unit/utils/DependencyCache.optimization.test.ts +++ b/tests/unit/utils/DependencyCache.optimization.test.ts @@ -65,7 +65,10 @@ function createDependencyCache(app: MockApp): DependencyCache { taskTag: "task", }, new FieldMapper(DEFAULT_FIELD_MAPPING), - { isCompletedStatus: jest.fn((status: string) => status === "done") } as never, + { + isCompletedStatus: jest.fn((status: string) => status === "done"), + isStarted: jest.fn((status: string) => status === "done" || status === "in-progress"), + } as never, (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) ); } From 3bada0226e0b12f2b61edb3ec23b7714ba8273b7 Mon Sep 17 00:00:00 2001 From: renatomen Date: Thu, 23 Jul 2026 22:15:04 +1200 Subject: [PATCH 06/24] feat(deps): thread per-endpoint blocked state onto TaskInfo Add startBlocked/finishBlocked and the reverse isBlockingStart/isBlockingFinish to TaskInfo, sourced from the cache's per-endpoint accessors at both assembly sites (TaskManager, bases/helpers); isBlocked stays as their OR for existing consumers. The cache-absent fallback approximates start/finish-blocked from the task's own edge reltypes (existence-based). This is the contract U5 (surfaces) and U8 (Bases) consume. --- src/bases/helpers.ts | 34 +++++++-- src/types.ts | 6 +- src/utils/TaskManager.ts | 27 +++++-- src/utils/taskInfoAssembly.ts | 12 +++ .../taskInfoAssembly.constraints.test.ts | 73 +++++++++++++++++++ 5 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 tests/unit/utils/taskInfoAssembly.constraints.test.ts diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index 1e87bbee5..794729721 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -8,7 +8,11 @@ import { convertInternalToUserProperties } from "../utils/propertyMapping"; import { DEFAULT_INTERNAL_VISIBLE_PROPERTIES } from "../settings/defaults"; import type { TaskCardOptions } from "../ui/TaskCard"; import { PropertyMappingService } from "./PropertyMappingService"; -import { normalizeDependencyList } from "../utils/dependencyUtils"; +import { + normalizeDependencyList, + reltypeConstrainsStart, + reltypeConstrainsFinish, +} from "../utils/dependencyUtils"; import { stringifyUnknown, stringifyUnknownArray } from "../utils/stringUtils"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; @@ -217,18 +221,28 @@ function createTaskInfoFromProperties( const totalTrackedTime = timeEntries ? calculateTotalTimeSpent(timeEntries) : 0; // Get dependency information from DependencyCache if plugin is available - let isBlocked = false; + let startBlocked = false; + let finishBlocked = false; let blockingTasks: string[] = []; - let isBlocking = false; + let isBlockingStart = false; + let isBlockingFinish = false; if (plugin?.dependencyCache && basesItem.path) { - // Use DependencyCache for status-aware blocking check - isBlocked = plugin.dependencyCache.isTaskBlocked(basesItem.path); + // Status-aware, per-endpoint blocking state from the cache + startBlocked = plugin.dependencyCache.isTaskStartBlocked(basesItem.path); + finishBlocked = plugin.dependencyCache.isTaskFinishBlocked(basesItem.path); blockingTasks = plugin.dependencyCache.getBlockedTaskPaths(basesItem.path); - isBlocking = blockingTasks.length > 0; + isBlockingStart = + plugin.dependencyCache.getStartBlockedDependentPaths(basesItem.path).length > 0; + isBlockingFinish = + plugin.dependencyCache.getFinishBlockedDependentPaths(basesItem.path).length > 0; } else { - // Fallback when plugin not available: use simple existence check - isBlocked = Array.isArray(props.blockedBy) && props.blockedBy.length > 0; + // Cache-absent fallback: existence-based, from this task's own edge reltypes. + const edges = normalizeDependencyList(props.blockedBy) ?? []; + startBlocked = edges.some((edge) => reltypeConstrainsStart(edge.reltype)); + finishBlocked = edges.some((edge) => reltypeConstrainsFinish(edge.reltype)); } + const isBlocked = startBlocked || finishBlocked; + const isBlocking = blockingTasks.length > 0; return { title: @@ -269,6 +283,10 @@ function createTaskInfoFromProperties( blocking: blockingTasks.length > 0 ? blockingTasks : undefined, isBlocked: isBlocked, isBlocking: isBlocking, + startBlocked, + finishBlocked, + isBlockingStart, + isBlockingFinish, sortOrder: toOptionalString(props.sortOrder), customProperties: Object.keys(customProperties).length > 0 ? customProperties : undefined, basesData: basesItem.basesData, diff --git a/src/types.ts b/src/types.ts index 8fbf8257d..da269ccb8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -490,8 +490,12 @@ export interface TaskInfo { basesData?: unknown; // Raw Bases data for formula computation (internal use) blockedBy?: TaskDependency[]; // Dependencies that must be satisfied before this task can start blocking?: string[]; // Task paths that this task is blocking - isBlocked?: boolean; // True if any blocking dependency is incomplete + isBlocked?: boolean; // True if startBlocked || finishBlocked isBlocking?: boolean; // True if this task blocks at least one other task + startBlocked?: boolean; // Cannot start: an FS predecessor unfinished or an SS predecessor unstarted + finishBlocked?: boolean; // Cannot finish: an FF predecessor unfinished or an SF predecessor unstarted + isBlockingStart?: boolean; // Blocks at least one successor's start + isBlockingFinish?: boolean; // Blocks at least one successor's finish hasSubtasks?: boolean; // True if another task references this task as a project details?: string; // Optional task body content sortOrder?: string; // LexoRank string for ordering within column diff --git a/src/utils/TaskManager.ts b/src/utils/TaskManager.ts index 01c66ddcb..ab236729a 100644 --- a/src/utils/TaskManager.ts +++ b/src/utils/TaskManager.ts @@ -7,6 +7,7 @@ import { TaskNotesSettings } from "../types/settings"; import type { DependencyCache } from "./DependencyCache"; import { isPathInExcludedFolder, parseExcludedFolders } from "./pathExclusions"; import { buildTaskInfoFromMappedTask } from "./taskInfoAssembly"; +import { reltypeConstrainsStart, reltypeConstrainsFinish } from "./dependencyUtils"; import { isTaskFrontmatter } from "./taskIdentification"; import { createTaskNotesLogger } from "./tasknotesLogger"; @@ -332,23 +333,37 @@ export class TaskManager extends Events { ); // Get dependency information from DependencyCache - let isBlocked = false; + let startBlocked = false; + let finishBlocked = false; let blockingTasks: string[] = []; + let isBlockingStart = false; + let isBlockingFinish = false; if (this._dependencyCache) { - // Use DependencyCache for status-aware blocking check - isBlocked = this._dependencyCache.isTaskBlocked(path); + // Status-aware, per-endpoint blocking state from the cache + startBlocked = this._dependencyCache.isTaskStartBlocked(path); + finishBlocked = this._dependencyCache.isTaskFinishBlocked(path); blockingTasks = this._dependencyCache.getBlockedTaskPaths(path); + isBlockingStart = + this._dependencyCache.getStartBlockedDependentPaths(path).length > 0; + isBlockingFinish = + this._dependencyCache.getFinishBlockedDependentPaths(path).length > 0; } else { - // Fallback when dependency cache not available: use simple existence check - isBlocked = Array.isArray(mappedTask.blockedBy) && mappedTask.blockedBy.length > 0; + // Cache-absent fallback: existence-based, from this task's own edge reltypes. + const edges = Array.isArray(mappedTask.blockedBy) ? mappedTask.blockedBy : []; + startBlocked = edges.some((edge) => reltypeConstrainsStart(edge.reltype)); + finishBlocked = edges.some((edge) => reltypeConstrainsFinish(edge.reltype)); } return buildTaskInfoFromMappedTask({ path, mappedTask, defaultTaskStatus: this.settings.defaultTaskStatus, - isBlocked, + isBlocked: startBlocked || finishBlocked, blockingTasks, + startBlocked, + finishBlocked, + isBlockingStart, + isBlockingFinish, }); } catch (error) { tasknotesLogger.error(`Error extracting task info from native metadata for ${path}:`, { diff --git a/src/utils/taskInfoAssembly.ts b/src/utils/taskInfoAssembly.ts index 383ef283c..529f39f97 100644 --- a/src/utils/taskInfoAssembly.ts +++ b/src/utils/taskInfoAssembly.ts @@ -7,6 +7,10 @@ export interface BuildTaskInfoFromMappedTaskInput { defaultTaskStatus: string; isBlocked: boolean; blockingTasks: string[]; + startBlocked?: boolean; + finishBlocked?: boolean; + isBlockingStart?: boolean; + isBlockingFinish?: boolean; } export function buildTaskInfoFromMappedTask({ @@ -15,6 +19,10 @@ export function buildTaskInfoFromMappedTask({ defaultTaskStatus, isBlocked, blockingTasks, + startBlocked, + finishBlocked, + isBlockingStart, + isBlockingFinish, }: BuildTaskInfoFromMappedTaskInput): TaskInfo { const totalTrackedTime = mappedTask.timeEntries ? calculateTotalTimeSpent(mappedTask.timeEntries) @@ -33,7 +41,11 @@ export function buildTaskInfoFromMappedTask({ projects: Array.isArray(mappedTask.projects) ? mappedTask.projects : [], totalTrackedTime, isBlocked, + startBlocked: startBlocked ?? isBlocked, + finishBlocked: finishBlocked ?? false, isBlocking: blockingTasks.length > 0, + isBlockingStart: isBlockingStart ?? blockingTasks.length > 0, + isBlockingFinish: isBlockingFinish ?? false, blocking: blockingTasks.length > 0 ? blockingTasks : undefined, }; } diff --git a/tests/unit/utils/taskInfoAssembly.constraints.test.ts b/tests/unit/utils/taskInfoAssembly.constraints.test.ts new file mode 100644 index 000000000..ea3cf3b3f --- /dev/null +++ b/tests/unit/utils/taskInfoAssembly.constraints.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "@jest/globals"; +import type { TaskInfo } from "../../../src/types"; +import { buildTaskInfoFromMappedTask } from "../../../src/utils/taskInfoAssembly"; + +function makeTask(overrides: Partial = {}): TaskInfo { + return { + title: "Task", + status: "open", + priority: "normal", + path: "Tasks/t.md", + archived: false, + tags: ["task"], + contexts: [], + projects: [], + ...overrides, + }; +} + +describe("buildTaskInfoFromMappedTask per-endpoint fields (U4)", () => { + it("threads start/finish-blocked and reverse flags, with isBlocked as their OR", () => { + const task = buildTaskInfoFromMappedTask({ + path: "Tasks/dep.md", + mappedTask: makeTask(), + defaultTaskStatus: "open", + isBlocked: true, + blockingTasks: ["Tasks/succ.md"], + startBlocked: false, + finishBlocked: true, + isBlockingStart: false, + isBlockingFinish: true, + }); + expect(task).toMatchObject({ + startBlocked: false, + finishBlocked: true, + isBlocked: true, + isBlockingStart: false, + isBlockingFinish: true, + isBlocking: true, + }); + }); + + it("defaults per-endpoint fields for a legacy caller (backward compatible)", () => { + const task = buildTaskInfoFromMappedTask({ + path: "Tasks/legacy.md", + mappedTask: makeTask(), + defaultTaskStatus: "open", + isBlocked: true, + blockingTasks: ["Tasks/succ.md"], + }); + // No per-endpoint input: startBlocked mirrors isBlocked; isBlockingStart mirrors isBlocking. + expect(task).toMatchObject({ + startBlocked: true, + finishBlocked: false, + isBlockingStart: true, + isBlockingFinish: false, + }); + }); + + it("an unblocked task is neither start- nor finish-blocked", () => { + const task = buildTaskInfoFromMappedTask({ + path: "Tasks/free.md", + mappedTask: makeTask(), + defaultTaskStatus: "open", + isBlocked: false, + blockingTasks: [], + startBlocked: false, + finishBlocked: false, + }); + expect(task.startBlocked).toBe(false); + expect(task.finishBlocked).toBe(false); + expect(task.isBlocked).toBe(false); + }); +}); From ad0cfb0b79611322a32f35cd98ec054cecb542a2 Mon Sep 17 00:00:00 2001 From: renatomen Date: Thu, 23 Jul 2026 22:27:02 +1200 Subject: [PATCH 07/24] test(deps): extend issue-1100 dependencyCache mock with per-endpoint accessors The calendar-filter test mocks dependencyCache with only the legacy accessors; TaskManager's assembly now also calls isTaskStartBlocked/isTaskFinishBlocked and the reverse dependent-path accessors (U4), so the partial mock threw and the task dropped out of the filtered set. Add the missing methods to the mock. --- tests/unit/issues/issue-1100-calendar-filtered-tasks.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/unit/issues/issue-1100-calendar-filtered-tasks.test.ts b/tests/unit/issues/issue-1100-calendar-filtered-tasks.test.ts index b7acedb35..00682c083 100644 --- a/tests/unit/issues/issue-1100-calendar-filtered-tasks.test.ts +++ b/tests/unit/issues/issue-1100-calendar-filtered-tasks.test.ts @@ -57,7 +57,11 @@ function createPlugin(): TaskNotesPlugin { }, dependencyCache: { isTaskBlocked: jest.fn(() => false), + isTaskStartBlocked: jest.fn(() => false), + isTaskFinishBlocked: jest.fn(() => false), getBlockedTaskPaths: jest.fn(() => []), + getStartBlockedDependentPaths: jest.fn(() => []), + getFinishBlockedDependentPaths: jest.fn(() => []), }, app: { metadataCache: { From 513f4217ace44ef898b8a4e708a34fe556b10466 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 00:32:35 +1200 Subject: [PATCH 08/24] feat(dependencies): show per-endpoint blocked state on card surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the task-card secondary badge, the metadata pill, and the editor widget to the computed start/finish-blocked state instead of raw blocked-by existence. A task reads "Blocked · start" when it cannot begin and "Blocked · finish" when it can begin but cannot complete; start wins over finish so the "can I act now?" signal stays unambiguous. The badge and pill count only currently-constraining predecessors, so a released edge (started SS, completed FS) drops the blocked label and shows as a listed-but-satisfied dependency rather than "Blocked (1)". DependencyCache gains getStart/FinishBlockingPredecessorPaths (the forward constraining view); taskCardRelationships.resolveBlockedConstraint is the shared honest-signal resolver every surface reads. --- docs/releases/unreleased.md | 4 + i18n.manifest.json | 5 + i18n.state.json | 40 ++++++ src/i18n/resources/en.ts | 5 + src/ui/taskCardMetadata.ts | 30 +++-- src/ui/taskCardProperties.ts | 18 ++- src/ui/taskCardPropertyAccess.ts | 2 + src/ui/taskCardRelationships.ts | 48 +++++++ src/ui/taskCardSecondaryBadges.ts | 75 ++++++++--- src/utils/DependencyCache.ts | 41 ++++-- styles/task-card-bem.css | 24 ++++ .../issue-922-dependency-display.test.ts | 5 +- .../ui/taskCardMetadata.constraints.test.ts | 115 ++++++++++++++++ tests/unit/ui/taskCardMetadata.test.ts | 8 +- ...askCardSecondaryBadges.constraints.test.ts | 124 ++++++++++++++++++ 15 files changed, 499 insertions(+), 45 deletions(-) create mode 100644 tests/unit/ui/taskCardMetadata.constraints.test.ts create mode 100644 tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 395800bbf..1d3c5bf1a 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -32,6 +32,10 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l --> +## Added + +- Dependency blocking now distinguishes a task's start from its finish, following the predecessor relationship type (finish-to-start, start-to-start, finish-to-finish, start-to-finish). A task shows "Blocked · start" when it cannot begin yet and "Blocked · finish" when it can begin but cannot complete; the signal clears the moment the predecessor reaches the point the relationship requires — started for a start-to-* edge, completed for a finish-to-* edge. A satisfied dependency stays listed under the task but no longer counts toward the blocked signal. + ## Changed - Task statuses now have a lifecycle category — Not started, Started, or Completed — shown as a pill in the status settings, replacing the previous "Completed" toggle. Existing completed statuses stay Completed; the rest default to Not started. diff --git a/i18n.manifest.json b/i18n.manifest.json index eeb468dfe..9cda0d43f 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1988,6 +1988,11 @@ "ui.taskCard.labels.blocking": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "ui.taskCard.blockedBadge": "99613c74ce010d98ed5501785f3591344a19bc17", "ui.taskCard.blockedBadgeTooltip": "5c34a82e776374bf83ad16d8d8ffb1edad391579", + "ui.taskCard.blockedStart": "ce05f765ef58d17a4aac4bd45f8d453b658ab366", + "ui.taskCard.blockedStartTooltip": "84d827e46930216b0fba2ec6372e5cb5e321a659", + "ui.taskCard.blockedFinish": "c49a38ad19c43f66093f5853a9429978ba2cbdd9", + "ui.taskCard.blockedFinishTooltip": "a2760fee6e842c197bc753eb704c2e3c0decfdbb", + "ui.taskCard.dependenciesBadge": "0562f32dc56f5c702810cbe010068ddd38dbd69a", "ui.taskCard.blockingBadge": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "ui.taskCard.blockingBadgeTooltip": "949888e3fc7f208a3c9a867b79a1abf4f15aa90a", "ui.taskCard.blockingToggle": "17e5c29b584df3fa8161559141c209308994dccb", diff --git a/i18n.state.json b/i18n.state.json index cfdf7fa01..9d2a01338 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7944,6 +7944,11 @@ "source": "5c34a82e776374bf83ad16d8d8ffb1edad391579", "translation": "f12277cffadcf891e3fb0ee75c8b256a8af4af76" }, + "ui.taskCard.blockedStart": null, + "ui.taskCard.blockedStartTooltip": null, + "ui.taskCard.blockedFinish": null, + "ui.taskCard.blockedFinishTooltip": null, + "ui.taskCard.dependenciesBadge": null, "ui.taskCard.blockingBadge": { "source": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "translation": "94fa45d32ab235bc9cf188ee29f5189bd3b34782" @@ -16806,6 +16811,11 @@ "source": "5c34a82e776374bf83ad16d8d8ffb1edad391579", "translation": "53b0d18cdb8872b5b47fc9c80a55ca41784cee12" }, + "ui.taskCard.blockedStart": null, + "ui.taskCard.blockedStartTooltip": null, + "ui.taskCard.blockedFinish": null, + "ui.taskCard.blockedFinishTooltip": null, + "ui.taskCard.dependenciesBadge": null, "ui.taskCard.blockingBadge": { "source": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "translation": "8a50b0ef4b49d07acfdf58ca7b4578ea5e9a760e" @@ -25668,6 +25678,11 @@ "source": "5c34a82e776374bf83ad16d8d8ffb1edad391579", "translation": "93a69f5c9a8fc2ca2f66caf7afde44468e27c861" }, + "ui.taskCard.blockedStart": null, + "ui.taskCard.blockedStartTooltip": null, + "ui.taskCard.blockedFinish": null, + "ui.taskCard.blockedFinishTooltip": null, + "ui.taskCard.dependenciesBadge": null, "ui.taskCard.blockingBadge": { "source": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "translation": "c05c5176e9c9c242715c6d11a0deec2695191f9f" @@ -34530,6 +34545,11 @@ "source": "5c34a82e776374bf83ad16d8d8ffb1edad391579", "translation": "bfc388ea0f2bb3290fad0e726b561e5357dd8ed9" }, + "ui.taskCard.blockedStart": null, + "ui.taskCard.blockedStartTooltip": null, + "ui.taskCard.blockedFinish": null, + "ui.taskCard.blockedFinishTooltip": null, + "ui.taskCard.dependenciesBadge": null, "ui.taskCard.blockingBadge": { "source": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "translation": "1a2fe6fc48859372352cc38a1e3be6d2ee96f4dd" @@ -43392,6 +43412,11 @@ "source": "5c34a82e776374bf83ad16d8d8ffb1edad391579", "translation": "07df251b75d8834bb89fc167a8a89c4228122203" }, + "ui.taskCard.blockedStart": null, + "ui.taskCard.blockedStartTooltip": null, + "ui.taskCard.blockedFinish": null, + "ui.taskCard.blockedFinishTooltip": null, + "ui.taskCard.dependenciesBadge": null, "ui.taskCard.blockingBadge": { "source": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "translation": "120c87f0634c333a403f8fb8a4110d583895a634" @@ -52254,6 +52279,11 @@ "source": "5c34a82e776374bf83ad16d8d8ffb1edad391579", "translation": "e64e7cd6bed7acf660a2f86dfc4360403bb13a23" }, + "ui.taskCard.blockedStart": null, + "ui.taskCard.blockedStartTooltip": null, + "ui.taskCard.blockedFinish": null, + "ui.taskCard.blockedFinishTooltip": null, + "ui.taskCard.dependenciesBadge": null, "ui.taskCard.blockingBadge": { "source": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "translation": "8a50b0ef4b49d07acfdf58ca7b4578ea5e9a760e" @@ -61116,6 +61146,11 @@ "source": "5c34a82e776374bf83ad16d8d8ffb1edad391579", "translation": "72075cbdefafa41ae10ea6f92a436ba3031c23af" }, + "ui.taskCard.blockedStart": null, + "ui.taskCard.blockedStartTooltip": null, + "ui.taskCard.blockedFinish": null, + "ui.taskCard.blockedFinishTooltip": null, + "ui.taskCard.dependenciesBadge": null, "ui.taskCard.blockingBadge": { "source": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "translation": "5ac46244a26dc9ecf19afa26ec61535a71eeb983" @@ -69978,6 +70013,11 @@ "source": "5c34a82e776374bf83ad16d8d8ffb1edad391579", "translation": "8841dcd71dcfd341e4532af66be062b909830f91" }, + "ui.taskCard.blockedStart": null, + "ui.taskCard.blockedStartTooltip": null, + "ui.taskCard.blockedFinish": null, + "ui.taskCard.blockedFinishTooltip": null, + "ui.taskCard.dependenciesBadge": null, "ui.taskCard.blockingBadge": { "source": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "translation": "1127903a5c2adf803c50e0dc75386e7a93f91a88" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 454f3fd0d..ad5d8b292 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3270,6 +3270,11 @@ export const en: TranslationTree = { }, blockedBadge: "Blocked", blockedBadgeTooltip: "This task is waiting on another task", + blockedStart: "Blocked · start", + blockedStartTooltip: "Cannot start yet — waiting on a predecessor", + blockedFinish: "Blocked · finish", + blockedFinishTooltip: "Can start, but cannot finish yet — waiting on a predecessor", + dependenciesBadge: "Dependencies", blockingBadge: "Blocking", blockingBadgeTooltip: "This task is blocking another task", blockingToggle: "Blocking {count} tasks", diff --git a/src/ui/taskCardMetadata.ts b/src/ui/taskCardMetadata.ts index 16eb9a292..fc35c22bc 100644 --- a/src/ui/taskCardMetadata.ts +++ b/src/ui/taskCardMetadata.ts @@ -10,7 +10,7 @@ import { updateMetadataVisibility, type TaskCardPropertyOptions, } from "./taskCardProperties"; -import { getBlockedByTaskPaths } from "./taskCardRelationships"; +import { getBlockedByTaskPaths, resolveBlockedConstraint } from "./taskCardRelationships"; import { prepareInteractiveControl } from "./taskCardIndicators"; import { toggleTaskCardBlockedByExpansion, @@ -55,32 +55,36 @@ function createBlockedMetadataPill(config: RenderTaskCardMetadataConfig): HTMLEl return null; } - const blockedLabel = plugin.i18n.translate("ui.taskCard.blockedBadge"); - const blockedByPaths = getBlockedByTaskPaths(task, plugin.app); - const blockedCount = task.blockedBy?.length ?? 0; - const pillText = blockedCount > 0 ? `${blockedLabel} (${blockedCount})` : blockedLabel; + const blocked = resolveBlockedConstraint(task, plugin.app, plugin.dependencyCache); + const isFinish = blocked.state === "finish"; + const blockedLabel = plugin.i18n.translate( + isFinish ? "ui.taskCard.blockedFinish" : "ui.taskCard.blockedStart" + ); + const blockedTooltip = plugin.i18n.translate( + isFinish ? "ui.taskCard.blockedFinishTooltip" : "ui.taskCard.blockedStartTooltip" + ); + const pillText = `${blockedLabel} (${blocked.count})`; const blockedPill = metadataLine.createSpan({ - cls: "task-card__metadata-pill task-card__metadata-pill--blocked", + cls: `task-card__metadata-pill task-card__metadata-pill--blocked ${ + isFinish ? "is-finish-blocked" : "is-start-blocked" + }`, text: pillText, }); - if (blockedByPaths.length > 0) { - const toggleLabel = `${blockedLabel} (${blockedByPaths.length})`; + if (getBlockedByTaskPaths(task, plugin.app).length > 0) { prepareInteractiveControl(blockedPill); - blockedPill.setAttribute("aria-label", toggleLabel); + blockedPill.setAttribute("aria-label", pillText); blockedPill.setAttribute( "aria-expanded", String(Boolean(card.querySelector(".task-card__blocked-by"))) ); - setTooltip(blockedPill, toggleLabel, { placement: "top" }); + setTooltip(blockedPill, blockedTooltip, { placement: "top" }); blockedPill.addEventListener("click", (event) => { event.stopPropagation(); onBlockedByToggle(); }); } else { - setTooltip(blockedPill, plugin.i18n.translate("ui.taskCard.blockedBadgeTooltip"), { - placement: "top", - }); + setTooltip(blockedPill, blockedTooltip, { placement: "top" }); } return blockedPill; diff --git a/src/ui/taskCardProperties.ts b/src/ui/taskCardProperties.ts index 9a1c5bedc..fc1e4916a 100644 --- a/src/ui/taskCardProperties.ts +++ b/src/ui/taskCardProperties.ts @@ -34,6 +34,7 @@ import { } from "./renderers/linkRenderer"; import { renderContextsValue, renderTagsValue, type TagServices } from "./renderers/tagRenderer"; import { normalizeDependencyEntry, resolveDependencyEntry } from "../utils/dependencyUtils"; +import { resolveBlockedConstraint } from "./taskCardRelationships"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Ui/TaskCardProperties" }); @@ -428,13 +429,18 @@ const PROPERTY_RENDERERS: Record = { })}`; } }, - blocked: (element, value, task, plugin, options) => { - if (value === true) { - const blockedCount = task.blockedBy?.length ?? 0; - const label = getTaskCardPropertyLabel("blocked", plugin, options?.propertyLabels); - element.textContent = blockedCount > 0 ? `${label} (${blockedCount})` : label; - element.classList.add("task-card__metadata-pill--blocked"); + blocked: (element, value, task, plugin) => { + if (value !== true) { + return; } + const blocked = resolveBlockedConstraint(task, plugin.app, plugin.dependencyCache); + const isFinish = blocked.state === "finish"; + const label = plugin.i18n.translate( + isFinish ? "ui.taskCard.blockedFinish" : "ui.taskCard.blockedStart" + ); + element.textContent = `${label} (${blocked.count})`; + element.classList.add("task-card__metadata-pill--blocked"); + element.classList.add(isFinish ? "is-finish-blocked" : "is-start-blocked"); }, blocking: (element, value, task, plugin, options) => { if (value === true) { diff --git a/src/ui/taskCardPropertyAccess.ts b/src/ui/taskCardPropertyAccess.ts index c9e8134dd..db4bedfea 100644 --- a/src/ui/taskCardPropertyAccess.ts +++ b/src/ui/taskCardPropertyAccess.ts @@ -49,6 +49,8 @@ const PROPERTY_EXTRACTORS: Record unknown> = { contexts: (task) => task.contexts, tags: (task) => task.tags, blocked: (task) => task.isBlocked, + blockedStart: (task) => task.startBlocked, + blockedFinish: (task) => task.finishBlocked, blocking: (task) => task.isBlocking, blockedBy: (task) => task.blockedBy, blockingTasks: (task) => task.blocking, diff --git a/src/ui/taskCardRelationships.ts b/src/ui/taskCardRelationships.ts index 26b0fc9c7..f314dc044 100644 --- a/src/ui/taskCardRelationships.ts +++ b/src/ui/taskCardRelationships.ts @@ -106,3 +106,51 @@ export function getBlockedByTaskPaths(task: TaskInfo, app: App): string[] { return Array.from(paths); } + +export interface DependencyConstraintSource { + getStartBlockingPredecessorPaths(taskPath: string): string[]; + getFinishBlockingPredecessorPaths(taskPath: string): string[]; +} + +export function getStartBlockingTaskPaths( + task: TaskInfo, + cache: DependencyConstraintSource | undefined +): string[] { + return cache ? cache.getStartBlockingPredecessorPaths(task.path) : []; +} + +export function getFinishBlockingTaskPaths( + task: TaskInfo, + cache: DependencyConstraintSource | undefined +): string[] { + return cache ? cache.getFinishBlockingPredecessorPaths(task.path) : []; +} + +export type BlockedConstraintState = "start" | "finish" | "released" | "none"; + +export interface BlockedConstraintInfo { + state: BlockedConstraintState; + count: number; +} + +// Start wins over finish (an un-startable task is implicitly un-finishable), and the +// count excludes released edges: a satisfied dependency is listed but not counted. +export function resolveBlockedConstraint( + task: TaskInfo, + app: App, + cache: DependencyConstraintSource | undefined +): BlockedConstraintInfo { + const existence = getBlockedByTaskPaths(task, app); + if (existence.length === 0) { + return { state: "none", count: 0 }; + } + if (task.startBlocked) { + const constraining = getStartBlockingTaskPaths(task, cache).length; + return { state: "start", count: constraining || existence.length }; + } + if (task.finishBlocked) { + const constraining = getFinishBlockingTaskPaths(task, cache).length; + return { state: "finish", count: constraining || existence.length }; + } + return { state: "released", count: existence.length }; +} diff --git a/src/ui/taskCardSecondaryBadges.ts b/src/ui/taskCardSecondaryBadges.ts index b84035263..52917ab24 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -13,7 +13,10 @@ import { updateBadgeIndicator, } from "./taskCardIndicators"; import { removeRelationshipContainer } from "./taskCardRelationshipExpansion"; -import { getBlockedByTaskPaths } from "./taskCardRelationships"; +import { + type BlockedConstraintState, + resolveBlockedConstraint, +} from "./taskCardRelationships"; import { type TaskCardPresentationOptions } from "./taskCardPresentation"; export interface TaskCardSecondaryBadgeOptions { @@ -250,6 +253,45 @@ function renderProjectBadges( } } +type BlockedToggleState = Exclude; + +const BLOCKED_STATE_CLASSES = ["is-start-blocked", "is-finish-blocked", "is-released"]; + +const BLOCKED_BADGE_TREATMENT: Record< + BlockedToggleState, + { stateClass: string; tooltipKey: "blockedStart" | "blockedFinish" | "dependenciesBadge" } +> = { + start: { stateClass: "is-start-blocked", tooltipKey: "blockedStart" }, + finish: { stateClass: "is-finish-blocked", tooltipKey: "blockedFinish" }, + released: { stateClass: "is-released", tooltipKey: "dependenciesBadge" }, +}; + +interface BlockedToggleDescriptor { + state: BlockedToggleState; + stateClass: string; + className: string; + tooltip: string; + count: number; +} + +function describeBlockedToggle( + task: TaskInfo, + plugin: TaskNotesPlugin +): BlockedToggleDescriptor | null { + const blocked = resolveBlockedConstraint(task, plugin.app, plugin.dependencyCache); + if (blocked.state === "none") { + return null; + } + const { stateClass, tooltipKey } = BLOCKED_BADGE_TREATMENT[blocked.state]; + return { + state: blocked.state, + stateClass, + className: `task-card__blocked-toggle is-visible ${stateClass}`, + tooltip: `${tTaskCard(plugin, tooltipKey)} (${blocked.count})`, + count: blocked.count, + }; +} + function renderDependencyToggles( options: RenderTaskCardSecondaryBadgesOptions, badgesContainer: HTMLElement @@ -273,18 +315,18 @@ function renderDependencyToggles( } } - const blockedByPaths = getBlockedByTaskPaths(task, plugin.app); - if (blockedByPaths.length > 0) { - const toggleLabel = `${tTaskCard(plugin, "blockedBadge")} (${blockedByPaths.length})`; + const blocked = describeBlockedToggle(task, plugin); + if (blocked) { const toggle = createBadgeIndicator({ container: badgesContainer, - className: "task-card__blocked-toggle is-visible", + className: blocked.className, icon: "git-merge", - tooltip: toggleLabel, + tooltip: blocked.tooltip, onClick: createBlockedByToggleClickHandler(task, plugin, card, handlers), }); if (toggle) { - toggle.dataset.count = String(blockedByPaths.length); + toggle.dataset.count = String(blocked.count); + toggle.dataset.blockedState = blocked.state; } } } @@ -447,19 +489,17 @@ function updateBlockingToggle(options: UpdateTaskCardSecondaryBadgesOptions): vo function updateBlockedByToggle(options: UpdateTaskCardSecondaryBadgesOptions): void { const { card, task, plugin, handlers } = options; - const blockedByPaths = getBlockedByTaskPaths(task, plugin.app); - const shouldExist = blockedByPaths.length > 0; - const toggleLabel = `${tTaskCard(plugin, "blockedBadge")} (${blockedByPaths.length})`; + const blocked = describeBlockedToggle(task, plugin); const toggle = updateBadgeIndicator(card, ".task-card__blocked-toggle", { - shouldExist, - className: "task-card__blocked-toggle is-visible", + shouldExist: blocked !== null, + className: blocked?.className ?? "task-card__blocked-toggle is-visible", icon: "git-merge", - tooltip: toggleLabel, + tooltip: blocked?.tooltip ?? "", onClick: createBlockedByToggleClickHandler(task, plugin, card, handlers), }); - if (!shouldExist) { + if (!blocked) { removeRelationshipContainer(card, ".task-card__blocked-by"); return; } @@ -470,14 +510,17 @@ function updateBlockedByToggle(options: UpdateTaskCardSecondaryBadgesOptions): v toggle.classList.add("is-visible"); toggle.classList.remove("is-hidden"); - toggle.dataset.count = String(blockedByPaths.length); + toggle.classList.remove(...BLOCKED_STATE_CLASSES); + toggle.classList.add(blocked.stateClass); + toggle.dataset.count = String(blocked.count); + toggle.dataset.blockedState = blocked.state; if (toggle.classList.contains("task-card__blocked-toggle--expanded")) { handlers.toggleBlockedByTasks(card, task, true).catch((error: unknown) => { getTaskCardBadgeLogger(plugin).error("Error refreshing blocked-by tasks", { category: "internal", operation: "refresh-blocked-by-tasks", - details: { taskPath: task.path, blockedByCount: blockedByPaths.length }, + details: { taskPath: task.path, blockedByCount: blocked.count }, error, }); }); diff --git a/src/utils/DependencyCache.ts b/src/utils/DependencyCache.ts index 0569f1b8e..8666d7d08 100644 --- a/src/utils/DependencyCache.ts +++ b/src/utils/DependencyCache.ts @@ -361,12 +361,14 @@ export class DependencyCache extends Events { : (this.startedStatusByPath.get(predecessorPath) ?? false); } - private computeBlockedState(taskPath: string): { - startBlocked: boolean; - finishBlocked: boolean; + // Forward view: the predecessors whose unreleased edges currently constrain this task, + // split by the endpoint they gate. A released edge (started-SS, completed FS) is excluded. + private computeConstrainingPredecessors(taskPath: string): { + start: Set; + finish: Set; } { - let startBlocked = false; - let finishBlocked = false; + const start = new Set(); + const finish = new Set(); const edges = this.edgeReltypes.get(taskPath); if (edges) { for (const [predecessorPath, reltype] of edges) { @@ -374,13 +376,21 @@ export class DependencyCache extends Events { continue; } if (reltypeConstrainsStart(reltype)) { - startBlocked = true; + start.add(predecessorPath); } else { - finishBlocked = true; + finish.add(predecessorPath); } } } - return { startBlocked, finishBlocked }; + return { start, finish }; + } + + private computeBlockedState(taskPath: string): { + startBlocked: boolean; + finishBlocked: boolean; + } { + const { start, finish } = this.computeConstrainingPredecessors(taskPath); + return { startBlocked: start.size > 0, finishBlocked: finish.size > 0 }; } // Reverse view: the successors this task currently blocks, split by the endpoint it gates. @@ -669,6 +679,21 @@ export class DependencyCache extends Events { return this.computeBlockedState(taskPath).finishBlocked; } + // Forward, release-aware view: FS/SS edges still gating start, FF/SF still gating finish. + getStartBlockingPredecessorPaths(taskPath: string): string[] { + if (!this.indexesBuilt) { + this.buildIndexesSync(); + } + return Array.from(this.computeConstrainingPredecessors(taskPath).start); + } + + getFinishBlockingPredecessorPaths(taskPath: string): string[] { + if (!this.indexesBuilt) { + this.buildIndexesSync(); + } + return Array.from(this.computeConstrainingPredecessors(taskPath).finish); + } + // Reverse: the successors this task currently blocks from starting. getStartBlockedDependentPaths(taskPath: string): string[] { if (!this.indexesBuilt) { diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index 070290fb6..91fc7f421 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -201,6 +201,16 @@ border-color: var(--tn-color-error); } +.tasknotes-plugin .task-card__metadata-pill--blocked.is-finish-blocked { + background: color-mix(in srgb, var(--tn-color-warning) 75%, var(--tn-bg-primary)); + border-color: color-mix(in srgb, var(--tn-color-warning) 85%, var(--tn-bg-primary)); +} + +.tasknotes-plugin .task-card__metadata-pill--blocked.is-finish-blocked:hover { + background: var(--tn-color-warning); + border-color: var(--tn-color-warning); +} + .tasknotes-plugin .task-card__metadata-pill--blocking { background: transparent; color: var(--tn-color-error); @@ -706,6 +716,20 @@ body.is-mobile .tasknotes-plugin .task-card.task-card--chevron-left .task-card__ display: none; } +.tasknotes-plugin .task-card__blocked-toggle.is-start-blocked { + color: var(--tn-color-error); + opacity: 1; +} + +.tasknotes-plugin .task-card__blocked-toggle.is-finish-blocked { + color: var(--tn-color-warning); + opacity: 1; +} + +.tasknotes-plugin .task-card__blocked-toggle.is-released { + color: var(--tn-text-muted); +} + /* Left-position variant for subtask chevron */ .tasknotes-plugin .task-card.task-card--chevron-left { /* Reserve gutter for chevron plus spacing */ diff --git a/tests/unit/issues/issue-922-dependency-display.test.ts b/tests/unit/issues/issue-922-dependency-display.test.ts index 162f69004..257c6318c 100644 --- a/tests/unit/issues/issue-922-dependency-display.test.ts +++ b/tests/unit/issues/issue-922-dependency-display.test.ts @@ -50,6 +50,8 @@ function createPlugin( const translations: Record = { "ui.taskCard.blockedBadge": "Blocked", "ui.taskCard.blockedBadgeTooltip": "This task is blocked", + "ui.taskCard.blockedStart": "Blocked · start", + "ui.taskCard.blockedFinish": "Blocked · finish", "ui.taskCard.taskOptions": "Task options", "ui.taskCard.priorityAriaLabel": `Priority: ${vars?.label ?? ""}`, }; @@ -75,13 +77,14 @@ describe("Issue #922: dependency display behavior", () => { path: "Tasks/blocked.md", blockedBy: [{ uid: "[[Tasks/blocker.md]]", reltype: "FINISHTOSTART" }], isBlocked: true, + startBlocked: true, }); const card = createTaskCard(task, plugin, ["blocked"]); const blockedPill = card.querySelector(".task-card__metadata-pill--blocked"); expect(blockedPill).not.toBeNull(); - expect(blockedPill?.textContent).toBe("Blocked (1)"); + expect(blockedPill?.textContent).toBe("Blocked · start (1)"); }); it("lets the blocked visible property expand blocker task cards", async () => { diff --git a/tests/unit/ui/taskCardMetadata.constraints.test.ts b/tests/unit/ui/taskCardMetadata.constraints.test.ts new file mode 100644 index 000000000..846dd3d7d --- /dev/null +++ b/tests/unit/ui/taskCardMetadata.constraints.test.ts @@ -0,0 +1,115 @@ +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskInfo } from "../../../src/types"; +import { renderTaskCardMetadata } from "../../../src/ui/taskCardMetadata"; + +function createTask(overrides: Partial = {}): TaskInfo { + return { + title: "Task", + status: "open", + priority: "normal", + path: "Tasks/task.md", + archived: false, + ...overrides, + }; +} + +function createPlugin(start: string[], finish: string[]): TaskNotesPlugin { + return { + settings: {}, + app: { + metadataCache: { + getFirstLinkpathDest: jest.fn(() => null), + getCache: jest.fn(() => ({ frontmatter: {} })), + }, + vault: { getAbstractFileByPath: jest.fn(() => null) }, + workspace: { openLinkText: jest.fn() }, + }, + dependencyCache: { + getStartBlockingPredecessorPaths: jest.fn(() => start), + getFinishBlockingPredecessorPaths: jest.fn(() => finish), + }, + fieldMapper: { + isPropertyForField: jest.fn(() => false), + lookupMappingKey: jest.fn((propertyId: string) => propertyId), + toUserField: jest.fn((field: string) => field), + }, + i18n: { + translate: jest.fn((key: string) => { + const translations: Record = { + "ui.taskCard.blockedStart": "Blocked · start", + "ui.taskCard.blockedStartTooltip": "Cannot start yet", + "ui.taskCard.blockedFinish": "Blocked · finish", + "ui.taskCard.blockedFinishTooltip": "Cannot finish yet", + }; + return translations[key] ?? key; + }), + }, + } as unknown as TaskNotesPlugin; +} + +function renderBlockedPill(task: TaskInfo, plugin: TaskNotesPlugin): HTMLElement | null { + const card = document.createElement("div"); + card.className = "task-card"; + const metadataLine = document.createElement("div"); + card.appendChild(metadataLine); + document.body.appendChild(card); + renderTaskCardMetadata({ + metadataLine, + card, + task, + plugin, + visibleProperties: ["blocked"], + onBlockedByToggle: jest.fn(), + }); + return metadataLine.querySelector(".task-card__metadata-pill--blocked"); +} + +describe("blocked metadata pill per-endpoint state (U5)", () => { + beforeEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); + }); + + it("labels a start-blocked task 'Blocked · start' with the constraining count", () => { + const pill = renderBlockedPill( + createTask({ + isBlocked: true, + startBlocked: true, + blockedBy: [ + { uid: "Tasks/a.md", reltype: "STARTTOSTART" }, + { uid: "Tasks/b.md", reltype: "STARTTOSTART" }, + ], + }), + createPlugin(["Tasks/a.md"], []) + ); + expect(pill?.textContent).toBe("Blocked · start (1)"); + expect(pill?.classList.contains("is-start-blocked")).toBe(true); + }); + + it("labels a finish-blocked task 'Blocked · finish' and uses the warning treatment", () => { + const pill = renderBlockedPill( + createTask({ + isBlocked: true, + startBlocked: false, + finishBlocked: true, + blockedBy: [{ uid: "Tasks/a.md", reltype: "FINISHTOFINISH" }], + }), + createPlugin([], ["Tasks/a.md"]) + ); + expect(pill?.textContent).toBe("Blocked · finish (1)"); + expect(pill?.classList.contains("is-finish-blocked")).toBe(true); + }); + + it("renders no blocked pill for a released task", () => { + const pill = renderBlockedPill( + createTask({ + isBlocked: false, + startBlocked: false, + finishBlocked: false, + blockedBy: [{ uid: "Tasks/a.md", reltype: "STARTTOSTART" }], + }), + createPlugin([], []) + ); + expect(pill).toBeNull(); + }); +}); diff --git a/tests/unit/ui/taskCardMetadata.test.ts b/tests/unit/ui/taskCardMetadata.test.ts index 280b72691..bdeb05f68 100644 --- a/tests/unit/ui/taskCardMetadata.test.ts +++ b/tests/unit/ui/taskCardMetadata.test.ts @@ -41,6 +41,10 @@ function createPlugin(): TaskNotesPlugin { const translations: Record = { "ui.taskCard.blockedBadge": "Blocked", "ui.taskCard.blockedBadgeTooltip": "This task is blocked", + "ui.taskCard.blockedStart": "Blocked · start", + "ui.taskCard.blockedStartTooltip": "Cannot start yet", + "ui.taskCard.blockedFinish": "Blocked · finish", + "ui.taskCard.blockedFinishTooltip": "Cannot finish yet", "ui.taskCard.blockingBadge": "Blocking", "ui.taskCard.blockingBadgeTooltip": "This task is blocking another task", "ui.taskCard.googleCalendarSyncTooltip": "Synced to Google Calendar", @@ -80,6 +84,7 @@ describe("taskCardMetadata", () => { card, task: createTask({ isBlocked: true, + startBlocked: true, blockedBy: [{ uid: "Tasks/blocker.md", reltype: "FINISHTOSTART" }], }), plugin, @@ -91,7 +96,7 @@ describe("taskCardMetadata", () => { ".task-card__metadata-pill--blocked" ); expect(elements).toEqual([blockedPill]); - expect(blockedPill?.textContent).toBe("Blocked (1)"); + expect(blockedPill?.textContent).toBe("Blocked · start (1)"); expect(blockedPill?.getAttribute("role")).toBe("button"); expect(blockedPill?.getAttribute("aria-expanded")).toBe("false"); @@ -192,6 +197,7 @@ describe("taskCardMetadata", () => { card, task: createTask({ isBlocked: true, + startBlocked: true, blockedBy: [{ uid: "Tasks/blocker.md", reltype: "FINISHTOSTART" }], }), plugin, diff --git a/tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts b/tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts new file mode 100644 index 000000000..4e684c253 --- /dev/null +++ b/tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "@jest/globals"; +import type { App } from "obsidian"; +import type { TaskDependency, TaskInfo } from "../../../src/types"; +import { + type DependencyConstraintSource, + resolveBlockedConstraint, +} from "../../../src/ui/taskCardRelationships"; + +const app = { + metadataCache: { getFirstLinkpathDest: () => null }, + vault: { getAbstractFileByPath: () => null }, +} as unknown as App; + +function makeTask(overrides: Partial = {}): TaskInfo { + return { + title: "Task", + status: "open", + priority: "normal", + path: "Tasks/dep.md", + archived: false, + tags: ["task"], + contexts: [], + projects: [], + ...overrides, + }; +} + +function edge(uid: string, reltype: TaskDependency["reltype"]): TaskDependency { + return { uid, reltype }; +} + +function cache(start: string[], finish: string[]): DependencyConstraintSource { + return { + getStartBlockingPredecessorPaths: () => start, + getFinishBlockingPredecessorPaths: () => finish, + }; +} + +describe("resolveBlockedConstraint honest signal (U5)", () => { + it("an SS edge to a not-started predecessor is start-blocked", () => { + const task = makeTask({ + blockedBy: [edge("Tasks/pred.md", "STARTTOSTART")], + startBlocked: true, + finishBlocked: false, + isBlocked: true, + }); + expect(resolveBlockedConstraint(task, app, cache(["Tasks/pred.md"], []))).toEqual({ + state: "start", + count: 1, + }); + }); + + it("an FF edge to an incomplete predecessor is finish-blocked (can start)", () => { + const task = makeTask({ + blockedBy: [edge("Tasks/pred.md", "FINISHTOFINISH")], + startBlocked: false, + finishBlocked: true, + isBlocked: true, + }); + expect(resolveBlockedConstraint(task, app, cache([], ["Tasks/pred.md"]))).toEqual({ + state: "finish", + count: 1, + }); + }); + + it("both-blocked collapses to start (an un-startable task is implicitly un-finishable)", () => { + const task = makeTask({ + blockedBy: [edge("Tasks/a.md", "STARTTOSTART"), edge("Tasks/b.md", "FINISHTOFINISH")], + startBlocked: true, + finishBlocked: true, + isBlocked: true, + }); + expect(resolveBlockedConstraint(task, app, cache(["Tasks/a.md"], ["Tasks/b.md"]))).toEqual({ + state: "start", + count: 1, + }); + }); + + it("a released edge drops the blocked label but still lists as a dependency", () => { + const task = makeTask({ + blockedBy: [edge("Tasks/pred.md", "STARTTOSTART")], + startBlocked: false, + finishBlocked: false, + isBlocked: false, + }); + expect(resolveBlockedConstraint(task, app, cache([], []))).toEqual({ + state: "released", + count: 1, + }); + }); + + it("the count reflects only currently-constraining predecessors, not existence", () => { + const task = makeTask({ + blockedBy: [edge("Tasks/a.md", "STARTTOSTART"), edge("Tasks/b.md", "STARTTOSTART")], + startBlocked: true, + finishBlocked: false, + isBlocked: true, + }); + expect(resolveBlockedConstraint(task, app, cache(["Tasks/a.md"], []))).toEqual({ + state: "start", + count: 1, + }); + }); + + it("no blocked-by edges yields state none", () => { + expect(resolveBlockedConstraint(makeTask(), app, cache([], []))).toEqual({ + state: "none", + count: 0, + }); + }); + + it("falls back to the existence count when no cache is available", () => { + const task = makeTask({ + blockedBy: [edge("Tasks/pred.md", "STARTTOSTART")], + startBlocked: true, + finishBlocked: false, + isBlocked: true, + }); + expect(resolveBlockedConstraint(task, app, undefined)).toEqual({ + state: "start", + count: 1, + }); + }); +}); From 6c7a3b27af7fb7ba290ca44052f6427f74ccd67e Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 01:11:26 +1200 Subject: [PATCH 09/24] feat(dependencies): author reltype + lag from both sides of the edit modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-row relationship controls to the task edit modal's Blocked-by and Blocking lists (gated by a new enableAdvancedDependencyTypes setting, off by default): a reltype dropdown, a structured lag (number + unit composed to an ISO-8601 duration), and a plain-language summary that names both concrete tasks so the direction can't be read backwards. Blocking rows source their real reltype/gap from the blocked task's own blockedBy (the canonical location) instead of assuming the default, and editing a Blocking edge writes the change back there — TaskService gains a "modify" action alongside add/remove, and the edit-change diff now reports modified edges. An exotic stored lag stays read-only so an edit never silently rewrites it. The row layout stacks on narrow and mobile widths. --- docs/releases/unreleased.md | 1 + i18n.manifest.json | 20 ++ i18n.state.json | 160 ++++++++++++++++ src/i18n/resources/en.ts | 32 ++++ src/modals/TaskEditModal.ts | 38 +++- src/modals/TaskModal.ts | 52 ++++- src/modals/taskEditChangeState.ts | 2 + src/modals/taskEditChanges.ts | 22 ++- src/modals/taskModalDependencies.ts | 178 +++++++++++++++++- src/services/TaskService.ts | 32 +++- .../task-service/taskBlockingRelationships.ts | 17 +- src/settings/defaults.ts | 1 + src/types/settings.ts | 2 + src/utils/dependencyUtils.ts | 37 ++++ styles/task-modal.css | 71 +++++++ tests/unit/modals/taskEditChangeState.test.ts | 2 +- .../taskModalReltypeControl.bothsides.test.ts | 101 ++++++++++ .../taskBlockingRelationships.test.ts | 70 +++++++ tests/unit/utils/dependencyGap.test.ts | 32 ++++ 19 files changed, 846 insertions(+), 24 deletions(-) create mode 100644 tests/unit/modals/taskModalReltypeControl.bothsides.test.ts create mode 100644 tests/unit/utils/dependencyGap.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 1d3c5bf1a..9364a04c3 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -35,6 +35,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Added - Dependency blocking now distinguishes a task's start from its finish, following the predecessor relationship type (finish-to-start, start-to-start, finish-to-finish, start-to-finish). A task shows "Blocked · start" when it cannot begin yet and "Blocked · finish" when it can begin but cannot complete; the signal clears the moment the predecessor reaches the point the relationship requires — started for a start-to-* edge, completed for a finish-to-* edge. A satisfied dependency stays listed under the task but no longer counts toward the blocked signal. +- The task edit modal can set each dependency's relationship type and an optional lag from both the "Blocked by" and "Blocking" lists, with a plain-language summary that names both tasks so the direction is never ambiguous. Editing a "Blocking" edge writes the relationship to the blocked task's own frontmatter. Advanced relationship types are off by default. ## Changed diff --git a/i18n.manifest.json b/i18n.manifest.json index 9cda0d43f..c4c4273cf 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1563,6 +1563,26 @@ "modals.task.dependencies.addTaskButton": "24700e60fedcf00a741c5adb1d9f15aee05f95d8", "modals.task.dependencies.selectTaskTooltip": "752b3be46866220ce17567bede45bdac374d4a4f", "modals.task.dependencies.removeTaskTooltip": "5be1bc9e1837b131abac53534235a44b4bca541e", + "modals.task.dependencies.thisTask": "fe4214812fd30d511c46fd8805552084ff0df47f", + "modals.task.dependencies.reltype.label": "9b4a86cba424a8ab70b35d9d1c129276324d7c03", + "modals.task.dependencies.reltype.finishToStart": "8ec81a8c3b9d0cd71c2c623edbfe897ed49cfacc", + "modals.task.dependencies.reltype.startToStart": "e6a33a3b7ad97040d82ca2a561515b93ea9ffc39", + "modals.task.dependencies.reltype.finishToFinish": "56f12e78d355d33960914e3bb28a42e9963f033c", + "modals.task.dependencies.reltype.startToFinish": "ef85558baac50380d1f6702fc3cd47dc6cd1d4ff", + "modals.task.dependencies.gap.label": "215953ae73cacbac55a17936f31b68796d1d5925", + "modals.task.dependencies.gap.placeholder": "b6589fc6ab0dc82cf12099d1c2d40ab994e8410c", + "modals.task.dependencies.gap.exotic": "bc073badb3924dd5322839e2338ce1afc7fbd7c5", + "modals.task.dependencies.gap.unit.hours": "9e25a34e635a123f8958bbe26e7c4843278597fb", + "modals.task.dependencies.gap.unit.days": "f6bb0f468a8569c67b3d6826e57e62c5500c3681", + "modals.task.dependencies.gap.unit.weeks": "7d75266a53b99ce4d80d337862ea1edd8ba1b1a1", + "modals.task.dependencies.summary.blockedBy.finishToStart": "0b13c4311f6709b43cf5df5cd25d9d8dbfa8611e", + "modals.task.dependencies.summary.blockedBy.startToStart": "e57a142e612010370d8dd5091aa7b66054982087", + "modals.task.dependencies.summary.blockedBy.finishToFinish": "c61e04f8588563575214b07c7cc407bb7e5fbefb", + "modals.task.dependencies.summary.blockedBy.startToFinish": "7623bbd63cfcac4ee412e388ebe8719fb09c1915", + "modals.task.dependencies.summary.blocking.finishToStart": "d3cb1ebbb1993329342312baea76b7d632244cf3", + "modals.task.dependencies.summary.blocking.startToStart": "2993b292dbefb6a76e3ffa2ec2ff920ecda51df7", + "modals.task.dependencies.summary.blocking.finishToFinish": "2881523ca1692b932537465a83ae16692941f6e3", + "modals.task.dependencies.summary.blocking.startToFinish": "37a73cf6cbd1e9f86eabbee297e4a75048c5f399", "modals.task.organization.projects": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "modals.task.organization.subtasks": "173312c6e2592de729610a4d319877c9aa8d8b67", "modals.task.organization.addToProject": "85be7d2ce90b02bd410c4deb918f46e112efd677", diff --git a/i18n.state.json b/i18n.state.json index 9d2a01338..5cf2bad88 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -6244,6 +6244,26 @@ "source": "5be1bc9e1837b131abac53534235a44b4bca541e", "translation": "adc9f2f32c8a773b7b0bac72f44be02ae4bdd45e" }, + "modals.task.dependencies.thisTask": null, + "modals.task.dependencies.reltype.label": null, + "modals.task.dependencies.reltype.finishToStart": null, + "modals.task.dependencies.reltype.startToStart": null, + "modals.task.dependencies.reltype.finishToFinish": null, + "modals.task.dependencies.reltype.startToFinish": null, + "modals.task.dependencies.gap.label": null, + "modals.task.dependencies.gap.placeholder": null, + "modals.task.dependencies.gap.exotic": null, + "modals.task.dependencies.gap.unit.hours": null, + "modals.task.dependencies.gap.unit.days": null, + "modals.task.dependencies.gap.unit.weeks": null, + "modals.task.dependencies.summary.blockedBy.finishToStart": null, + "modals.task.dependencies.summary.blockedBy.startToStart": null, + "modals.task.dependencies.summary.blockedBy.finishToFinish": null, + "modals.task.dependencies.summary.blockedBy.startToFinish": null, + "modals.task.dependencies.summary.blocking.finishToStart": null, + "modals.task.dependencies.summary.blocking.startToStart": null, + "modals.task.dependencies.summary.blocking.finishToFinish": null, + "modals.task.dependencies.summary.blocking.startToFinish": null, "modals.task.organization.projects": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "translation": "3930f79f07e540a93c58dbf7a3a48c45f2d91ef8" @@ -15111,6 +15131,26 @@ "source": "5be1bc9e1837b131abac53534235a44b4bca541e", "translation": "b8d2a10718e2a037c13b60400c7d38e0f97911fc" }, + "modals.task.dependencies.thisTask": null, + "modals.task.dependencies.reltype.label": null, + "modals.task.dependencies.reltype.finishToStart": null, + "modals.task.dependencies.reltype.startToStart": null, + "modals.task.dependencies.reltype.finishToFinish": null, + "modals.task.dependencies.reltype.startToFinish": null, + "modals.task.dependencies.gap.label": null, + "modals.task.dependencies.gap.placeholder": null, + "modals.task.dependencies.gap.exotic": null, + "modals.task.dependencies.gap.unit.hours": null, + "modals.task.dependencies.gap.unit.days": null, + "modals.task.dependencies.gap.unit.weeks": null, + "modals.task.dependencies.summary.blockedBy.finishToStart": null, + "modals.task.dependencies.summary.blockedBy.startToStart": null, + "modals.task.dependencies.summary.blockedBy.finishToFinish": null, + "modals.task.dependencies.summary.blockedBy.startToFinish": null, + "modals.task.dependencies.summary.blocking.finishToStart": null, + "modals.task.dependencies.summary.blocking.startToStart": null, + "modals.task.dependencies.summary.blocking.finishToFinish": null, + "modals.task.dependencies.summary.blocking.startToFinish": null, "modals.task.organization.projects": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "translation": "8541c1877ee3309d0f3d1910723d879f56f38441" @@ -23978,6 +24018,26 @@ "source": "5be1bc9e1837b131abac53534235a44b4bca541e", "translation": "276b299a986fcd43e518ea337f28cfa18f0d8428" }, + "modals.task.dependencies.thisTask": null, + "modals.task.dependencies.reltype.label": null, + "modals.task.dependencies.reltype.finishToStart": null, + "modals.task.dependencies.reltype.startToStart": null, + "modals.task.dependencies.reltype.finishToFinish": null, + "modals.task.dependencies.reltype.startToFinish": null, + "modals.task.dependencies.gap.label": null, + "modals.task.dependencies.gap.placeholder": null, + "modals.task.dependencies.gap.exotic": null, + "modals.task.dependencies.gap.unit.hours": null, + "modals.task.dependencies.gap.unit.days": null, + "modals.task.dependencies.gap.unit.weeks": null, + "modals.task.dependencies.summary.blockedBy.finishToStart": null, + "modals.task.dependencies.summary.blockedBy.startToStart": null, + "modals.task.dependencies.summary.blockedBy.finishToFinish": null, + "modals.task.dependencies.summary.blockedBy.startToFinish": null, + "modals.task.dependencies.summary.blocking.finishToStart": null, + "modals.task.dependencies.summary.blocking.startToStart": null, + "modals.task.dependencies.summary.blocking.finishToFinish": null, + "modals.task.dependencies.summary.blocking.startToFinish": null, "modals.task.organization.projects": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "translation": "b1e8cd4da2128109e230cd0b08a140bf854d70a8" @@ -32845,6 +32905,26 @@ "source": "5be1bc9e1837b131abac53534235a44b4bca541e", "translation": "d8851a8ef576d91cef7e1b6e0403e9fe086f5038" }, + "modals.task.dependencies.thisTask": null, + "modals.task.dependencies.reltype.label": null, + "modals.task.dependencies.reltype.finishToStart": null, + "modals.task.dependencies.reltype.startToStart": null, + "modals.task.dependencies.reltype.finishToFinish": null, + "modals.task.dependencies.reltype.startToFinish": null, + "modals.task.dependencies.gap.label": null, + "modals.task.dependencies.gap.placeholder": null, + "modals.task.dependencies.gap.exotic": null, + "modals.task.dependencies.gap.unit.hours": null, + "modals.task.dependencies.gap.unit.days": null, + "modals.task.dependencies.gap.unit.weeks": null, + "modals.task.dependencies.summary.blockedBy.finishToStart": null, + "modals.task.dependencies.summary.blockedBy.startToStart": null, + "modals.task.dependencies.summary.blockedBy.finishToFinish": null, + "modals.task.dependencies.summary.blockedBy.startToFinish": null, + "modals.task.dependencies.summary.blocking.finishToStart": null, + "modals.task.dependencies.summary.blocking.startToStart": null, + "modals.task.dependencies.summary.blocking.finishToFinish": null, + "modals.task.dependencies.summary.blocking.startToFinish": null, "modals.task.organization.projects": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "translation": "598ab78312d81dc734208e1af4111acd9de08fac" @@ -41712,6 +41792,26 @@ "source": "5be1bc9e1837b131abac53534235a44b4bca541e", "translation": "4616fd9c1974b653141390fc118af8c70e2f3618" }, + "modals.task.dependencies.thisTask": null, + "modals.task.dependencies.reltype.label": null, + "modals.task.dependencies.reltype.finishToStart": null, + "modals.task.dependencies.reltype.startToStart": null, + "modals.task.dependencies.reltype.finishToFinish": null, + "modals.task.dependencies.reltype.startToFinish": null, + "modals.task.dependencies.gap.label": null, + "modals.task.dependencies.gap.placeholder": null, + "modals.task.dependencies.gap.exotic": null, + "modals.task.dependencies.gap.unit.hours": null, + "modals.task.dependencies.gap.unit.days": null, + "modals.task.dependencies.gap.unit.weeks": null, + "modals.task.dependencies.summary.blockedBy.finishToStart": null, + "modals.task.dependencies.summary.blockedBy.startToStart": null, + "modals.task.dependencies.summary.blockedBy.finishToFinish": null, + "modals.task.dependencies.summary.blockedBy.startToFinish": null, + "modals.task.dependencies.summary.blocking.finishToStart": null, + "modals.task.dependencies.summary.blocking.startToStart": null, + "modals.task.dependencies.summary.blocking.finishToFinish": null, + "modals.task.dependencies.summary.blocking.startToFinish": null, "modals.task.organization.projects": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "translation": "65281a189635d891eacffef53dd841d76301f6ff" @@ -50579,6 +50679,26 @@ "source": "5be1bc9e1837b131abac53534235a44b4bca541e", "translation": "521d322fe560284a421fffe9ebae6206109e25eb" }, + "modals.task.dependencies.thisTask": null, + "modals.task.dependencies.reltype.label": null, + "modals.task.dependencies.reltype.finishToStart": null, + "modals.task.dependencies.reltype.startToStart": null, + "modals.task.dependencies.reltype.finishToFinish": null, + "modals.task.dependencies.reltype.startToFinish": null, + "modals.task.dependencies.gap.label": null, + "modals.task.dependencies.gap.placeholder": null, + "modals.task.dependencies.gap.exotic": null, + "modals.task.dependencies.gap.unit.hours": null, + "modals.task.dependencies.gap.unit.days": null, + "modals.task.dependencies.gap.unit.weeks": null, + "modals.task.dependencies.summary.blockedBy.finishToStart": null, + "modals.task.dependencies.summary.blockedBy.startToStart": null, + "modals.task.dependencies.summary.blockedBy.finishToFinish": null, + "modals.task.dependencies.summary.blockedBy.startToFinish": null, + "modals.task.dependencies.summary.blocking.finishToStart": null, + "modals.task.dependencies.summary.blocking.startToStart": null, + "modals.task.dependencies.summary.blocking.finishToFinish": null, + "modals.task.dependencies.summary.blocking.startToFinish": null, "modals.task.organization.projects": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "translation": "2ae76d4e12e2430967afb4ae125496ffa76bbeba" @@ -59446,6 +59566,26 @@ "source": "5be1bc9e1837b131abac53534235a44b4bca541e", "translation": "baf47b08bc84a7f2983bc31b1288c23ccc06f11a" }, + "modals.task.dependencies.thisTask": null, + "modals.task.dependencies.reltype.label": null, + "modals.task.dependencies.reltype.finishToStart": null, + "modals.task.dependencies.reltype.startToStart": null, + "modals.task.dependencies.reltype.finishToFinish": null, + "modals.task.dependencies.reltype.startToFinish": null, + "modals.task.dependencies.gap.label": null, + "modals.task.dependencies.gap.placeholder": null, + "modals.task.dependencies.gap.exotic": null, + "modals.task.dependencies.gap.unit.hours": null, + "modals.task.dependencies.gap.unit.days": null, + "modals.task.dependencies.gap.unit.weeks": null, + "modals.task.dependencies.summary.blockedBy.finishToStart": null, + "modals.task.dependencies.summary.blockedBy.startToStart": null, + "modals.task.dependencies.summary.blockedBy.finishToFinish": null, + "modals.task.dependencies.summary.blockedBy.startToFinish": null, + "modals.task.dependencies.summary.blocking.finishToStart": null, + "modals.task.dependencies.summary.blocking.startToStart": null, + "modals.task.dependencies.summary.blocking.finishToFinish": null, + "modals.task.dependencies.summary.blocking.startToFinish": null, "modals.task.organization.projects": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "translation": "b90d9f72b44e086831c38646ac95961cc7d79c64" @@ -68313,6 +68453,26 @@ "source": "5be1bc9e1837b131abac53534235a44b4bca541e", "translation": "0705816c6804a18095ddeedadeaa0357848d3432" }, + "modals.task.dependencies.thisTask": null, + "modals.task.dependencies.reltype.label": null, + "modals.task.dependencies.reltype.finishToStart": null, + "modals.task.dependencies.reltype.startToStart": null, + "modals.task.dependencies.reltype.finishToFinish": null, + "modals.task.dependencies.reltype.startToFinish": null, + "modals.task.dependencies.gap.label": null, + "modals.task.dependencies.gap.placeholder": null, + "modals.task.dependencies.gap.exotic": null, + "modals.task.dependencies.gap.unit.hours": null, + "modals.task.dependencies.gap.unit.days": null, + "modals.task.dependencies.gap.unit.weeks": null, + "modals.task.dependencies.summary.blockedBy.finishToStart": null, + "modals.task.dependencies.summary.blockedBy.startToStart": null, + "modals.task.dependencies.summary.blockedBy.finishToFinish": null, + "modals.task.dependencies.summary.blockedBy.startToFinish": null, + "modals.task.dependencies.summary.blocking.finishToStart": null, + "modals.task.dependencies.summary.blocking.startToStart": null, + "modals.task.dependencies.summary.blocking.finishToFinish": null, + "modals.task.dependencies.summary.blocking.startToFinish": null, "modals.task.organization.projects": { "source": "53e890d5f0fffe09587c55dce74e9a6febf59b5c", "translation": "22336e6b892f14855e9f71b6ff9d861510dbe57b" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index ad5d8b292..a4457d26b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2633,6 +2633,38 @@ export const en: TranslationTree = { addTaskButton: "Add task", selectTaskTooltip: "Select a task note using fuzzy search", removeTaskTooltip: "Remove task", + thisTask: "This task", + reltype: { + label: "Relationship", + finishToStart: "Finish-to-start", + startToStart: "Start-to-start", + finishToFinish: "Finish-to-finish", + startToFinish: "Start-to-finish", + }, + gap: { + label: "Lag", + placeholder: "0", + exotic: "Custom offset: {gap}", + unit: { + hours: "Hours", + days: "Days", + weeks: "Weeks", + }, + }, + summary: { + blockedBy: { + finishToStart: "{other} must finish before {self} can start", + startToStart: "{other} must start before {self} can start", + finishToFinish: "{other} must finish before {self} can finish", + startToFinish: "{other} must start before {self} can finish", + }, + blocking: { + finishToStart: "{self} must finish before {other} can start", + startToStart: "{self} must start before {other} can start", + finishToFinish: "{self} must finish before {other} can finish", + startToFinish: "{self} must start before {other} can finish", + }, + }, }, organization: { projects: "Projects", diff --git a/src/modals/TaskEditModal.ts b/src/modals/TaskEditModal.ts index f800917b1..0f4a7140a 100644 --- a/src/modals/TaskEditModal.ts +++ b/src/modals/TaskEditModal.ts @@ -13,6 +13,7 @@ import { stringifyUnknown } from "../utils/stringUtils"; import { ConfirmationModal, showConfirmationModal } from "./ConfirmationModal"; import { createCompletionsCalendarSection } from "./taskEditCompletions"; import { BlockingUpdates } from "./taskEditChanges"; +import { findBlockingEdgeDependency } from "./taskModalDependencies"; import { createTaskModalActionButtons } from "./taskModalActionButtons"; import { showTaskModalReminderContextMenu } from "./taskModalActionMenus"; import { buildTaskEditChangesFromModalState } from "./taskEditChangeState"; @@ -37,7 +38,8 @@ export class TaskEditModal extends TaskModal { private skippedInstancesChanges: string[] = []; private initialBlockedBy: TaskDependency[] = []; private initialBlockingPaths: string[] = []; - private pendingBlockingUpdates: BlockingUpdates = { added: [], removed: [], raw: {} }; + private initialBlockingEntries: Record = {}; + private pendingBlockingUpdates: BlockingUpdates = { added: [], removed: [], modified: [], raw: {} }; private unresolvedBlockingEntries: string[] = []; private initialTags = ""; private isShowingConfirmation = false; @@ -116,13 +118,30 @@ export class TaskEditModal extends TaskModal { ); this.initialBlockedBy = this.blockedByItems.map((item) => ({ ...item.dependency })); - this.blockingItems = (this.task.blocking ?? []).map((path) => - this.createDependencyItemFromPath(path) - ); + this.blockingItems = (this.task.blocking ?? []).map((path) => { + const item = this.createDependencyItemFromPath(path); + const blockingTask = this.plugin.cacheManager.getCachedTaskInfoSync(path); + const real = blockingTask + ? findBlockingEdgeDependency(this.plugin, this.task.path, blockingTask) + : null; + if (real) { + item.dependency = { ...item.dependency, reltype: real.reltype }; + if (real.gap) { + item.dependency.gap = real.gap; + } + } + return item; + }); this.initialBlockingPaths = this.blockingItems .filter((item) => item.path) .map((item) => item.path!); - this.pendingBlockingUpdates = { added: [], removed: [], raw: {} }; + this.initialBlockingEntries = {}; + for (const item of this.blockingItems) { + if (item.path) { + this.initialBlockingEntries[item.path] = { ...item.dependency }; + } + } + this.pendingBlockingUpdates = { added: [], removed: [], modified: [], raw: {} }; this.unresolvedBlockingEntries = []; } @@ -452,7 +471,8 @@ export class TaskEditModal extends TaskModal { const changes = this.getChanges({ includeConversionWrite: true }); const hasBlockingChanges = this.pendingBlockingUpdates.added.length > 0 || - this.pendingBlockingUpdates.removed.length > 0; + this.pendingBlockingUpdates.removed.length > 0 || + this.pendingBlockingUpdates.modified.length > 0; const hasTaskChanges = Object.keys(changes).length > 0; const hasSubtaskChanges = this.hasSubtaskChanges(); @@ -490,7 +510,8 @@ export class TaskEditModal extends TaskModal { updatedTask, this.pendingBlockingUpdates.added, this.pendingBlockingUpdates.removed, - this.pendingBlockingUpdates.raw + this.pendingBlockingUpdates.raw, + this.pendingBlockingUpdates.modified ); const refreshed = await this.plugin.cacheManager.getTaskInfo(updatedTask.path); @@ -524,7 +545,7 @@ export class TaskEditModal extends TaskModal { new Notice(this.t("modals.taskEdit.notices.dependenciesUpdateSuccess")); } - this.pendingBlockingUpdates = { added: [], removed: [], raw: {} }; + this.pendingBlockingUpdates = { added: [], removed: [], modified: [], raw: {} }; this.unresolvedBlockingEntries = []; } catch (error) { tasknotesLogger.error("Failed to update task:", { @@ -558,6 +579,7 @@ export class TaskEditModal extends TaskModal { initialBlockedBy: this.initialBlockedBy, blockingItems: this.blockingItems, initialBlockingPaths: this.initialBlockingPaths, + initialBlockingEntries: this.initialBlockingEntries, details: this.details, originalDetails: this.originalDetails, completedInstancesChanges: this.completedInstancesChanges, diff --git a/src/modals/TaskModal.ts b/src/modals/TaskModal.ts index 7635f8a3c..5537c916e 100644 --- a/src/modals/TaskModal.ts +++ b/src/modals/TaskModal.ts @@ -16,7 +16,7 @@ import { } from "./taskModalDetailsEditor"; import { splitFrontmatterAndBody } from "../utils/helpers"; import { ProjectSelectModal } from "./ProjectSelectModal"; -import { TaskDependency, Reminder } from "../types"; +import { TaskDependency, TaskDependencyRelType, Reminder } from "../types"; import { DEFAULT_DEPENDENCY_RELTYPE, formatDependencyLink } from "../utils/dependencyUtils"; import { type LinkServices } from "../ui/renderers/linkRenderer"; import { generateLink } from "../utils/linkUtils"; @@ -35,10 +35,12 @@ import { createDependencyItemFromFile as createDependencyItemFromFileHelper, createDependencyItemFromPath as createDependencyItemFromPathHelper, DependencyItem, + type DependencyListSide, getBlockedByDependencyCandidates, getBlockingDependencyCandidates, removeDependencyItemAtIndex, renderDependencyList, + updateDependencyItemAtIndex, } from "./taskModalDependencies"; import { createTaskModalContextsField, @@ -182,23 +184,50 @@ export abstract class TaskModal extends Modal { } protected renderBlockedByList(): void { - void this.renderDependencyList(this.blockedByList, this.blockedByItems, (index) => { - this.blockedByItems = removeDependencyItemAtIndex(this.blockedByItems, index); - this.renderBlockedByList(); + void this.renderDependencyList(this.blockedByList, this.blockedByItems, "blocked-by", { + onRemove: (index) => { + this.blockedByItems = removeDependencyItemAtIndex(this.blockedByItems, index); + this.renderBlockedByList(); + }, + onReltypeChange: (index, reltype) => { + this.blockedByItems = updateDependencyItemAtIndex(this.blockedByItems, index, { + reltype, + }); + this.renderBlockedByList(); + }, + onGapChange: (index, gap) => { + this.blockedByItems = updateDependencyItemAtIndex(this.blockedByItems, index, { gap }); + }, }); } protected renderBlockingList(): void { - void this.renderDependencyList(this.blockingList, this.blockingItems, (index) => { - this.blockingItems = removeDependencyItemAtIndex(this.blockingItems, index); - this.renderBlockingList(); + void this.renderDependencyList(this.blockingList, this.blockingItems, "blocking", { + onRemove: (index) => { + this.blockingItems = removeDependencyItemAtIndex(this.blockingItems, index); + this.renderBlockingList(); + }, + onReltypeChange: (index, reltype) => { + this.blockingItems = updateDependencyItemAtIndex(this.blockingItems, index, { + reltype, + }); + this.renderBlockingList(); + }, + onGapChange: (index, gap) => { + this.blockingItems = updateDependencyItemAtIndex(this.blockingItems, index, { gap }); + }, }); } private async renderDependencyList( listEl: HTMLElement | undefined, items: DependencyItem[], - onRemove: (index: number) => void + side: DependencyListSide, + handlers: { + onRemove: (index: number) => void; + onReltypeChange: (index: number, reltype: TaskDependencyRelType) => void; + onGapChange: (index: number, gap: string | undefined) => void; + } ): Promise { if (!listEl) { return; @@ -210,7 +239,12 @@ export abstract class TaskModal extends Modal { items, linkServices: this.getLinkServices(), translate: (key, params) => this.t(key, params), - onRemove, + side, + selfName: this.title.trim() || this.t("modals.task.dependencies.thisTask"), + showReltypeControls: this.plugin.settings.enableAdvancedDependencyTypes, + onRemove: handlers.onRemove, + onReltypeChange: handlers.onReltypeChange, + onGapChange: handlers.onGapChange, }); } diff --git a/src/modals/taskEditChangeState.ts b/src/modals/taskEditChangeState.ts index c6e34df16..d30e579a9 100644 --- a/src/modals/taskEditChangeState.ts +++ b/src/modals/taskEditChangeState.ts @@ -39,6 +39,7 @@ export interface TaskEditModalChangeState { initialBlockedBy: TaskDependency[]; blockingItems: DependencyItemLike[]; initialBlockingPaths: string[]; + initialBlockingEntries?: Record; details: string; originalDetails: string; completedInstancesChanges: string[]; @@ -91,6 +92,7 @@ export function buildTaskEditChangesFromModalState( initialBlockedBy: input.initialBlockedBy, blockingItems: input.blockingItems, initialBlockingPaths: input.initialBlockingPaths, + initialBlockingEntries: input.initialBlockingEntries, details: input.details, originalDetails: input.originalDetails, completedInstancesChanges: input.completedInstancesChanges, diff --git a/src/modals/taskEditChanges.ts b/src/modals/taskEditChanges.ts index aa359cda8..0e41f941e 100644 --- a/src/modals/taskEditChanges.ts +++ b/src/modals/taskEditChanges.ts @@ -15,6 +15,7 @@ interface DependencyItem { export interface BlockingUpdates { added: string[]; removed: string[]; + modified: string[]; raw: Record; } @@ -37,6 +38,7 @@ export interface TaskEditChangeInput { initialBlockedBy: TaskDependency[]; blockingItems: DependencyItem[]; initialBlockingPaths: string[]; + initialBlockingEntries?: Record; details: string; originalDetails: string; completedInstancesChanges: string[]; @@ -218,6 +220,10 @@ function normalizeProjectList(projects: string[]): string[] { .filter((project) => project.length > 0); } +function isSameReltypeAndGap(a: TaskDependency, b: TaskDependency): boolean { + return a.reltype === b.reltype && (a.gap ?? "") === (b.gap ?? ""); +} + function buildBlockingUpdates(input: TaskEditChangeInput): { blockingUpdates: BlockingUpdates; unresolvedBlockingEntries: string[]; @@ -238,9 +244,21 @@ function buildBlockingUpdates(input: TaskEditChangeInput): { const newPathSet = new Set(newBlockingPaths); const added = newBlockingPaths.filter((path) => !originalPaths.has(path)); const removed = input.initialBlockingPaths.filter((path) => !newPathSet.has(path)); + const initialEntries = input.initialBlockingEntries ?? {}; + const modified = newBlockingPaths.filter((path) => { + if (!originalPaths.has(path)) { + return false; + } + const current = resolvedBlocking.get(path); + const initial = initialEntries[path]; + if (!current || !initial) { + return false; + } + return !isSameReltypeAndGap(current, initial); + }); const raw: Record = {}; - for (const path of added) { + for (const path of [...added, ...modified]) { const dependency = resolvedBlocking.get(path); if (dependency) { raw[path] = { ...dependency }; @@ -248,7 +266,7 @@ function buildBlockingUpdates(input: TaskEditChangeInput): { } return { - blockingUpdates: { added, removed, raw }, + blockingUpdates: { added, removed, modified, raw }, unresolvedBlockingEntries, }; } diff --git a/src/modals/taskModalDependencies.ts b/src/modals/taskModalDependencies.ts index e55e90733..dd06168ce 100644 --- a/src/modals/taskModalDependencies.ts +++ b/src/modals/taskModalDependencies.ts @@ -1,10 +1,13 @@ import { setTooltip, TFile } from "obsidian"; import TaskNotesPlugin from "../main"; -import { TaskDependency, TaskInfo } from "../types"; +import { TaskDependency, TaskDependencyRelType, TaskInfo } from "../types"; import { + composeDependencyGap, DEFAULT_DEPENDENCY_RELTYPE, + type DependencyGapUnit, formatDependencyLink, normalizeDependencyEntry, + parseDependencyGap, resolveDependencyEntry, } from "../utils/dependencyUtils"; import { appendInternalLink, type LinkServices } from "../ui/renderers/linkRenderer"; @@ -23,6 +26,8 @@ export interface CreateDependencyContext { sourcePath: string; } +export type DependencyListSide = "blocked-by" | "blocking"; + export interface RenderDependencyListOptions { plugin: TaskNotesPlugin; listEl: HTMLElement | undefined; @@ -30,8 +35,29 @@ export interface RenderDependencyListOptions { linkServices: LinkServices; translate: (key: string, params?: Record) => string; onRemove: (index: number) => void; + side: DependencyListSide; + selfName: string; + showReltypeControls?: boolean; + onReltypeChange?: (index: number, reltype: TaskDependencyRelType) => void; + onGapChange?: (index: number, gap: string | undefined) => void; } +const RELTYPE_ORDER: TaskDependencyRelType[] = [ + "FINISHTOSTART", + "STARTTOSTART", + "FINISHTOFINISH", + "STARTTOFINISH", +]; + +const RELTYPE_KEY: Record = { + FINISHTOSTART: "finishToStart", + STARTTOSTART: "startToStart", + FINISHTOFINISH: "finishToFinish", + STARTTOFINISH: "startToFinish", +}; + +const GAP_UNITS: DependencyGapUnit[] = ["days", "weeks", "hours"]; + export function createDependencyItemFromFile( { plugin, sourcePath }: CreateDependencyContext, file: TFile @@ -87,6 +113,27 @@ export function createDependencyItemFromDependency( }; } +// A blocking edge's canonical reltype/gap lives in the blocked task's blockedBy, not on this +// task; find the entry there that points back to this task so the row shows the real values. +export function findBlockingEdgeDependency( + plugin: TaskNotesPlugin, + thisTaskPath: string, + blockingTask: Pick +): TaskDependency | null { + const entries = Array.isArray(blockingTask.blockedBy) ? blockingTask.blockedBy : []; + for (const entry of entries) { + const normalized = normalizeDependencyEntry(entry); + if (!normalized) { + continue; + } + const resolved = resolveDependencyEntry(plugin.app, blockingTask.path, normalized); + if (resolved?.path === thisTaskPath) { + return normalized; + } + } + return null; +} + export function createDependencyItemFromPath( { plugin, sourcePath }: CreateDependencyContext, path: string @@ -146,6 +193,18 @@ export function removeDependencyItemAtIndex( return items.filter((_, index) => index !== indexToRemove); } +export function updateDependencyItemAtIndex( + items: readonly DependencyItem[], + indexToUpdate: number, + patch: Partial +): DependencyItem[] { + return items.map((item, index) => + index === indexToUpdate + ? { ...item, dependency: { ...item.dependency, ...patch } } + : item + ); +} + export async function renderDependencyList({ plugin, listEl, @@ -153,6 +212,11 @@ export async function renderDependencyList({ linkServices, translate, onRemove, + side, + selfName, + showReltypeControls, + onReltypeChange, + onGapChange, }: RenderDependencyListOptions): Promise { if (!listEl) { return; @@ -170,6 +234,9 @@ export async function renderDependencyList({ ? "task-project-item task-project-item--task-card" : "task-project-item", }); + if (showReltypeControls) { + itemEl.addClass("task-project-item--with-reltype"); + } if (item.unresolved) { itemEl.addClass("task-project-item--unresolved"); setTooltip( @@ -203,7 +270,116 @@ export async function renderDependencyList({ event.stopPropagation(); onRemove(index); }); + + if (showReltypeControls) { + renderReltypeControls(itemEl, { + item, + index, + side, + selfName, + translate, + onReltypeChange, + onGapChange, + }); + } + } +} + +interface ReltypeControlsOptions { + item: DependencyItem; + index: number; + side: DependencyListSide; + selfName: string; + translate: (key: string, params?: Record) => string; + onReltypeChange?: (index: number, reltype: TaskDependencyRelType) => void; + onGapChange?: (index: number, gap: string | undefined) => void; +} + +function renderReltypeControls(itemEl: HTMLElement, options: ReltypeControlsOptions): void { + const { item, index, side, selfName, translate, onReltypeChange, onGapChange } = options; + const controlsEl = itemEl.createDiv({ cls: "task-dependency-controls" }); + + const reltypeField = controlsEl.createDiv({ cls: "task-dependency-field" }); + reltypeField.createSpan({ + cls: "task-dependency-field-label", + text: translate("modals.task.dependencies.reltype.label"), + }); + const reltypeSelect = reltypeField.createEl("select", { + cls: "task-dependency-reltype dropdown", + }); + for (const reltype of RELTYPE_ORDER) { + const option = reltypeSelect.createEl("option", { + value: reltype, + text: translate(`modals.task.dependencies.reltype.${RELTYPE_KEY[reltype]}`), + }); + if (reltype === item.dependency.reltype) { + option.selected = true; + } } + reltypeSelect.addEventListener("change", () => { + onReltypeChange?.(index, reltypeSelect.value as TaskDependencyRelType); + }); + + renderGapField(controlsEl, { item, index, translate, onGapChange }); + + const sideKey = side === "blocked-by" ? "blockedBy" : "blocking"; + controlsEl.createDiv({ + cls: "task-dependency-summary", + text: translate( + `modals.task.dependencies.summary.${sideKey}.${RELTYPE_KEY[item.dependency.reltype]}`, + { self: selfName, other: item.name } + ), + }); +} + +function renderGapField( + controlsEl: HTMLElement, + options: Pick +): void { + const { item, index, translate, onGapChange } = options; + const parsed = parseDependencyGap(item.dependency.gap); + + const gapField = controlsEl.createDiv({ cls: "task-dependency-field" }); + gapField.createSpan({ + cls: "task-dependency-field-label", + text: translate("modals.task.dependencies.gap.label"), + }); + + // A stored gap this UI can't compose stays read-only so an edit never silently rewrites it. + if (item.dependency.gap && !parsed) { + gapField.createSpan({ + cls: "task-dependency-gap-exotic", + text: translate("modals.task.dependencies.gap.exotic", { gap: item.dependency.gap }), + }); + return; + } + + const gapValue = gapField.createEl("input", { + cls: "task-dependency-gap-value", + type: "number", + }); + gapValue.min = "0"; + gapValue.placeholder = translate("modals.task.dependencies.gap.placeholder"); + if (parsed) { + gapValue.value = String(parsed.value); + } + + const gapUnit = gapField.createEl("select", { cls: "task-dependency-gap-unit dropdown" }); + for (const unit of GAP_UNITS) { + const option = gapUnit.createEl("option", { + value: unit, + text: translate(`modals.task.dependencies.gap.unit.${unit}`), + }); + if (parsed?.unit === unit) { + option.selected = true; + } + } + + const emitGap = () => { + onGapChange?.(index, composeDependencyGap(Number(gapValue.value), gapUnit.value as DependencyGapUnit)); + }; + gapValue.addEventListener("change", emitGap); + gapUnit.addEventListener("change", emitGap); } async function renderResolvedDependency( diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 533b325c2..c69fbbda9 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -75,6 +75,7 @@ import { applyGoogleCalendarRecurringExceptionForScheduledChange, } from "./task-service/googleCalendarRecurringExceptions"; import { + type BlockingRelationshipAction, buildBlockedByTaskUpdate, buildBlockingRelationshipPathChanges, computeBlockedByUpdate, @@ -1464,7 +1465,8 @@ export class TaskService { currentTask: TaskInfo, addedBlockedTaskPaths: string[], removedBlockedTaskPaths: string[], - rawEntries: Record = {} + rawEntries: Record = {}, + modifiedBlockedTaskPaths: string[] = [] ): Promise { // This method is called when the current task's "blocking" list is updated in the UI. // The current task is the one blocking other tasks. @@ -1515,12 +1517,38 @@ export class TaskService { await this.updateTask(blockedTask, updates); } + + // Rewrite the reltype/gap on edges that already exist in the blocked task's blockedBy — + // the canonical location — without touching the set membership. + for (const blockedTaskPath of modifiedBlockedTaskPaths) { + if (uniqueAdditions.includes(blockedTaskPath) || uniqueRemovals.includes(blockedTaskPath)) { + continue; + } + const blockedTask = await this.plugin.cacheManager.getTaskInfo(blockedTaskPath); + if (!blockedTask) { + continue; + } + + const updates = buildBlockedByTaskUpdate( + this.computeBlockedByUpdate( + blockedTask, + currentTask.path, + "modify", + rawEntries[blockedTaskPath] + ) + ); + if (!updates) { + continue; + } + + await this.updateTask(blockedTask, updates); + } } private computeBlockedByUpdate( blockedTask: TaskInfo, blockingTaskPath: string, - action: "add" | "remove", + action: BlockingRelationshipAction, rawEntry?: TaskDependency | string ): TaskDependency[] | null { return computeBlockedByUpdate({ diff --git a/src/services/task-service/taskBlockingRelationships.ts b/src/services/task-service/taskBlockingRelationships.ts index 9f8ba368b..13ce39746 100644 --- a/src/services/task-service/taskBlockingRelationships.ts +++ b/src/services/task-service/taskBlockingRelationships.ts @@ -4,7 +4,7 @@ import { normalizeDependencyEntry, } from "../../utils/dependencyUtils"; -export type BlockingRelationshipAction = "add" | "remove"; +export type BlockingRelationshipAction = "add" | "remove" | "modify"; export interface BlockingRelationshipPathChanges { uniqueAdditions: string[]; @@ -69,6 +69,21 @@ export function computeBlockedByUpdate({ modified = true; continue; } + if (action === "modify") { + const normalizedIncoming = rawEntry ? normalizeDependencyEntry(rawEntry) : null; + if (normalizedIncoming) { + const updated: TaskDependency = { + uid: entry.uid, + reltype: normalizedIncoming.reltype, + }; + if (normalizedIncoming.gap) { + updated.gap = normalizedIncoming.gap; + } + result.push(updated); + modified = true; + continue; + } + } } result.push(entry); } diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index bf4226d09..dc2ecf03e 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -354,6 +354,7 @@ export const DEFAULT_SETTINGS: TaskNotesSettings = { // Relationships widget defaults (unified subtasks, projects, and dependencies) showRelationships: true, relationshipsPosition: "bottom", + enableAdvancedDependencyTypes: false, // Task card in note defaults showTaskCardInNote: true, showCompletedTaskStrikethrough: true, diff --git a/src/types/settings.ts b/src/types/settings.ts index 960391ce5..cde892cfb 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -177,6 +177,8 @@ export interface TaskNotesSettings { // Relationships widget settings (unified subtasks, projects, and dependencies) showRelationships: boolean; relationshipsPosition: "top" | "bottom"; + // Gates the RFC 9253 reltype/gap authoring controls in the task dependency lists. + enableAdvancedDependencyTypes: boolean; // Task card in note settings showTaskCardInNote: boolean; showCompletedTaskStrikethrough: boolean; diff --git a/src/utils/dependencyUtils.ts b/src/utils/dependencyUtils.ts index 23f0a7d5c..2c989bce1 100644 --- a/src/utils/dependencyUtils.ts +++ b/src/utils/dependencyUtils.ts @@ -31,6 +31,43 @@ export function reltypeReleasedByPredecessorFinish(reltype: TaskDependencyRelTyp return reltype === "FINISHTOSTART" || reltype === "FINISHTOFINISH"; } +export type DependencyGapUnit = "hours" | "days" | "weeks"; + +export interface StructuredGap { + value: number; + unit: DependencyGapUnit; +} + +const GAP_UNIT_TOKEN: Record = { + hours: "PT{n}H", + days: "P{n}D", + weeks: "P{n}W", +}; + +// Compose a whole-number offset into the ISO-8601 duration subset this UI authors. +export function composeDependencyGap(value: number, unit: DependencyGapUnit): string | undefined { + if (!Number.isFinite(value) || value <= 0) { + return undefined; + } + return GAP_UNIT_TOKEN[unit].replace("{n}", String(Math.floor(value))); +} + +// Parses only the whole-unit forms this UI composes; an exotic stored gap returns null so +// the caller surfaces it read-only rather than silently rewriting it. +export function parseDependencyGap(gap: string | undefined): StructuredGap | null { + if (!gap) { + return null; + } + const match = /^P(?:T(\d+)H|(\d+)([DW]))$/.exec(gap.trim().toUpperCase()); + if (!match) { + return null; + } + if (match[1]) { + return { value: Number(match[1]), unit: "hours" }; + } + return { value: Number(match[2]), unit: match[3] === "W" ? "weeks" : "days" }; +} + export function extractDependencyUid(entry: unknown): string { if (typeof entry === "string") { return entry; diff --git a/styles/task-modal.css b/styles/task-modal.css index 1135c7770..0a4405371 100644 --- a/styles/task-modal.css +++ b/styles/task-modal.css @@ -1503,6 +1503,77 @@ body.is-mobile .tasknotes-plugin .task-project-item--task-card .task-project-rem margin-top: var(--size-4-2); } +.tasknotes-plugin .task-project-item--with-reltype { + flex-wrap: wrap; +} + +.tasknotes-plugin .task-dependency-controls { + flex: 1 0 100%; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--size-4-2) var(--size-4-3); + padding-top: var(--size-4-2); +} + +.tasknotes-plugin .task-dependency-field { + display: flex; + align-items: center; + gap: var(--size-4-1); +} + +.tasknotes-plugin .task-dependency-field-label { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.tasknotes-plugin .task-dependency-gap-value { + width: 6ch; +} + +.tasknotes-plugin .task-dependency-summary { + flex: 1 0 100%; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + font-style: italic; +} + +.tasknotes-plugin .task-dependency-gap-exotic { + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} + +.tasknotes-plugin .task-project-item--with-reltype.task-project-item--task-card + .task-project-remove { + margin-top: 0; +} + +@media (max-width: 480px) { + .tasknotes-plugin .task-dependency-controls { + flex-direction: column; + align-items: stretch; + } + + .tasknotes-plugin .task-dependency-field { + justify-content: space-between; + } + + .tasknotes-plugin .task-dependency-gap-value { + width: auto; + flex: 1; + } +} + +body.is-mobile .tasknotes-plugin .task-dependency-controls { + flex-direction: column; + align-items: stretch; +} + +body.is-mobile .tasknotes-plugin .task-dependency-gap-value { + width: auto; + flex: 1; +} + .tasknotes-plugin .task-project-info { flex: 1; min-width: 0; diff --git a/tests/unit/modals/taskEditChangeState.test.ts b/tests/unit/modals/taskEditChangeState.test.ts index c698c34d2..af7db8761 100644 --- a/tests/unit/modals/taskEditChangeState.test.ts +++ b/tests/unit/modals/taskEditChangeState.test.ts @@ -168,7 +168,7 @@ describe("taskEditChangeState", () => { review: "new", }); expect(result.changes.dateModified).toEqual(expect.any(String)); - expect(result.blockingUpdates).toEqual({ added: [], removed: [], raw: {} }); + expect(result.blockingUpdates).toEqual({ added: [], removed: [], modified: [], raw: {} }); expect(result.unresolvedBlockingEntries).toEqual([]); }); diff --git a/tests/unit/modals/taskModalReltypeControl.bothsides.test.ts b/tests/unit/modals/taskModalReltypeControl.bothsides.test.ts new file mode 100644 index 000000000..7a83a456b --- /dev/null +++ b/tests/unit/modals/taskModalReltypeControl.bothsides.test.ts @@ -0,0 +1,101 @@ +import type TaskNotesPlugin from "../../../src/main"; +import type { TaskDependencyRelType } from "../../../src/types"; +import { + type DependencyItem, + type DependencyListSide, + renderDependencyList, +} from "../../../src/modals/taskModalDependencies"; +import type { LinkServices } from "../../../src/ui/renderers/linkRenderer"; + +function unresolvedItem(uid: string, reltype: TaskDependencyRelType, gap?: string): DependencyItem { + return { dependency: gap ? { uid, reltype, gap } : { uid, reltype }, name: uid, unresolved: true }; +} + +const translate = (key: string, params?: Record): string => + params ? `${key}|self=${params.self}|other=${params.other}` : key; + +async function render( + side: DependencyListSide, + items: DependencyItem[], + overrides: Partial[0]> = {} +): Promise { + const listEl = document.createElement("div"); + await renderDependencyList({ + plugin: {} as TaskNotesPlugin, + listEl, + items, + linkServices: {} as LinkServices, + translate, + onRemove: jest.fn(), + side, + selfName: "This Task", + showReltypeControls: true, + ...overrides, + }); + return listEl; +} + +describe("dependency reltype controls on both sides (U6)", () => { + it("renders a 4-option reltype dropdown reflecting the stored type", async () => { + const listEl = await render("blocked-by", [unresolvedItem("[[Pred]]", "STARTTOSTART")]); + const select = listEl.querySelector("select.task-dependency-reltype"); + expect(select).not.toBeNull(); + expect(select?.options.length).toBe(4); + expect(select?.value).toBe("STARTTOSTART"); + }); + + it("names both concrete tasks in the blocked-by directional summary", async () => { + const listEl = await render("blocked-by", [unresolvedItem("[[Pred]]", "STARTTOSTART")]); + const summary = listEl.querySelector(".task-dependency-summary"); + expect(summary?.textContent).toContain( + "modals.task.dependencies.summary.blockedBy.startToStart" + ); + expect(summary?.textContent).toContain("self=This Task"); + expect(summary?.textContent).toContain("other=[[Pred]]"); + }); + + it("uses the reversed blocking-side summary template", async () => { + const listEl = await render("blocking", [unresolvedItem("[[Succ]]", "FINISHTOSTART")]); + const summary = listEl.querySelector(".task-dependency-summary"); + expect(summary?.textContent).toContain( + "modals.task.dependencies.summary.blocking.finishToStart" + ); + }); + + it("emits the picked reltype", async () => { + const onReltypeChange = jest.fn(); + const listEl = await render("blocked-by", [unresolvedItem("[[Pred]]", "FINISHTOSTART")], { + onReltypeChange, + }); + const select = listEl.querySelector("select.task-dependency-reltype"); + select!.value = "FINISHTOFINISH"; + select!.dispatchEvent(new Event("change")); + expect(onReltypeChange).toHaveBeenCalledWith(0, "FINISHTOFINISH"); + }); + + it("composes the gap into ISO-8601 on change", async () => { + const onGapChange = jest.fn(); + const listEl = await render("blocked-by", [unresolvedItem("[[Pred]]", "FINISHTOSTART")], { + onGapChange, + }); + const value = listEl.querySelector("input.task-dependency-gap-value"); + value!.value = "2"; + value!.dispatchEvent(new Event("change")); + expect(onGapChange).toHaveBeenCalledWith(0, "P2D"); + }); + + it("shows an exotic stored gap read-only", async () => { + const listEl = await render("blocked-by", [ + unresolvedItem("[[Pred]]", "FINISHTOSTART", "P1DT2H"), + ]); + expect(listEl.querySelector(".task-dependency-gap-exotic")).not.toBeNull(); + expect(listEl.querySelector("input.task-dependency-gap-value")).toBeNull(); + }); + + it("omits all controls when the advanced-types flag is off", async () => { + const listEl = await render("blocked-by", [unresolvedItem("[[Pred]]", "FINISHTOSTART")], { + showReltypeControls: false, + }); + expect(listEl.querySelector(".task-dependency-controls")).toBeNull(); + }); +}); diff --git a/tests/unit/services/taskBlockingRelationships.test.ts b/tests/unit/services/taskBlockingRelationships.test.ts index 8e9adff76..fcb109597 100644 --- a/tests/unit/services/taskBlockingRelationships.test.ts +++ b/tests/unit/services/taskBlockingRelationships.test.ts @@ -164,4 +164,74 @@ describe("taskBlockingRelationships", () => { { uid: "[[TaskNotes/Blocker.md]]", reltype: "FINISHTOSTART" }, ]); }); + + it("modifies an existing edge's reltype and gap while preserving its uid and siblings", () => { + const context = createContext(); + context.resolvedPaths.set( + "TaskNotes/Blocked task.md:TaskNotes/Blocker.md", + "TaskNotes/Blocker.md" + ); + + const updated = computeBlockedByUpdate({ + blockedTask: createTask({ + blockedBy: [ + { uid: "TaskNotes/Blocker.md", reltype: "FINISHTOSTART" }, + { uid: "TaskNotes/Other.md", reltype: "STARTTOSTART" }, + ], + }), + blockingTaskPath: "TaskNotes/Blocker.md", + action: "modify", + rawEntry: { uid: "Ignored source-relative uid", reltype: "STARTTOSTART", gap: "P3D" }, + resolveDependencyPath: context.resolveDependencyPath, + formatDependencyLink: context.formatDependencyLink, + }); + + expect(updated).toEqual([ + { uid: "TaskNotes/Blocker.md", reltype: "STARTTOSTART", gap: "P3D" }, + { uid: "TaskNotes/Other.md", reltype: "STARTTOSTART" }, + ]); + expect(context.formatDependencyLink).not.toHaveBeenCalled(); + }); + + it("modify drops a gap that the new entry omits", () => { + const context = createContext(); + context.resolvedPaths.set( + "TaskNotes/Blocked task.md:TaskNotes/Blocker.md", + "TaskNotes/Blocker.md" + ); + + const updated = computeBlockedByUpdate({ + blockedTask: createTask({ + blockedBy: [{ uid: "TaskNotes/Blocker.md", reltype: "STARTTOSTART", gap: "P1D" }], + }), + blockingTaskPath: "TaskNotes/Blocker.md", + action: "modify", + rawEntry: { uid: "x", reltype: "FINISHTOSTART" }, + resolveDependencyPath: context.resolveDependencyPath, + formatDependencyLink: context.formatDependencyLink, + }); + + expect(updated).toEqual([{ uid: "TaskNotes/Blocker.md", reltype: "FINISHTOSTART" }]); + }); + + it("modify returns null when the edge is absent (no membership change)", () => { + const context = createContext(); + context.resolvedPaths.set( + "TaskNotes/Blocked task.md:TaskNotes/Other.md", + "TaskNotes/Other.md" + ); + + const updated = computeBlockedByUpdate({ + blockedTask: createTask({ + blockedBy: [{ uid: "TaskNotes/Other.md", reltype: "FINISHTOSTART" }], + }), + blockingTaskPath: "TaskNotes/Blocker.md", + action: "modify", + rawEntry: { uid: "x", reltype: "STARTTOSTART" }, + resolveDependencyPath: context.resolveDependencyPath, + formatDependencyLink: context.formatDependencyLink, + }); + + expect(updated).toBeNull(); + }); }); diff --git a/tests/unit/utils/dependencyGap.test.ts b/tests/unit/utils/dependencyGap.test.ts new file mode 100644 index 000000000..beb86ab9c --- /dev/null +++ b/tests/unit/utils/dependencyGap.test.ts @@ -0,0 +1,32 @@ +import { composeDependencyGap, parseDependencyGap } from "../../../src/utils/dependencyUtils"; + +describe("dependency gap compose/parse (U6)", () => { + it("composes whole-unit ISO-8601 durations", () => { + expect(composeDependencyGap(3, "hours")).toBe("PT3H"); + expect(composeDependencyGap(2, "days")).toBe("P2D"); + expect(composeDependencyGap(1, "weeks")).toBe("P1W"); + }); + + it("omits a non-positive or non-finite value", () => { + expect(composeDependencyGap(0, "days")).toBeUndefined(); + expect(composeDependencyGap(-1, "days")).toBeUndefined(); + expect(composeDependencyGap(Number.NaN, "days")).toBeUndefined(); + }); + + it("floors a fractional value", () => { + expect(composeDependencyGap(2.9, "days")).toBe("P2D"); + }); + + it("round-trips the forms it composes", () => { + expect(parseDependencyGap("PT3H")).toEqual({ value: 3, unit: "hours" }); + expect(parseDependencyGap("P2D")).toEqual({ value: 2, unit: "days" }); + expect(parseDependencyGap("P1W")).toEqual({ value: 1, unit: "weeks" }); + }); + + it("returns null for an exotic, mixed, or empty gap so the UI leaves it read-only", () => { + expect(parseDependencyGap(undefined)).toBeNull(); + expect(parseDependencyGap("")).toBeNull(); + expect(parseDependencyGap("P1DT2H")).toBeNull(); + expect(parseDependencyGap("P1M")).toBeNull(); + }); +}); From 5ea7fbeaf2cc90e67fcd1b4513b069138fbc7b5d Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 01:19:10 +1200 Subject: [PATCH 10/24] feat(settings): add the advanced dependency types opt-in to Features Surface a toggle for enableAdvancedDependencyTypes under a new Dependencies group in the Features tab, mirroring the existing feature-toggle pattern. Off by default; turning it on reveals the reltype/lag authoring controls in the task edit modal. --- i18n.manifest.json | 4 ++++ i18n.state.json | 32 ++++++++++++++++++++++++++++++++ src/i18n/resources/en.ts | 9 +++++++++ src/settings/tabs/featuresTab.ts | 23 +++++++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/i18n.manifest.json b/i18n.manifest.json index c4c4273cf..4725e6ee6 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -347,6 +347,10 @@ "settings.tabs.appearance": "1576acbe8d999f289f587cca610d867affd66953", "settings.tabs.features": "fc338f87a058158eb824b53705961801516a9460", "settings.tabs.integrations": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", + "settings.features.dependencies.header": "0562f32dc56f5c702810cbe010068ddd38dbd69a", + "settings.features.dependencies.description": "0d9169b5400763b70fbf5d6d6e353ea379b03255", + "settings.features.dependencies.advancedTypes.name": "f66818c3d60bc0e0af07f76a294316490648fdd8", + "settings.features.dependencies.advancedTypes.description": "c47eb9e8b41785e03257c949975b9ce7c66d9ca8", "settings.features.inlineTasks.header": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "settings.features.inlineTasks.description": "041baf632682261876d68fdfb557dcb820eeb505", "settings.features.taskCreation.header": "ec5845a572a9d5ae23130b55f1113f7bc2854e96", diff --git a/i18n.state.json b/i18n.state.json index 5cf2bad88..df3188515 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -1392,6 +1392,10 @@ "source": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", "translation": "a5126e7c9412cef1a03d744ebed395eb7214db26" }, + "settings.features.dependencies.header": null, + "settings.features.dependencies.description": null, + "settings.features.dependencies.advancedTypes.name": null, + "settings.features.dependencies.advancedTypes.description": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "f98acefe1cdc9c888903a42f94689b0e6d7bef41" @@ -10279,6 +10283,10 @@ "source": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", "translation": "6082310864faed1ba917afb4164aae29fb80803a" }, + "settings.features.dependencies.header": null, + "settings.features.dependencies.description": null, + "settings.features.dependencies.advancedTypes.name": null, + "settings.features.dependencies.advancedTypes.description": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "a7f016a7bf869a4faec082a631841e0f359e46c2" @@ -19166,6 +19174,10 @@ "source": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", "translation": "4f90e5966c8332bc8de5124dbb9ca107372bb513" }, + "settings.features.dependencies.header": null, + "settings.features.dependencies.description": null, + "settings.features.dependencies.advancedTypes.name": null, + "settings.features.dependencies.advancedTypes.description": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "a32a664acc6d5279c6600620c1a3fb0a8bb8cd1b" @@ -28053,6 +28065,10 @@ "source": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", "translation": "3fdb3a4c402e9c819b75361b4849b530f25dfb41" }, + "settings.features.dependencies.header": null, + "settings.features.dependencies.description": null, + "settings.features.dependencies.advancedTypes.name": null, + "settings.features.dependencies.advancedTypes.description": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "7277ee93a6bc575b91eacbff0045075f71fa9b22" @@ -36940,6 +36956,10 @@ "source": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", "translation": "892f1c0be159b891166ca6b5d3ad9923e0888b10" }, + "settings.features.dependencies.header": null, + "settings.features.dependencies.description": null, + "settings.features.dependencies.advancedTypes.name": null, + "settings.features.dependencies.advancedTypes.description": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "1e85b7120ae8c808302b3a235b6f91916bbf7de8" @@ -45827,6 +45847,10 @@ "source": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", "translation": "935a73f2095899b2cc1730e3257e5c96e4e35b5c" }, + "settings.features.dependencies.header": null, + "settings.features.dependencies.description": null, + "settings.features.dependencies.advancedTypes.name": null, + "settings.features.dependencies.advancedTypes.description": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "e4840351a15b65cb43b38b53ce73a1b976f8993f" @@ -54714,6 +54738,10 @@ "source": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", "translation": "55ca8fac219f5e28fd59d8bb5fbf076eb08046d3" }, + "settings.features.dependencies.header": null, + "settings.features.dependencies.description": null, + "settings.features.dependencies.advancedTypes.name": null, + "settings.features.dependencies.advancedTypes.description": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "ed325e2a0e049939e66b927ab4ad76ff76319842" @@ -63601,6 +63629,10 @@ "source": "a7881cac6e64abca5eafd172df6eb31fe0b84c88", "translation": "2a213ace35b25b9171b9198828a4d3151a85f77f" }, + "settings.features.dependencies.header": null, + "settings.features.dependencies.description": null, + "settings.features.dependencies.advancedTypes.name": null, + "settings.features.dependencies.advancedTypes.description": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "29a6c4edbeac9373f6e112eee10387b97be35a25" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index a4457d26b..6da3011a1 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -513,6 +513,15 @@ export const en: TranslationTree = { integrations: "Integrations", }, features: { + dependencies: { + header: "Dependencies", + description: "Task dependency relationship settings.", + advancedTypes: { + name: "Advanced dependency types", + description: + "Choose a relationship type (finish-to-start, start-to-start, finish-to-finish, start-to-finish) and an optional lag for each dependency in the task edit modal. When off, dependencies use finish-to-start.", + }, + }, inlineTasks: { header: "Inline tasks", description: "Settings for task links and checkbox-to-task conversion in notes.", diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index 8de3be064..551c9af03 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -983,6 +983,29 @@ export function renderFeaturesTab( } ); + // Dependencies Section + createSettingGroup( + container, + { + heading: translate("settings.features.dependencies.header"), + description: translate("settings.features.dependencies.description"), + }, + (group) => { + group.addSetting( + (setting) => + void configureToggleSetting(setting, { + name: translate("settings.features.dependencies.advancedTypes.name"), + desc: translate("settings.features.dependencies.advancedTypes.description"), + getValue: () => plugin.settings.enableAdvancedDependencyTypes, + setValue: async (value: boolean) => { + plugin.settings.enableAdvancedDependencyTypes = value; + save(); + }, + }) + ); + } + ); + // Debug Logging Section createSettingGroup( container, From 3c112e849fd0e6b2f1be5a3aa8a305a742ed0ae0 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 01:25:17 +1200 Subject: [PATCH 11/24] feat(bases): reflect per-endpoint blocking in rendered views; document filter limit TaskNotes-rendered Bases views (Task List, Kanban, cards) already carry the computed start/finish-blocked state from the cache. Add coverage for those fields, and document why a native .base filter on blocked-by stays existence-only: a filter formula evaluates one note's frontmatter and cannot read a predecessor's status, so it cannot tell a released edge from a live one. Update the dependencies docs to describe per-endpoint blocking and point users to a TaskNotes-rendered view for constraint- aware display. --- docs/features/task-management.md | 6 +- src/services/BasesFilterConverter.ts | 5 +- .../unit/bases/basesConstraintFields.test.ts | 80 +++++++++++++++++++ 3 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 tests/unit/bases/basesConstraintFields.test.ts diff --git a/docs/features/task-management.md b/docs/features/task-management.md index 3a21b5ff3..e9e39885b 100644 --- a/docs/features/task-management.md +++ b/docs/features/task-management.md @@ -153,7 +153,11 @@ Whenever a dependency is added, TaskNotes updates the upstream note’s `blockin - The task context menu provides the same selector, enabling dependency management directly from the Task List, Kanban, and calendar views. - Task cards show a fork icon whenever a task blocks other work. Clicking it expands an inline list of downstream tasks without triggering the parent card’s modal, so you can inspect dependents in place. -These controls currently create and manage finish-to-start style blockers. Advanced `reltype` values and `gap` data are preserved in frontmatter, but blocking evaluation is currently based on whether unresolved dependencies exist rather than relationship-type-specific scheduling rules. +By default, dependencies use finish-to-start. Enable **Features → Dependencies → Advanced dependency types** to choose a relationship type and an optional lag per dependency from both the "Blocked by" and "Blocking" lists in the edit modal. + +Blocking is evaluated per endpoint: finish-to-start and start-to-start gate a task's *start*, while finish-to-finish and start-to-finish gate its *finish*. An edge releases as soon as the predecessor reaches the point the relationship requires — started for a start-to-\* edge, completed for a finish-to-\* edge — so a task reads "Blocked · start" or "Blocked · finish" only while it is genuinely constrained. + +TaskNotes-rendered views (Task List, Kanban, cards) reflect this computed state. A native `.base` filter on "blocked by", however, matches by existence only: a Bases filter formula evaluates a single note's frontmatter and cannot read a predecessor's status, so it cannot distinguish a released edge from a live one. Use a TaskNotes-rendered view when you need constraint-aware blocking. ![Task context menu](../assets/feature-task-context-menu.png) diff --git a/src/services/BasesFilterConverter.ts b/src/services/BasesFilterConverter.ts index 5e3b3952e..c932d56a4 100644 --- a/src/services/BasesFilterConverter.ts +++ b/src/services/BasesFilterConverter.ts @@ -226,8 +226,9 @@ export class BasesFilterConverter { } /** - * Convert dependencies.isBlocked to Bases expression - * A task is blocked if it has any entries in its blockedBy array + * Blocked-by native filter, existence-only: a `.base` filter compiles to a per-note + * formula that can't reach a predecessor's status, so it can't distinguish a released + * edge from a live one — constraint-aware blocking needs a TaskNotes-rendered view. */ private convertIsBlockedCondition(operator: FilterOperator): string { const fm = this.plugin.fieldMapper; diff --git a/tests/unit/bases/basesConstraintFields.test.ts b/tests/unit/bases/basesConstraintFields.test.ts new file mode 100644 index 000000000..fa4b794d1 --- /dev/null +++ b/tests/unit/bases/basesConstraintFields.test.ts @@ -0,0 +1,80 @@ +import { createTaskInfoFromBasesData } from "../../../src/bases/helpers"; +import type TaskNotesPlugin from "../../../src/main"; +import { FieldMapper } from "../../../src/services/FieldMapper"; +import { DEFAULT_FIELD_MAPPING } from "../../../src/settings/defaults"; + +function createPlugin(dependencyCache?: unknown): TaskNotesPlugin { + return { + fieldMapper: new FieldMapper({ ...DEFAULT_FIELD_MAPPING }), + settings: { defaultTaskStatus: "open", storeTitleInFilename: false }, + dependencyCache, + cacheManager: { getCachedTaskInfoSync: jest.fn() }, + } as unknown as TaskNotesPlugin; +} + +describe("Bases items carry per-endpoint constraint state (U8)", () => { + it("carries start/finish-blocked from the dependency cache", () => { + const cache = { + isTaskStartBlocked: jest.fn(() => true), + isTaskFinishBlocked: jest.fn(() => false), + getBlockedTaskPaths: jest.fn(() => []), + getStartBlockedDependentPaths: jest.fn(() => []), + getFinishBlockedDependentPaths: jest.fn(() => []), + }; + + const task = createTaskInfoFromBasesData( + { + path: "Tasks/Dep.md", + name: "Dep", + properties: { + title: "Dep", + status: "open", + blockedBy: [{ uid: "[[Pred]]", reltype: "STARTTOSTART" }], + }, + }, + createPlugin(cache) + ); + + expect(task?.startBlocked).toBe(true); + expect(task?.finishBlocked).toBe(false); + expect(task?.isBlocked).toBe(true); + expect(cache.isTaskStartBlocked).toHaveBeenCalledWith("Tasks/Dep.md"); + }); + + it("marks the reverse (blocking) endpoints from the cache", () => { + const cache = { + isTaskStartBlocked: jest.fn(() => false), + isTaskFinishBlocked: jest.fn(() => false), + getBlockedTaskPaths: jest.fn(() => ["Tasks/Succ.md"]), + getStartBlockedDependentPaths: jest.fn(() => ["Tasks/Succ.md"]), + getFinishBlockedDependentPaths: jest.fn(() => []), + }; + + const task = createTaskInfoFromBasesData( + { path: "Tasks/Pred.md", name: "Pred", properties: { title: "Pred", status: "open" } }, + createPlugin(cache) + ); + + expect(task?.isBlockingStart).toBe(true); + expect(task?.isBlockingFinish).toBe(false); + expect(task?.isBlocking).toBe(true); + }); + + it("falls back to existence-based endpoint blocking when the cache is absent", () => { + const task = createTaskInfoFromBasesData( + { + path: "Tasks/Dep.md", + name: "Dep", + properties: { + title: "Dep", + status: "open", + blockedBy: [{ uid: "[[Pred]]", reltype: "FINISHTOFINISH" }], + }, + }, + createPlugin(undefined) + ); + + expect(task?.startBlocked).toBe(false); + expect(task?.finishBlocked).toBe(true); + }); +}); From 5caf3d2bec752e09cde429e661577dfd7db5a4f3 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 01:56:07 +1200 Subject: [PATCH 12/24] fix(review): harden dependency edge cases from the branch review - composeDependencyGap: reject a sub-1 value (was composing a "P0D" no-op) and an absurd value that would serialize in scientific notation; the lag input gains step/max so fractional entry is prevented at the source. - StatusManager.isStarted: treat a completed status as started via isCompletedStatus, robust to a status config that was not normalized. - Extract deriveBlockingState so the TaskManager and Bases assembly sites share one per-endpoint derivation instead of duplicating it verbatim. - A broken-link (unresolved) blocker now holds the card pessimistically blocked rather than reading as a satisfied "Dependencies" relationship. - Note the last-wins reltype for a hand-authored duplicate edge. Tests: blocking-edge modify detection in the edit-change diff, reverse accessors dropping a released successor, gap edge cases, and the unresolved-blocker state. Changelog: flag that a custom in-progress status must be categorized Started to release start-based dependencies. --- docs/releases/unreleased.md | 2 +- src/bases/helpers.ts | 30 ++------ src/modals/taskModalDependencies.ts | 2 + src/services/StatusManager.ts | 6 +- src/types.ts | 2 +- src/ui/taskCardRelationships.ts | 16 ++++- src/utils/DependencyCache.ts | 2 + src/utils/TaskManager.ts | 29 ++------ src/utils/dependencyUtils.ts | 49 ++++++++++++- tests/unit/modals/taskEditChangeState.test.ts | 70 +++++++++++++++++++ ...askCardSecondaryBadges.constraints.test.ts | 22 +++++- .../utils/DependencyCache.constraints.test.ts | 12 ++++ tests/unit/utils/dependencyGap.test.ts | 11 ++- 13 files changed, 197 insertions(+), 56 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 9364a04c3..9669ad73d 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -39,4 +39,4 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Changed -- Task statuses now have a lifecycle category — Not started, Started, or Completed — shown as a pill in the status settings, replacing the previous "Completed" toggle. Existing completed statuses stay Completed; the rest default to Not started. +- Task statuses now have a lifecycle category — Not started, Started, or Completed — shown as a pill in the status settings, replacing the previous "Completed" toggle. Existing completed statuses stay Completed; the rest default to Not started. If you keep a custom "in progress" status, set its category to **Started** in Settings → Task Properties so that start-based dependencies (start-to-start / start-to-finish) release when it begins — until then it is treated as Not started. diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index 794729721..87bd999b6 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -10,8 +10,7 @@ import type { TaskCardOptions } from "../ui/TaskCard"; import { PropertyMappingService } from "./PropertyMappingService"; import { normalizeDependencyList, - reltypeConstrainsStart, - reltypeConstrainsFinish, + deriveBlockingState, } from "../utils/dependencyUtils"; import { stringifyUnknown, stringifyUnknownArray } from "../utils/stringUtils"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; @@ -220,27 +219,12 @@ function createTaskInfoFromProperties( const timeEntries = toTimeEntries(props.timeEntries); const totalTrackedTime = timeEntries ? calculateTotalTimeSpent(timeEntries) : 0; - // Get dependency information from DependencyCache if plugin is available - let startBlocked = false; - let finishBlocked = false; - let blockingTasks: string[] = []; - let isBlockingStart = false; - let isBlockingFinish = false; - if (plugin?.dependencyCache && basesItem.path) { - // Status-aware, per-endpoint blocking state from the cache - startBlocked = plugin.dependencyCache.isTaskStartBlocked(basesItem.path); - finishBlocked = plugin.dependencyCache.isTaskFinishBlocked(basesItem.path); - blockingTasks = plugin.dependencyCache.getBlockedTaskPaths(basesItem.path); - isBlockingStart = - plugin.dependencyCache.getStartBlockedDependentPaths(basesItem.path).length > 0; - isBlockingFinish = - plugin.dependencyCache.getFinishBlockedDependentPaths(basesItem.path).length > 0; - } else { - // Cache-absent fallback: existence-based, from this task's own edge reltypes. - const edges = normalizeDependencyList(props.blockedBy) ?? []; - startBlocked = edges.some((edge) => reltypeConstrainsStart(edge.reltype)); - finishBlocked = edges.some((edge) => reltypeConstrainsFinish(edge.reltype)); - } + const { startBlocked, finishBlocked, blockingTasks, isBlockingStart, isBlockingFinish } = + deriveBlockingState( + plugin?.dependencyCache, + basesItem.path ?? "", + normalizeDependencyList(props.blockedBy) ?? [] + ); const isBlocked = startBlocked || finishBlocked; const isBlocking = blockingTasks.length > 0; diff --git a/src/modals/taskModalDependencies.ts b/src/modals/taskModalDependencies.ts index dd06168ce..4b945a2e1 100644 --- a/src/modals/taskModalDependencies.ts +++ b/src/modals/taskModalDependencies.ts @@ -359,6 +359,8 @@ function renderGapField( type: "number", }); gapValue.min = "0"; + gapValue.max = "100000"; + gapValue.step = "1"; gapValue.placeholder = translate("modals.task.dependencies.gap.placeholder"); if (parsed) { gapValue.value = String(parsed.value); diff --git a/src/services/StatusManager.ts b/src/services/StatusManager.ts index 3183efd22..a1a83d6e6 100644 --- a/src/services/StatusManager.ts +++ b/src/services/StatusManager.ts @@ -146,8 +146,10 @@ export class StatusManager { * STARTTOFINISH edges. A Not-started (planned) status is not started. */ isStarted(statusValue: string): boolean { - const category = this.getCategory(statusValue); - return category === "in-progress" || category === "completed"; + if (this.isCompletedStatus(statusValue)) { + return true; + } + return this.getCategory(statusValue) === "in-progress"; } /** diff --git a/src/types.ts b/src/types.ts index da269ccb8..8199913a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -490,7 +490,7 @@ export interface TaskInfo { basesData?: unknown; // Raw Bases data for formula computation (internal use) blockedBy?: TaskDependency[]; // Dependencies that must be satisfied before this task can start blocking?: string[]; // Task paths that this task is blocking - isBlocked?: boolean; // True if startBlocked || finishBlocked + isBlocked?: boolean; isBlocking?: boolean; // True if this task blocks at least one other task startBlocked?: boolean; // Cannot start: an FS predecessor unfinished or an SS predecessor unstarted finishBlocked?: boolean; // Cannot finish: an FF predecessor unfinished or an SF predecessor unstarted diff --git a/src/ui/taskCardRelationships.ts b/src/ui/taskCardRelationships.ts index f314dc044..cd7234d1a 100644 --- a/src/ui/taskCardRelationships.ts +++ b/src/ui/taskCardRelationships.ts @@ -107,6 +107,14 @@ export function getBlockedByTaskPaths(task: TaskInfo, app: App): string[] { return Array.from(paths); } +export function hasUnresolvedBlockedBy(task: TaskInfo, app: App): boolean { + const entries = Array.isArray(task.blockedBy) ? task.blockedBy : []; + return entries.some((entry) => { + const normalized = normalizeDependencyEntry(entry); + return normalized !== null && !resolveDependencyEntry(app, task.path, normalized); + }); +} + export interface DependencyConstraintSource { getStartBlockingPredecessorPaths(taskPath: string): string[]; getFinishBlockingPredecessorPaths(taskPath: string): string[]; @@ -133,8 +141,8 @@ export interface BlockedConstraintInfo { count: number; } -// Start wins over finish (an un-startable task is implicitly un-finishable), and the -// count excludes released edges: a satisfied dependency is listed but not counted. +// Start wins over finish (an un-startable task is implicitly un-finishable), and the count +// excludes released edges: a satisfied dependency is listed but not counted. export function resolveBlockedConstraint( task: TaskInfo, app: App, @@ -152,5 +160,9 @@ export function resolveBlockedConstraint( const constraining = getFinishBlockingTaskPaths(task, cache).length; return { state: "finish", count: constraining || existence.length }; } + // A broken-link blocker can't be confirmed satisfied — hold it pessimistically blocked. + if (hasUnresolvedBlockedBy(task, app)) { + return { state: "start", count: existence.length }; + } return { state: "released", count: existence.length }; } diff --git a/src/utils/DependencyCache.ts b/src/utils/DependencyCache.ts index 8666d7d08..8ef29dde1 100644 --- a/src/utils/DependencyCache.ts +++ b/src/utils/DependencyCache.ts @@ -350,6 +350,8 @@ export class DependencyCache extends Events { edges = new Map(); this.edgeReltypes.set(dependentPath, edges); } + // One reltype per (dependent, predecessor): a hand-authored duplicate edge to the same + // predecessor keeps the last reltype. The dependency picker prevents duplicates. edges.set(blockingPath, reltype); } diff --git a/src/utils/TaskManager.ts b/src/utils/TaskManager.ts index ab236729a..8718186a7 100644 --- a/src/utils/TaskManager.ts +++ b/src/utils/TaskManager.ts @@ -7,7 +7,7 @@ import { TaskNotesSettings } from "../types/settings"; import type { DependencyCache } from "./DependencyCache"; import { isPathInExcludedFolder, parseExcludedFolders } from "./pathExclusions"; import { buildTaskInfoFromMappedTask } from "./taskInfoAssembly"; -import { reltypeConstrainsStart, reltypeConstrainsFinish } from "./dependencyUtils"; +import { deriveBlockingState } from "./dependencyUtils"; import { isTaskFrontmatter } from "./taskIdentification"; import { createTaskNotesLogger } from "./tasknotesLogger"; @@ -332,27 +332,12 @@ export class TaskManager extends Events { this.storeTitleInFilename ); - // Get dependency information from DependencyCache - let startBlocked = false; - let finishBlocked = false; - let blockingTasks: string[] = []; - let isBlockingStart = false; - let isBlockingFinish = false; - if (this._dependencyCache) { - // Status-aware, per-endpoint blocking state from the cache - startBlocked = this._dependencyCache.isTaskStartBlocked(path); - finishBlocked = this._dependencyCache.isTaskFinishBlocked(path); - blockingTasks = this._dependencyCache.getBlockedTaskPaths(path); - isBlockingStart = - this._dependencyCache.getStartBlockedDependentPaths(path).length > 0; - isBlockingFinish = - this._dependencyCache.getFinishBlockedDependentPaths(path).length > 0; - } else { - // Cache-absent fallback: existence-based, from this task's own edge reltypes. - const edges = Array.isArray(mappedTask.blockedBy) ? mappedTask.blockedBy : []; - startBlocked = edges.some((edge) => reltypeConstrainsStart(edge.reltype)); - finishBlocked = edges.some((edge) => reltypeConstrainsFinish(edge.reltype)); - } + const { startBlocked, finishBlocked, blockingTasks, isBlockingStart, isBlockingFinish } = + deriveBlockingState( + this._dependencyCache, + path, + Array.isArray(mappedTask.blockedBy) ? mappedTask.blockedBy : [] + ); return buildTaskInfoFromMappedTask({ path, diff --git a/src/utils/dependencyUtils.ts b/src/utils/dependencyUtils.ts index 2c989bce1..f53d20a82 100644 --- a/src/utils/dependencyUtils.ts +++ b/src/utils/dependencyUtils.ts @@ -31,6 +31,47 @@ export function reltypeReleasedByPredecessorFinish(reltype: TaskDependencyRelTyp return reltype === "FINISHTOSTART" || reltype === "FINISHTOFINISH"; } +export interface BlockingStateSource { + isTaskStartBlocked(taskPath: string): boolean; + isTaskFinishBlocked(taskPath: string): boolean; + getBlockedTaskPaths(taskPath: string): string[]; + getStartBlockedDependentPaths(taskPath: string): string[]; + getFinishBlockedDependentPaths(taskPath: string): string[]; +} + +export interface DerivedBlockingState { + startBlocked: boolean; + finishBlocked: boolean; + blockingTasks: string[]; + isBlockingStart: boolean; + isBlockingFinish: boolean; +} + +// Per-endpoint blocking state for a task: status-aware from the cache when available, else an +// existence-based fallback from the task's own edge reltypes (the fallback has no reverse view). +export function deriveBlockingState( + cache: BlockingStateSource | undefined, + taskPath: string, + blockedByEntries: readonly TaskDependency[] +): DerivedBlockingState { + if (cache && taskPath) { + return { + startBlocked: cache.isTaskStartBlocked(taskPath), + finishBlocked: cache.isTaskFinishBlocked(taskPath), + blockingTasks: cache.getBlockedTaskPaths(taskPath), + isBlockingStart: cache.getStartBlockedDependentPaths(taskPath).length > 0, + isBlockingFinish: cache.getFinishBlockedDependentPaths(taskPath).length > 0, + }; + } + return { + startBlocked: blockedByEntries.some((edge) => reltypeConstrainsStart(edge.reltype)), + finishBlocked: blockedByEntries.some((edge) => reltypeConstrainsFinish(edge.reltype)), + blockingTasks: [], + isBlockingStart: false, + isBlockingFinish: false, + }; +} + export type DependencyGapUnit = "hours" | "days" | "weeks"; export interface StructuredGap { @@ -44,12 +85,14 @@ const GAP_UNIT_TOKEN: Record = { weeks: "P{n}W", }; -// Compose a whole-number offset into the ISO-8601 duration subset this UI authors. +// Compose a whole-number ISO-8601 offset. Floors first (a sub-1 value clears the gap rather +// than composing a "P0D" no-op) and bounds the size (never a scientific-notation token). export function composeDependencyGap(value: number, unit: DependencyGapUnit): string | undefined { - if (!Number.isFinite(value) || value <= 0) { + const whole = Math.floor(value); + if (!Number.isFinite(value) || whole <= 0 || whole > 100000) { return undefined; } - return GAP_UNIT_TOKEN[unit].replace("{n}", String(Math.floor(value))); + return GAP_UNIT_TOKEN[unit].replace("{n}", String(whole)); } // Parses only the whole-unit forms this UI composes; an exotic stored gap returns null so diff --git a/tests/unit/modals/taskEditChangeState.test.ts b/tests/unit/modals/taskEditChangeState.test.ts index af7db8761..235372416 100644 --- a/tests/unit/modals/taskEditChangeState.test.ts +++ b/tests/unit/modals/taskEditChangeState.test.ts @@ -172,6 +172,76 @@ describe("taskEditChangeState", () => { expect(result.unresolvedBlockingEntries).toEqual([]); }); + const blockingSettings = { + userFields: [], + taskIdentificationMethod: "property" as const, + taskTag: "task", + maintainDueDateOffsetInRecurring: false, + }; + + it("classifies a blocking-edge reltype change as a modified edge", () => { + const app = createMockApp(MockObsidian.createMockApp()); + const task = createTask({ + path: "TaskNotes/Edit task.md", + blocking: ["TaskNotes/Successor.md"], + }); + + const result = buildTaskEditChangesFromModalState({ + ...createState({ + task, + blockingItems: [ + { + path: "TaskNotes/Successor.md", + dependency: { uid: "[[TaskNotes/Successor.md]]", reltype: "STARTTOSTART" }, + }, + ], + initialBlockingPaths: ["TaskNotes/Successor.md"], + initialBlockingEntries: { + "TaskNotes/Successor.md": { + uid: "[[TaskNotes/Successor.md]]", + reltype: "FINISHTOSTART", + }, + }, + }), + app, + settings: blockingSettings, + normalizeDetails: (value) => value, + }); + + expect(result.blockingUpdates.modified).toEqual(["TaskNotes/Successor.md"]); + expect(result.blockingUpdates.added).toEqual([]); + expect(result.blockingUpdates.removed).toEqual([]); + expect(result.blockingUpdates.raw["TaskNotes/Successor.md"]).toEqual({ + uid: "[[TaskNotes/Successor.md]]", + reltype: "STARTTOSTART", + }); + }); + + it("excludes a newly-added blocking edge from the modified set", () => { + const app = createMockApp(MockObsidian.createMockApp()); + const task = createTask({ path: "TaskNotes/Edit task.md", blocking: [] }); + + const result = buildTaskEditChangesFromModalState({ + ...createState({ + task, + blockingItems: [ + { + path: "TaskNotes/New.md", + dependency: { uid: "[[TaskNotes/New.md]]", reltype: "STARTTOSTART" }, + }, + ], + initialBlockingPaths: [], + initialBlockingEntries: {}, + }), + app, + settings: blockingSettings, + normalizeDetails: (value) => value, + }); + + expect(result.blockingUpdates.added).toEqual(["TaskNotes/New.md"]); + expect(result.blockingUpdates.modified).toEqual([]); + }); + it("builds skipped recurring instance changes from modal state", () => { const app = createMockApp(MockObsidian.createMockApp()); const task = createTask({ diff --git a/tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts b/tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts index 4e684c253..e903b81ba 100644 --- a/tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts +++ b/tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it } from "@jest/globals"; -import type { App } from "obsidian"; +import { App, TFile } from "obsidian"; import type { TaskDependency, TaskInfo } from "../../../src/types"; import { type DependencyConstraintSource, resolveBlockedConstraint, } from "../../../src/ui/taskCardRelationships"; +// Resolves every blocked-by uid to a real file, so edges count as resolved. const app = { + metadataCache: { getFirstLinkpathDest: (linkpath: string) => new TFile(linkpath) }, + vault: { getAbstractFileByPath: () => null }, +} as unknown as App; + +// Resolves nothing, so every blocked-by edge is a broken link. +const unresolvedApp = { metadataCache: { getFirstLinkpathDest: () => null }, vault: { getAbstractFileByPath: () => null }, } as unknown as App; @@ -89,6 +96,19 @@ describe("resolveBlockedConstraint honest signal (U5)", () => { }); }); + it("holds a broken-link blocker pessimistically start-blocked", () => { + const task = makeTask({ + blockedBy: [edge("[[Missing]]", "STARTTOSTART")], + startBlocked: false, + finishBlocked: false, + isBlocked: false, + }); + expect(resolveBlockedConstraint(task, unresolvedApp, cache([], []))).toEqual({ + state: "start", + count: 1, + }); + }); + it("the count reflects only currently-constraining predecessors, not existence", () => { const task = makeTask({ blockedBy: [edge("Tasks/a.md", "STARTTOSTART"), edge("Tasks/b.md", "STARTTOSTART")], diff --git a/tests/unit/utils/DependencyCache.constraints.test.ts b/tests/unit/utils/DependencyCache.constraints.test.ts index bbf0e21c7..1f78fb584 100644 --- a/tests/unit/utils/DependencyCache.constraints.test.ts +++ b/tests/unit/utils/DependencyCache.constraints.test.ts @@ -141,6 +141,18 @@ describe("DependencyCache per-endpoint constraints (U3)", () => { expect(cache.getFinishBlockedDependentPaths(p("pred"))).toEqual([p("f")]); }); + it("reverse accessors drop a successor once its edge releases", async () => { + // pred started (not completed): the SS edge releases the successor's start, but the FF + // edge still gates the other successor's finish (that one needs completion). + const cache = await buildCache([ + { name: "pred", status: "in-progress" }, + { name: "s", status: "open", blockedBy: [ss("pred")] }, + { name: "f", status: "open", blockedBy: [ff("pred")] }, + ]); + expect(cache.getStartBlockedDependentPaths(p("pred"))).toEqual([]); + expect(cache.getFinishBlockedDependentPaths(p("pred"))).toEqual([p("f")]); + }); + it("FS-only vault: isTaskBlocked matches the today's-blocked set (regression parity)", async () => { const cache = await buildCache([ { name: "p1", status: "open" }, diff --git a/tests/unit/utils/dependencyGap.test.ts b/tests/unit/utils/dependencyGap.test.ts index beb86ab9c..c75a201be 100644 --- a/tests/unit/utils/dependencyGap.test.ts +++ b/tests/unit/utils/dependencyGap.test.ts @@ -11,9 +11,18 @@ describe("dependency gap compose/parse (U6)", () => { expect(composeDependencyGap(0, "days")).toBeUndefined(); expect(composeDependencyGap(-1, "days")).toBeUndefined(); expect(composeDependencyGap(Number.NaN, "days")).toBeUndefined(); + expect(composeDependencyGap(Number.POSITIVE_INFINITY, "days")).toBeUndefined(); }); - it("floors a fractional value", () => { + it("omits a sub-1 value instead of composing a zero-duration no-op", () => { + expect(composeDependencyGap(0.5, "days")).toBeUndefined(); + }); + + it("omits an absurd value that would serialize in scientific notation", () => { + expect(composeDependencyGap(1e21, "days")).toBeUndefined(); + }); + + it("floors a fractional value at or above 1", () => { expect(composeDependencyGap(2.9, "days")).toBe("P2D"); }); From ee33f38314b65557d072ecb4b6206a6eb172ae18 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 08:46:00 +1200 Subject: [PATCH 13/24] fix(task-card): persist blocked-by / blocking expansion across re-renders The blocked-by and blocking relationship lists tracked their expanded state only in the DOM, so any card re-render (a related task update, a view refresh) collapsed them with nothing to restore. Move that state into ExpandedProjectsService alongside the subtasks expansion and re-open the list on render when the service says it was open. --- src/services/ExpandedProjectsService.ts | 42 +++++++++++++-- src/ui/taskCardSecondaryBadges.ts | 49 ++++++++++++++++-- .../services/ExpandedProjectsService.test.ts | 51 +++++++++++++++++++ 3 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 tests/unit/services/ExpandedProjectsService.test.ts diff --git a/src/services/ExpandedProjectsService.ts b/src/services/ExpandedProjectsService.ts index b398b2d1c..99df702e4 100644 --- a/src/services/ExpandedProjectsService.ts +++ b/src/services/ExpandedProjectsService.ts @@ -4,6 +4,8 @@ export class ExpandedProjectsService { private plugin: TaskNotesPlugin; private expandedProjects: Set = new Set(); private collapsedDefaultExpandedProjects: Set = new Set(); + private readonly expandedBlockedBy: Set = new Set(); + private readonly expandedBlocking: Set = new Set(); constructor(plugin: TaskNotesPlugin) { this.plugin = plugin; @@ -69,16 +71,44 @@ export class ExpandedProjectsService { } } + isBlockedByExpanded(taskPath: string): boolean { + return this.expandedBlockedBy.has(taskPath); + } + + setBlockedByExpanded(taskPath: string, expanded: boolean): void { + if (expanded) { + this.expandedBlockedBy.add(taskPath); + } else { + this.expandedBlockedBy.delete(taskPath); + } + } + + isBlockingExpanded(taskPath: string): boolean { + return this.expandedBlocking.has(taskPath); + } + + setBlockingExpanded(taskPath: string, expanded: boolean): void { + if (expanded) { + this.expandedBlocking.add(taskPath); + } else { + this.expandedBlocking.delete(taskPath); + } + } + /** * Preserve expansion when a task file is renamed. */ renamePath(oldPath: string, newPath: string): void { if (oldPath === newPath) return; - if (this.expandedProjects.delete(oldPath)) { - this.expandedProjects.add(newPath); - } - if (this.collapsedDefaultExpandedProjects.delete(oldPath)) { - this.collapsedDefaultExpandedProjects.add(newPath); + for (const set of [ + this.expandedProjects, + this.collapsedDefaultExpandedProjects, + this.expandedBlockedBy, + this.expandedBlocking, + ]) { + if (set.delete(oldPath)) { + set.add(newPath); + } } } @@ -95,6 +125,8 @@ export class ExpandedProjectsService { clearAll(): void { this.expandedProjects.clear(); this.collapsedDefaultExpandedProjects.clear(); + this.expandedBlockedBy.clear(); + this.expandedBlocking.clear(); } /** diff --git a/src/ui/taskCardSecondaryBadges.ts b/src/ui/taskCardSecondaryBadges.ts index 52917ab24..164a5d3fd 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -158,6 +158,7 @@ function createChevronClickHandler( function createBlockingToggleClickHandler( task: TaskInfo, + plugin: TaskNotesPlugin, card: HTMLElement, handlers: TaskCardSecondaryBadgeHandlers ): () => void { @@ -168,6 +169,7 @@ function createBlockingToggleClickHandler( return; } const expanded = toggle.classList.toggle("task-card__blocking-toggle--expanded"); + plugin.expandedProjectsService?.setBlockingExpanded(task.path, expanded); await handlers.toggleBlockingTasks(card, task, expanded); })(); }; @@ -204,9 +206,10 @@ export function syncTaskCardBlockedByExpansionControls( export async function toggleTaskCardBlockedByExpansion( options: ToggleBlockedByExpansionOptions ): Promise { - const { card, task, handlers } = options; + const { card, task, plugin, handlers } = options; const expanded = !card.querySelector(".task-card__blocked-by"); syncTaskCardBlockedByExpansionControls(card, expanded); + plugin.expandedProjectsService?.setBlockedByExpanded(task.path, expanded); await handlers.toggleBlockedByTasks(card, task, expanded); } @@ -308,10 +311,18 @@ function renderDependencyToggles( className: "task-card__blocking-toggle is-visible", icon: "git-branch", tooltip: toggleLabel, - onClick: createBlockingToggleClickHandler(task, card, handlers), + onClick: createBlockingToggleClickHandler(task, plugin, card, handlers), }); if (toggle) { toggle.dataset.count = String(blockingCount); + restoreRelationshipExpansion({ + toggle, + expanded: plugin.expandedProjectsService?.isBlockingExpanded(task.path) ?? false, + expandedClass: "task-card__blocking-toggle--expanded", + render: () => handlers.toggleBlockingTasks(card, task, true), + plugin, + task, + }); } } @@ -327,10 +338,42 @@ function renderDependencyToggles( if (toggle) { toggle.dataset.count = String(blocked.count); toggle.dataset.blockedState = blocked.state; + restoreRelationshipExpansion({ + toggle, + expanded: plugin.expandedProjectsService?.isBlockedByExpanded(task.path) ?? false, + expandedClass: "task-card__blocked-toggle--expanded", + render: () => handlers.toggleBlockedByTasks(card, task, true), + plugin, + task, + }); } } } +// A card re-render rebuilds the toggle fresh, so the on/off state lives in the expansion +// service, not the DOM; re-open the relationship list here when the service says it was open. +function restoreRelationshipExpansion(options: { + toggle: HTMLElement; + expanded: boolean; + expandedClass: string; + render: () => Promise; + plugin: TaskNotesPlugin; + task: TaskInfo; +}): void { + if (!options.expanded) { + return; + } + options.toggle.classList.add(options.expandedClass); + options.render().catch((error: unknown) => { + getTaskCardBadgeLogger(options.plugin).error("Error restoring relationship expansion", { + category: "internal", + operation: "restore-relationship-expansion", + details: { taskPath: options.task.path }, + error, + }); + }); +} + export function renderTaskCardSecondaryBadges( options: RenderTaskCardSecondaryBadgesOptions ): void { @@ -459,7 +502,7 @@ function updateBlockingToggle(options: UpdateTaskCardSecondaryBadgesOptions): vo className: "task-card__blocking-toggle is-visible", icon: "git-branch", tooltip: toggleLabel, - onClick: createBlockingToggleClickHandler(task, card, handlers), + onClick: createBlockingToggleClickHandler(task, plugin, card, handlers), }); if (!shouldExist) { diff --git a/tests/unit/services/ExpandedProjectsService.test.ts b/tests/unit/services/ExpandedProjectsService.test.ts new file mode 100644 index 000000000..33cfe7c3d --- /dev/null +++ b/tests/unit/services/ExpandedProjectsService.test.ts @@ -0,0 +1,51 @@ +import { ExpandedProjectsService } from "../../../src/services/ExpandedProjectsService"; +import type TaskNotesPlugin from "../../../src/main"; + +function createService(): ExpandedProjectsService { + return new ExpandedProjectsService({} as TaskNotesPlugin); +} + +describe("ExpandedProjectsService relationship expansion state", () => { + it("tracks blocked-by expansion independently of blocking and subtasks", () => { + const svc = createService(); + expect(svc.isBlockedByExpanded("a.md")).toBe(false); + + svc.setBlockedByExpanded("a.md", true); + expect(svc.isBlockedByExpanded("a.md")).toBe(true); + expect(svc.isBlockingExpanded("a.md")).toBe(false); + expect(svc.isExpanded("a.md")).toBe(false); + + svc.setBlockedByExpanded("a.md", false); + expect(svc.isBlockedByExpanded("a.md")).toBe(false); + }); + + it("tracks blocking expansion independently of blocked-by", () => { + const svc = createService(); + svc.setBlockingExpanded("b.md", true); + expect(svc.isBlockingExpanded("b.md")).toBe(true); + expect(svc.isBlockedByExpanded("b.md")).toBe(false); + }); + + it("preserves relationship expansion across a rename", () => { + const svc = createService(); + svc.setBlockedByExpanded("old.md", true); + svc.setBlockingExpanded("old.md", true); + + svc.renamePath("old.md", "new.md"); + + expect(svc.isBlockedByExpanded("new.md")).toBe(true); + expect(svc.isBlockingExpanded("new.md")).toBe(true); + expect(svc.isBlockedByExpanded("old.md")).toBe(false); + }); + + it("clearAll resets relationship expansion", () => { + const svc = createService(); + svc.setBlockedByExpanded("a.md", true); + svc.setBlockingExpanded("a.md", true); + + svc.clearAll(); + + expect(svc.isBlockedByExpanded("a.md")).toBe(false); + expect(svc.isBlockingExpanded("a.md")).toBe(false); + }); +}); From 8eb7de5d95da60ba50cfe7906f7bd67237dfd7fd Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 09:08:45 +1200 Subject: [PATCH 14/24] feat(dependencies): status-readiness guidance for advanced dependency types Non-gating guidance so users configure statuses for the feature to work: - A status-readiness summary under Advanced Dependencies in Features shows the per-category counts and whether start-based (start-to-start / start-to-finish) dependencies can release, since those need a Started status to release before completion. - An inline advisory in the status settings flags a missing category while advanced types are on, at the point statuses are edited. Enablement is never blocked: a degraded state is safe (start-based edges just fall back to releasing on completion), so this guides rather than gates. --- i18n.manifest.json | 5 +++ i18n.state.json | 40 +++++++++++++++++++ src/i18n/resources/en.ts | 9 +++++ src/settings/defaults.ts | 10 ++++- src/settings/tabs/featuresTab.ts | 24 +++++++++++ .../tabs/taskProperties/statusPropertyCard.ts | 29 ++++++++++++++ styles/settings-view.css | 20 ++++++++++ .../settings/countStatusCategories.test.ts | 31 ++++++++++++++ 8 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 tests/unit/settings/countStatusCategories.test.ts diff --git a/i18n.manifest.json b/i18n.manifest.json index 4725e6ee6..c4dcbaf1e 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -351,6 +351,10 @@ "settings.features.dependencies.description": "0d9169b5400763b70fbf5d6d6e353ea379b03255", "settings.features.dependencies.advancedTypes.name": "f66818c3d60bc0e0af07f76a294316490648fdd8", "settings.features.dependencies.advancedTypes.description": "c47eb9e8b41785e03257c949975b9ce7c66d9ca8", + "settings.features.dependencies.readiness.name": "3c1caea99da6c9c2c75a4d39d243c109e4fac509", + "settings.features.dependencies.readiness.counts": "91f28f33f798573daafbf09d285a721cee8b5bae", + "settings.features.dependencies.readiness.ready": "834a072164f4cf15f133437b0c87011be5922007", + "settings.features.dependencies.readiness.notReady": "06a54db32352cd734c1cdc1cac1530dcff8b2a2b", "settings.features.inlineTasks.header": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "settings.features.inlineTasks.description": "041baf632682261876d68fdfb557dcb820eeb505", "settings.features.taskCreation.header": "ec5845a572a9d5ae23130b55f1113f7bc2854e96", @@ -733,6 +737,7 @@ "settings.taskProperties.taskStatuses.badges.completed": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "settings.taskProperties.taskStatuses.badges.planned": "db2c45d5579367438b625f4cf40cad184e39bd54", "settings.taskProperties.taskStatuses.badges.inProgress": "faa9e7e7ef5a264c58c68a4a0b9c8bd54241450a", + "settings.taskProperties.taskStatuses.categoryAdvisory": "20a7cd3e531f6642a97afe38d62a434b9b43639f", "settings.taskProperties.taskStatuses.deleteConfirm": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "settings.taskProperties.taskPriorities.header": "44c22d1630a67a7d465d56ddea064d62259dc9dc", "settings.taskProperties.taskPriorities.description": "27e335b697e148e2ff9d76108bbb76d7081f0836", diff --git a/i18n.state.json b/i18n.state.json index df3188515..54d00e7be 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -1396,6 +1396,10 @@ "settings.features.dependencies.description": null, "settings.features.dependencies.advancedTypes.name": null, "settings.features.dependencies.advancedTypes.description": null, + "settings.features.dependencies.readiness.name": null, + "settings.features.dependencies.readiness.counts": null, + "settings.features.dependencies.readiness.ready": null, + "settings.features.dependencies.readiness.notReady": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "f98acefe1cdc9c888903a42f94689b0e6d7bef41" @@ -2912,6 +2916,7 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.categoryAdvisory": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "bd5e5868fd22e54aa9b5018245a7dac8304bd1f6" @@ -10287,6 +10292,10 @@ "settings.features.dependencies.description": null, "settings.features.dependencies.advancedTypes.name": null, "settings.features.dependencies.advancedTypes.description": null, + "settings.features.dependencies.readiness.name": null, + "settings.features.dependencies.readiness.counts": null, + "settings.features.dependencies.readiness.ready": null, + "settings.features.dependencies.readiness.notReady": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "a7f016a7bf869a4faec082a631841e0f359e46c2" @@ -11803,6 +11812,7 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.categoryAdvisory": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "6181bea9a3b42002b16368c96c4b7ec405424a9d" @@ -19178,6 +19188,10 @@ "settings.features.dependencies.description": null, "settings.features.dependencies.advancedTypes.name": null, "settings.features.dependencies.advancedTypes.description": null, + "settings.features.dependencies.readiness.name": null, + "settings.features.dependencies.readiness.counts": null, + "settings.features.dependencies.readiness.ready": null, + "settings.features.dependencies.readiness.notReady": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "a32a664acc6d5279c6600620c1a3fb0a8bb8cd1b" @@ -20694,6 +20708,7 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.categoryAdvisory": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "b9270228ed590ded12e0538f71a4adaa6702b218" @@ -28069,6 +28084,10 @@ "settings.features.dependencies.description": null, "settings.features.dependencies.advancedTypes.name": null, "settings.features.dependencies.advancedTypes.description": null, + "settings.features.dependencies.readiness.name": null, + "settings.features.dependencies.readiness.counts": null, + "settings.features.dependencies.readiness.ready": null, + "settings.features.dependencies.readiness.notReady": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "7277ee93a6bc575b91eacbff0045075f71fa9b22" @@ -29585,6 +29604,7 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.categoryAdvisory": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "d7a8c7b2efbbd6760f19259c206a211ffc3b0674" @@ -36960,6 +36980,10 @@ "settings.features.dependencies.description": null, "settings.features.dependencies.advancedTypes.name": null, "settings.features.dependencies.advancedTypes.description": null, + "settings.features.dependencies.readiness.name": null, + "settings.features.dependencies.readiness.counts": null, + "settings.features.dependencies.readiness.ready": null, + "settings.features.dependencies.readiness.notReady": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "1e85b7120ae8c808302b3a235b6f91916bbf7de8" @@ -38476,6 +38500,7 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.categoryAdvisory": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "f6882f9122f9b4e5f620c536e1d062ebc3225517" @@ -45851,6 +45876,10 @@ "settings.features.dependencies.description": null, "settings.features.dependencies.advancedTypes.name": null, "settings.features.dependencies.advancedTypes.description": null, + "settings.features.dependencies.readiness.name": null, + "settings.features.dependencies.readiness.counts": null, + "settings.features.dependencies.readiness.ready": null, + "settings.features.dependencies.readiness.notReady": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "e4840351a15b65cb43b38b53ce73a1b976f8993f" @@ -47367,6 +47396,7 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.categoryAdvisory": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "5a9415cfc781eb37f56b19b6451cd9da046630de" @@ -54742,6 +54772,10 @@ "settings.features.dependencies.description": null, "settings.features.dependencies.advancedTypes.name": null, "settings.features.dependencies.advancedTypes.description": null, + "settings.features.dependencies.readiness.name": null, + "settings.features.dependencies.readiness.counts": null, + "settings.features.dependencies.readiness.ready": null, + "settings.features.dependencies.readiness.notReady": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "ed325e2a0e049939e66b927ab4ad76ff76319842" @@ -56258,6 +56292,7 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.categoryAdvisory": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "0c211b5422e7e0d78ec042fbd5154781a79bcd90" @@ -63633,6 +63668,10 @@ "settings.features.dependencies.description": null, "settings.features.dependencies.advancedTypes.name": null, "settings.features.dependencies.advancedTypes.description": null, + "settings.features.dependencies.readiness.name": null, + "settings.features.dependencies.readiness.counts": null, + "settings.features.dependencies.readiness.ready": null, + "settings.features.dependencies.readiness.notReady": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "29a6c4edbeac9373f6e112eee10387b97be35a25" @@ -65149,6 +65188,7 @@ }, "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, + "settings.taskProperties.taskStatuses.categoryAdvisory": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "20035d63eaafed20bd3b4001a0dca01e09b82186" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 6da3011a1..0e52a9bf3 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -521,6 +521,13 @@ export const en: TranslationTree = { description: "Choose a relationship type (finish-to-start, start-to-start, finish-to-finish, start-to-finish) and an optional lag for each dependency in the task edit modal. When off, dependencies use finish-to-start.", }, + readiness: { + name: "Status readiness", + counts: "Not started: {notStarted}, Started: {started}, Completed: {completed}.", + ready: "Start-based dependencies are ready.", + notReady: + "Start-to-start and start-to-finish dependencies need at least one not-started status and one started status; without a started status they only release when the predecessor completes. Categorize a status in the task properties settings.", + }, }, inlineTasks: { header: "Inline tasks", @@ -1259,6 +1266,8 @@ export const en: TranslationTree = { planned: "Not started", inProgress: "Started", }, + categoryAdvisory: + "Advanced dependency types are on, but no {categories} status exists. Start-based dependencies release as authored only when every category has a status.", deleteConfirm: 'Are you sure you want to delete the status "{label}"?', }, taskPriorities: { diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index dc2ecf03e..afe8cfeb6 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -1,4 +1,4 @@ -import { FieldMapping, StatusConfig, PriorityConfig } from "../types"; +import { FieldMapping, StatusCategory, StatusConfig, PriorityConfig } from "../types"; import { TaskNotesSettings, TaskCreationDefaults, @@ -96,6 +96,14 @@ export function normalizeStatusCategories(statuses: StatusConfig[]): StatusConfi }); } +export function countStatusCategories(statuses: StatusConfig[]): Record { + const counts: Record = { planned: 0, "in-progress": 0, completed: 0 }; + for (const status of normalizeStatusCategories(statuses)) { + counts[status.category ?? "planned"]++; + } + return counts; +} + // Default priority configuration matches current hardcoded behavior export const DEFAULT_PRIORITIES: PriorityConfig[] = [ { diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index 551c9af03..1d614e51d 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -18,6 +18,7 @@ import { normalizeThemeColor, } from "../../utils/themeColors"; import { configureThemeColorInput } from "../components/CardComponent"; +import { countStatusCategories } from "../defaults"; async function getInitializedPomodoroService(plugin: TaskNotesPlugin) { if (!plugin.pomodoroService) { @@ -1000,9 +1001,32 @@ export function renderFeaturesTab( setValue: async (value: boolean) => { plugin.settings.enableAdvancedDependencyTypes = value; save(); + renderFeaturesTab(container, plugin, save); }, }) ); + + if (plugin.settings.enableAdvancedDependencyTypes) { + const counts = countStatusCategories(plugin.settings.customStatuses); + const startReady = counts["in-progress"] > 0 && counts.planned > 0; + group.addSetting((setting) => { + setting.setName(translate("settings.features.dependencies.readiness.name")); + setting.setDesc( + `${translate("settings.features.dependencies.readiness.counts", { + notStarted: counts.planned, + started: counts["in-progress"], + completed: counts.completed, + })} ${translate( + startReady + ? "settings.features.dependencies.readiness.ready" + : "settings.features.dependencies.readiness.notReady" + )}` + ); + setting.settingEl.addClass( + startReady ? "tn-dependency-readiness--ok" : "tn-dependency-readiness--warn" + ); + }); + } } ); diff --git a/src/settings/tabs/taskProperties/statusPropertyCard.ts b/src/settings/tabs/taskProperties/statusPropertyCard.ts index b63f30ff2..77bf9a5cf 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -19,6 +19,7 @@ import { import { createIconInput } from "../../components/IconSuggest"; import { createNLPTriggerRows, createPropertyDescription, TranslateFn } from "./helpers"; import { StatusCategory, STATUS_CATEGORIES } from "../../../types"; +import { countStatusCategories } from "../../defaults"; // The badge i18n keys are camelCase, but the stored category value for "Started" is // the hyphenated "in-progress" (also the CSS variant); map the one that differs. @@ -36,6 +37,32 @@ function createCategoryBadge( ); } +// Advisory (not a gate): when advanced dependency types are on, a missing category means +// start-based edges can't release as authored. Guidance at the point statuses are edited. +function renderStatusCategoryAdvisory( + container: HTMLElement, + plugin: TaskNotesPlugin, + translate: TranslateFn +): void { + if (!plugin.settings.enableAdvancedDependencyTypes) { + return; + } + const counts = countStatusCategories(plugin.settings.customStatuses ?? []); + const missing = STATUS_CATEGORIES.filter((category) => counts[category] === 0); + if (missing.length === 0) { + return; + } + const categories = missing + .map((category) => + translate(`settings.taskProperties.taskStatuses.badges.${categoryI18nKey(category)}`) + ) + .join(", "); + const advisory = container.createDiv({ cls: "tn-status-category-advisory" }); + advisory.setText( + translate("settings.taskProperties.taskStatuses.categoryAdvisory", { categories }) + ); +} + /** * Renders the Status property card with nested status value cards */ @@ -245,6 +272,8 @@ function renderStatusList( ): void { container.empty(); + renderStatusCategoryAdvisory(container, plugin, translate); + if (!plugin.settings.customStatuses || plugin.settings.customStatuses.length === 0) { showCardEmptyState(container, translate("settings.taskProperties.taskStatuses.emptyState")); return; diff --git a/styles/settings-view.css b/styles/settings-view.css index 787bfa180..a52a1fe98 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -1413,3 +1413,23 @@ body.is-mobile .tasknotes-plugin .tasknotes-settings__card-action-btn { transition: none; } } + +.setting-item.tn-dependency-readiness--ok { + border-inline-start: 3px solid var(--color-green); + padding-inline-start: var(--size-4-2); +} + +.setting-item.tn-dependency-readiness--warn { + border-inline-start: 3px solid var(--color-orange); + padding-inline-start: var(--size-4-2); +} + +.tn-status-category-advisory { + margin-bottom: var(--size-4-2); + padding: var(--size-4-2) var(--size-4-3); + border-inline-start: 3px solid var(--color-orange); + background: var(--background-modifier-hover); + border-radius: var(--radius-s); + color: var(--text-muted); + font-size: var(--font-ui-small); +} diff --git a/tests/unit/settings/countStatusCategories.test.ts b/tests/unit/settings/countStatusCategories.test.ts new file mode 100644 index 000000000..e11e879c5 --- /dev/null +++ b/tests/unit/settings/countStatusCategories.test.ts @@ -0,0 +1,31 @@ +import { countStatusCategories } from "../../../src/settings/defaults"; +import type { StatusConfig } from "../../../src/types"; + +function status(overrides: Partial): StatusConfig { + return { + id: "x", + value: "x", + label: "x", + color: "#000000", + isCompleted: false, + order: 0, + ...overrides, + }; +} + +describe("countStatusCategories", () => { + it("counts by normalized category (completed via isCompleted; default planned)", () => { + const counts = countStatusCategories([ + status({ id: "a", isCompleted: false }), + status({ id: "b", category: "planned" }), + status({ id: "c", category: "in-progress" }), + status({ id: "d", isCompleted: true }), + status({ id: "e", category: "completed", isCompleted: false }), + ]); + expect(counts).toEqual({ planned: 2, "in-progress": 1, completed: 2 }); + }); + + it("returns zeros for an empty list", () => { + expect(countStatusCategories([])).toEqual({ planned: 0, "in-progress": 0, completed: 0 }); + }); +}); From 0b8bb20226ab8cc938c77ca87a3b049244b0bbdd Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 09:40:54 +1200 Subject: [PATCH 15/24] feat(task-card): break the blocking tooltip down by endpoint When a task gates a successor's finish, the blocking toggle tooltip now reads "Blocking N tasks: X to start, Y to finish" instead of a flat count, mirroring the per-endpoint blocked-by badge. A finish-to-start-only case (no finish-gated successors) keeps the plain summary. --- i18n.manifest.json | 1 + i18n.state.json | 8 ++++++++ src/i18n/resources/en.ts | 1 + src/ui/taskCardSecondaryBadges.ts | 23 +++++++++++++++++------ 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index c4dcbaf1e..1faffb025 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -2025,6 +2025,7 @@ "ui.taskCard.blockingBadge": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "ui.taskCard.blockingBadgeTooltip": "949888e3fc7f208a3c9a867b79a1abf4f15aa90a", "ui.taskCard.blockingToggle": "17e5c29b584df3fa8161559141c209308994dccb", + "ui.taskCard.blockingToggleBreakdown": "d2c7ddc1ae15b4ee52c5e9dd8df166f3647199f2", "ui.taskCard.priorityAriaLabel": "28f478cb8ee60d0167b0507b87fa966e52061d81", "ui.taskCard.taskOptions": "d7788a93bb9a5f6b6770830ead3075c0b91cc9a3", "ui.taskCard.recurrenceTooltip": "468ddecacd77613fb8597819d97008bc336a76e8", diff --git a/i18n.state.json b/i18n.state.json index 54d00e7be..31a621405 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7990,6 +7990,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "3564edcba569e2d15e34f0b38542e0cdc5bae04f" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "86e4a49489b8cc0ced8c7249cfce2ae618268af3" @@ -16886,6 +16887,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "9f322ef2ec9a09368c7b0635c59b991157055425" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "f15a805078c511a9555a8a88c16b670a4b9c6f92" @@ -25782,6 +25784,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "46307ae570790f319bb1435f2c6652b116e3c8d7" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "9e64071edad38ec04234439e93121798318e2d06" @@ -34678,6 +34681,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "4b9503320c661915dc9dac425fd7fa084d7c67e1" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "c4a27cd7c9b3e2f6e9db4992ef231d61f66cbdba" @@ -43574,6 +43578,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "1c82e0b019f0faf4bb9d8ed4bf3f655d87b4eb7a" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "f67350f40f7ad2d24672f5e562f54a3bdb8d1103" @@ -52470,6 +52475,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "a84cad376851638e4537357667959951433ef52a" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "96c717aafcb524053df980be7f24731052107dd9" @@ -61366,6 +61372,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "c8b5589a0fffc49c324be3d5a717608734455df3" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "d05244686c421dd71e5cca6c8bc76879f43ca0cf" @@ -70262,6 +70269,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "bac942705dd9850c28f2a6716dcede66a2c5c806" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "8783c8a8decc50ccc704bfb179c3a43374df33a8" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0e52a9bf3..a88a2e53e 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3328,6 +3328,7 @@ export const en: TranslationTree = { blockingBadge: "Blocking", blockingBadgeTooltip: "This task is blocking another task", blockingToggle: "Blocking {count} tasks", + blockingToggleBreakdown: "Blocking {count} tasks: {start} to start, {finish} to finish", priorityAriaLabel: "Priority: {label}", taskOptions: "Task options", recurrenceTooltip: "{label}: {value}", diff --git a/src/ui/taskCardSecondaryBadges.ts b/src/ui/taskCardSecondaryBadges.ts index 164a5d3fd..b3659b62a 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -86,6 +86,21 @@ function shouldExpandSubtasksByDefault(plugin: TaskNotesPlugin): boolean { return plugin.settings?.expandSubtasksByDefault === true; } +// Break the reverse (blocking) count down by endpoint when this task gates any successor's +// finish; an all-start default (finish = 0, the finish-to-start case) keeps the plain summary. +function blockingToggleTooltip(task: TaskInfo, plugin: TaskNotesPlugin, count: number): string { + const finishCount = plugin.dependencyCache?.getFinishBlockedDependentPaths(task.path).length ?? 0; + if (finishCount === 0) { + return tTaskCard(plugin, "blockingToggle", { count }); + } + const startCount = plugin.dependencyCache?.getStartBlockedDependentPaths(task.path).length ?? 0; + return tTaskCard(plugin, "blockingToggleBreakdown", { + count, + start: startCount, + finish: finishCount, + }); +} + export function isTaskCardSubtasksExpanded(task: TaskInfo, plugin: TaskNotesPlugin): boolean { return ( plugin.expandedProjectsService?.isExpanded( @@ -303,9 +318,7 @@ function renderDependencyToggles( if (task.blocking && task.blocking.length > 0) { const blockingCount = task.blocking.length; - const toggleLabel = plugin.i18n.translate("ui.taskCard.blockingToggle", { - count: blockingCount, - }); + const toggleLabel = blockingToggleTooltip(task, plugin, blockingCount); const toggle = createBadgeIndicator({ container: badgesContainer, className: "task-card__blocking-toggle is-visible", @@ -493,9 +506,7 @@ function updateBlockingToggle(options: UpdateTaskCardSecondaryBadgesOptions): vo const { card, task, plugin, handlers } = options; const blockingCount = task.blocking?.length ?? 0; const shouldExist = blockingCount > 0; - const toggleLabel = plugin.i18n.translate("ui.taskCard.blockingToggle", { - count: blockingCount, - }); + const toggleLabel = blockingToggleTooltip(task, plugin, blockingCount); const toggle = updateBadgeIndicator(card, ".task-card__blocking-toggle", { shouldExist, From 7970cb269245828b03e1b1d051d2ca379b96fbf1 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 09:45:20 +1200 Subject: [PATCH 16/24] feat(task-card): stack the blocking tooltip breakdown on its own lines Obsidian tooltips render with white-space: pre-wrap, so put the start and finish totals each on their own line for readability. --- i18n.manifest.json | 2 +- src/i18n/resources/en.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index 1faffb025..9b9777d5f 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -2025,7 +2025,7 @@ "ui.taskCard.blockingBadge": "d785c0d4b3b9c24878b62f64a7bcf78e9506ab27", "ui.taskCard.blockingBadgeTooltip": "949888e3fc7f208a3c9a867b79a1abf4f15aa90a", "ui.taskCard.blockingToggle": "17e5c29b584df3fa8161559141c209308994dccb", - "ui.taskCard.blockingToggleBreakdown": "d2c7ddc1ae15b4ee52c5e9dd8df166f3647199f2", + "ui.taskCard.blockingToggleBreakdown": "57d886e2f4236976638cb06097f510606848015b", "ui.taskCard.priorityAriaLabel": "28f478cb8ee60d0167b0507b87fa966e52061d81", "ui.taskCard.taskOptions": "d7788a93bb9a5f6b6770830ead3075c0b91cc9a3", "ui.taskCard.recurrenceTooltip": "468ddecacd77613fb8597819d97008bc336a76e8", diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index a88a2e53e..a8f01bb34 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3328,7 +3328,7 @@ export const en: TranslationTree = { blockingBadge: "Blocking", blockingBadgeTooltip: "This task is blocking another task", blockingToggle: "Blocking {count} tasks", - blockingToggleBreakdown: "Blocking {count} tasks: {start} to start, {finish} to finish", + blockingToggleBreakdown: "Blocking {count} tasks:\n{start} to start\n{finish} to finish", priorityAriaLabel: "Priority: {label}", taskOptions: "Task options", recurrenceTooltip: "{label}: {value}", From 9206179672ddbbca6dfe6dcbe78520260a64d240 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 13:53:23 +1200 Subject: [PATCH 17/24] feat(i18n): add context-menu relationship-type labels for the dependency quick path Eight plain-language menu titles (four types x blocking/blocked-by) for the context-menu type-before-task step: readable arrow names plus a direction-specific clause, no raw FS/SS/FF/SF codes. --- i18n.manifest.json | 8 +++++ i18n.state.json | 64 ++++++++++++++++++++++++++++++++++++++++ src/i18n/resources/en.ts | 14 +++++++++ 3 files changed, 86 insertions(+) diff --git a/i18n.manifest.json b/i18n.manifest.json index 9b9777d5f..5e6a487d3 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1835,6 +1835,14 @@ "contextMenus.task.dependencies.removeBlockedBy": "7d38375d41dfda37e2adcdaea68e95312a792312", "contextMenus.task.dependencies.removeBlocking": "29f784e16f2ca0df6d37a9fb6f29c3a186937738", "contextMenus.task.dependencies.unknownDependency": "bc7819b34ff87570745fbe461e36a16f80e562ce", + "contextMenus.task.dependencies.reltype.blocking.finishToStart": "b50cfe466b096494591205ea9cfab3526cba9054", + "contextMenus.task.dependencies.reltype.blocking.startToStart": "25eb197f4962b676253b09c8aa866c26bd3bc642", + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": "4c05dbcb794856b1af7968e4b10a986a7c662b42", + "contextMenus.task.dependencies.reltype.blocking.startToFinish": "8635c71ae358909a9d8db530e18b9920be387a64", + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": "96d8d50fbcc29b664f7880e1ad48b6fb9a5da51f", + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": "aaf413f633282c6874dc3b04f2c701803acaa67b", + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": "687b6b600b1e26e581f09e9f7df0192f740a7112", + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": "07bc71f9865dcde8b4303e9d5b2f74674704e284", "contextMenus.task.dependencies.inputPlaceholder": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "contextMenus.task.dependencies.notices.noEntries": "e5145350fdd7dee5b4a532bea740c844b64d938e", "contextMenus.task.dependencies.notices.blockedByAdded": "5b40b08d56beaf43181cea5aeb55474c47a3e347", diff --git a/i18n.state.json b/i18n.state.json index 31a621405..1ce39808c 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7245,6 +7245,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "d0b00a9fadc88a3867b81d199cf03ae96a7b2f7b" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "bbd106824000d1d212bf07cc7aa6a94e663e150d" @@ -16142,6 +16150,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "452c8a125bc1f5fb0593d47a3f43ecf184ad5aa5" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "74706e43408a7303fab3739e018c8d757f29e63d" @@ -25039,6 +25055,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "f3cfc645bf78ba57f3f6efc9c599ee641d79f705" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "55e731b268bb8200261b2e75b325ac6c4103ccea" @@ -33936,6 +33960,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "75e9dd8f7631851abc9a581082b573daf0509b2e" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "e0dd7b3c859584e950f686781a00a66187bb5656" @@ -42833,6 +42865,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "8916b6394ae9c12d585417ece28011981631a95c" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "6c226bb2ba5792cc5f751bb02582c8f84c793148" @@ -51730,6 +51770,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "11ab623f76c151807f276bc04b876def4ed0fea8" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "bad56640a3a190acbd85aa0301b5fc3ed614f661" @@ -60627,6 +60675,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "43b44f1eccaa91c8b3bc855cb2e135aea9ee41b8" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "0082275ed30d02e8430271ace5fb52e365d46552" @@ -69524,6 +69580,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "d9c32a4c3dda1c37ab78fe57a1fe60c62b1a81eb" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "cde761a18a3d3b85182fc314d5496a2fe766580e" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index a8f01bb34..7ea9ca89f 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3044,6 +3044,20 @@ export const en: TranslationTree = { removeBlockedBy: "Remove blocked-by…", removeBlocking: "Remove blocking…", unknownDependency: "Unknown", + reltype: { + blocking: { + finishToStart: "Finish → start · they can't start until this finishes", + startToStart: "Start → start · they can't start until this starts", + finishToFinish: "Finish → finish · they can't finish until this finishes", + startToFinish: "Start → finish · they can't finish until this starts", + }, + blockedBy: { + finishToStart: "Finish → start · this can't start until that finishes", + startToStart: "Start → start · this can't start until that starts", + finishToFinish: "Finish → finish · this can't finish until that finishes", + startToFinish: "Start → finish · this can't finish until that starts", + }, + }, inputPlaceholder: "[[Task Note]]", notices: { noEntries: "Please enter at least one task", From 7b9ad5ee40b1f1bb1afb0b8d0f8fe0de7e8e419e Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 13:54:47 +1200 Subject: [PATCH 18/24] feat(dependencies): choose the relationship type from the context-menu quick path When advanced dependency types are enabled, Dependencies -> Blocking / Blocked by lists the four relationship types before the task picker, and the edge is created with the chosen type. The shared task picker is reused unchanged and toggle-off behavior is identical to before. The reltype-aware add is extracted into taskContextMenuDependencies.ts so the threading is unit-tested. --- src/components/TaskContextMenu.ts | 196 +++++++----------- src/components/taskContextMenuDependencies.ts | 142 +++++++++++++ .../taskContextMenuDependencies.test.ts | 130 ++++++++++++ 3 files changed, 346 insertions(+), 122 deletions(-) create mode 100644 src/components/taskContextMenuDependencies.ts create mode 100644 tests/unit/components/taskContextMenuDependencies.test.ts diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 7760f07b3..b1421aaf6 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -1,7 +1,7 @@ import { Menu, Notice, Platform, TFile, type MenuItem, type TAbstractFile } from "obsidian"; import type { OccurrenceMaterializationMode, OccurrenceNextTrigger } from "@tasknotes/model"; import TaskNotesPlugin from "../main"; -import { TaskDependency, TaskInfo } from "../types"; +import { TaskDependencyRelType, TaskInfo } from "../types"; import { formatDateForStorage } from "../utils/dateUtils"; import { ReminderModal } from "../modals/ReminderModal"; import { @@ -26,8 +26,8 @@ import { DEFAULT_DEPENDENCY_RELTYPE, extractDependencyUid, formatDependencyLink, - normalizeDependencyEntry, } from "../utils/dependencyUtils"; +import { addBlockedByDependency, addBlockingDependency } from "./taskContextMenuDependencies"; import { generateLink } from "../utils/linkUtils"; import { ContextMenu } from "./ContextMenu"; import { buildTimeblockPrefillForTask } from "../utils/timeblockPrefillUtils"; @@ -1020,10 +1020,17 @@ export class TaskContextMenu { menu.addItem((subItem) => { subItem.setTitle(this.t("contextMenus.task.dependencies.addBlockedBy")); subItem.setIcon("link-2"); - subItem.onClick(() => { - this.menu.hide(); - void this.openBlockedBySelector(task, plugin); - }); + if (plugin.settings.enableAdvancedDependencyTypes) { + this.addReltypeMenuItems(getSubmenu(subItem), "blockedBy", (reltype) => { + this.menu.hide(); + void this.openBlockedBySelector(task, plugin, reltype); + }); + } else { + subItem.onClick(() => { + this.menu.hide(); + void this.openBlockedBySelector(task, plugin); + }); + } }); const blockedByEntries = task.blockedBy ?? []; @@ -1075,10 +1082,17 @@ export class TaskContextMenu { menu.addItem((subItem) => { subItem.setTitle(this.t("contextMenus.task.dependencies.addBlocking")); subItem.setIcon("git-branch-plus"); - subItem.onClick(() => { - this.menu.hide(); - void this.openBlockingSelector(task, plugin); - }); + if (plugin.settings.enableAdvancedDependencyTypes) { + this.addReltypeMenuItems(getSubmenu(subItem), "blocking", (reltype) => { + this.menu.hide(); + void this.openBlockingSelector(task, plugin, reltype); + }); + } else { + subItem.onClick(() => { + this.menu.hide(); + void this.openBlockingSelector(task, plugin); + }); + } }); const blockingEntries = task.blocking ?? []; @@ -1128,22 +1142,38 @@ export class TaskContextMenu { } } - private dedupeDependencyEntries(entries: Array): TaskDependency[] { - const seen = new Map(); - for (const entry of entries) { - const normalized = normalizeDependencyEntry(entry); - if (!normalized) { - continue; - } - const key = this.getDependencyKey(normalized); - if (!seen.has(key)) { - seen.set(key, normalized); - } + private addReltypeMenuItems( + submenu: Menu, + side: "blockedBy" | "blocking", + onPick: (reltype: TaskDependencyRelType) => void + ): void { + const order: TaskDependencyRelType[] = [ + "FINISHTOSTART", + "STARTTOSTART", + "FINISHTOFINISH", + "STARTTOFINISH", + ]; + const key: Record = { + FINISHTOSTART: "finishToStart", + STARTTOSTART: "startToStart", + FINISHTOFINISH: "finishToFinish", + STARTTOFINISH: "startToFinish", + }; + for (const reltype of order) { + submenu.addItem((item) => { + item.setTitle( + this.t(`contextMenus.task.dependencies.reltype.${side}.${key[reltype]}`) + ); + item.onClick(() => onPick(reltype)); + }); } - return Array.from(seen.values()); } - private async openBlockedBySelector(task: TaskInfo, plugin: TaskNotesPlugin): Promise { + private async openBlockedBySelector( + task: TaskInfo, + plugin: TaskNotesPlugin, + reltype: TaskDependencyRelType = DEFAULT_DEPENDENCY_RELTYPE + ): Promise { const existingUids = new Set( (Array.isArray(task.blockedBy) ? task.blockedBy : []).map( (dependency) => dependency.uid @@ -1162,12 +1192,23 @@ export class TaskContextMenu { return !existingUids.has(candidateUid); }, async (selected) => { - await this.handleBlockedBySelection(task, plugin, selected); + await addBlockedByDependency({ + plugin, + task, + selectedTask: selected, + reltype, + translate: (key, params) => this.t(key, params), + onUpdate: () => this.options.onUpdate?.(), + }); } ); } - private async openBlockingSelector(task: TaskInfo, plugin: TaskNotesPlugin): Promise { + private async openBlockingSelector( + task: TaskInfo, + plugin: TaskNotesPlugin, + reltype: TaskDependencyRelType = DEFAULT_DEPENDENCY_RELTYPE + ): Promise { const existingPaths = new Set(task.blocking ?? []); await this.openTaskDependencySelector( plugin, @@ -1176,7 +1217,14 @@ export class TaskContextMenu { return !existingPaths.has(candidate.path); }, async (selected) => { - await this.handleBlockingSelection(task, plugin, selected); + await addBlockingDependency({ + plugin, + task, + selectedTask: selected, + reltype, + translate: (key, params) => this.t(key, params), + onUpdate: () => this.options.onUpdate?.(), + }); } ); } @@ -1209,102 +1257,6 @@ export class TaskContextMenu { } } - private async handleBlockedBySelection( - task: TaskInfo, - plugin: TaskNotesPlugin, - selectedTask: TaskInfo - ): Promise { - if (selectedTask.path === task.path) { - return; - } - - try { - const dependency: TaskDependency = { - uid: formatDependencyLink( - plugin.app, - task.path, - selectedTask.path, - plugin.settings.useFrontmatterMarkdownLinks - ), - reltype: DEFAULT_DEPENDENCY_RELTYPE, - }; - const existing = Array.isArray(task.blockedBy) ? task.blockedBy : []; - const combined = this.dedupeDependencyEntries([...existing, dependency]); - if (combined.length === existing.length) { - return; - } - - const updatedTask = await plugin.updateTaskProperty(task, "blockedBy", combined); - Object.assign(task, updatedTask); - - new Notice( - this.t("contextMenus.task.dependencies.notices.blockedByAdded", { count: 1 }) - ); - this.options.onUpdate?.(); - } catch (error) { - tasknotesLogger.error("Failed to add blocked-by dependency via selector:", { - category: "persistence", - operation: "add-blocked-by-dependency-via-selector", - error: error, - }); - new Notice(this.t("contextMenus.task.dependencies.notices.updateFailed")); - } - } - - private async handleBlockingSelection( - task: TaskInfo, - plugin: TaskNotesPlugin, - selectedTask: TaskInfo - ): Promise { - const blockedPath = selectedTask.path; - if (blockedPath === task.path) { - return; - } - if (task.blocking?.includes(blockedPath)) { - return; - } - - try { - const rawEntry: TaskDependency = { - uid: formatDependencyLink( - plugin.app, - blockedPath, - task.path, - plugin.settings.useFrontmatterMarkdownLinks - ), - reltype: DEFAULT_DEPENDENCY_RELTYPE, - }; - await plugin.taskService.updateBlockingRelationships(task, [blockedPath], [], { - [blockedPath]: rawEntry, - }); - - const refreshed = await plugin.cacheManager.getTaskInfo(task.path); - if (refreshed) { - Object.assign(task, refreshed); - } else if (Array.isArray(task.blocking)) { - task.blocking = Array.from(new Set([...task.blocking, blockedPath])); - } else { - task.blocking = [blockedPath]; - } - - new Notice( - this.t("contextMenus.task.dependencies.notices.blockingAdded", { count: 1 }) - ); - this.options.onUpdate?.(); - } catch (error) { - tasknotesLogger.error("Failed to add blocking dependency via selector:", { - category: "persistence", - operation: "add-blocking-dependency-via-selector", - error: error, - }); - new Notice(this.t("contextMenus.task.dependencies.notices.updateFailed")); - } - } - - private getDependencyKey(entry: TaskDependency): string { - return `${entry.uid}::${entry.reltype}::${entry.gap ?? ""}`; - } - private addOrganizationMenuItems(menu: Menu, task: TaskInfo, plugin: TaskNotesPlugin): void { // Contexts menu.addItem((subItem) => { diff --git a/src/components/taskContextMenuDependencies.ts b/src/components/taskContextMenuDependencies.ts new file mode 100644 index 000000000..77eefe440 --- /dev/null +++ b/src/components/taskContextMenuDependencies.ts @@ -0,0 +1,142 @@ +import { Notice } from "obsidian"; +import TaskNotesPlugin from "../main"; +import { TaskDependency, TaskDependencyRelType, TaskInfo } from "../types"; +import { + DEFAULT_DEPENDENCY_RELTYPE, + formatDependencyLink, + normalizeDependencyEntry, +} from "../utils/dependencyUtils"; +import { createTaskNotesLogger } from "../utils/tasknotesLogger"; + +const logger = createTaskNotesLogger({ tag: "Components/TaskContextMenuDependencies" }); + +export type DependencyTranslate = ( + key: string, + params?: Record +) => string; + +export interface AddDependencyContext { + plugin: TaskNotesPlugin; + task: TaskInfo; + selectedTask: TaskInfo; + reltype?: TaskDependencyRelType; + translate: DependencyTranslate; + onUpdate?: () => void; +} + +function dependencyKey(entry: TaskDependency): string { + return `${entry.uid}::${entry.reltype}::${entry.gap ?? ""}`; +} + +function dedupeDependencies(entries: Array): TaskDependency[] { + const seen = new Map(); + for (const entry of entries) { + const normalized = normalizeDependencyEntry(entry); + if (!normalized) { + continue; + } + const key = dependencyKey(normalized); + if (!seen.has(key)) { + seen.set(key, normalized); + } + } + return Array.from(seen.values()); +} + +export async function addBlockedByDependency({ + plugin, + task, + selectedTask, + reltype = DEFAULT_DEPENDENCY_RELTYPE, + translate, + onUpdate, +}: AddDependencyContext): Promise { + if (selectedTask.path === task.path) { + return; + } + + try { + const dependency: TaskDependency = { + uid: formatDependencyLink( + plugin.app, + task.path, + selectedTask.path, + plugin.settings.useFrontmatterMarkdownLinks + ), + reltype, + }; + const existing = Array.isArray(task.blockedBy) ? task.blockedBy : []; + const combined = dedupeDependencies([...existing, dependency]); + if (combined.length === existing.length) { + return; + } + + const updatedTask = await plugin.updateTaskProperty(task, "blockedBy", combined); + Object.assign(task, updatedTask); + + new Notice( + translate("contextMenus.task.dependencies.notices.blockedByAdded", { count: 1 }) + ); + onUpdate?.(); + } catch (error) { + logger.error("Failed to add blocked-by dependency via selector:", { + category: "persistence", + operation: "add-blocked-by-dependency-via-selector", + error, + }); + new Notice(translate("contextMenus.task.dependencies.notices.updateFailed")); + } +} + +export async function addBlockingDependency({ + plugin, + task, + selectedTask, + reltype = DEFAULT_DEPENDENCY_RELTYPE, + translate, + onUpdate, +}: AddDependencyContext): Promise { + const blockedPath = selectedTask.path; + if (blockedPath === task.path) { + return; + } + if (task.blocking?.includes(blockedPath)) { + return; + } + + try { + const rawEntry: TaskDependency = { + uid: formatDependencyLink( + plugin.app, + blockedPath, + task.path, + plugin.settings.useFrontmatterMarkdownLinks + ), + reltype, + }; + await plugin.taskService.updateBlockingRelationships(task, [blockedPath], [], { + [blockedPath]: rawEntry, + }); + + const refreshed = await plugin.cacheManager.getTaskInfo(task.path); + if (refreshed) { + Object.assign(task, refreshed); + } else if (Array.isArray(task.blocking)) { + task.blocking = Array.from(new Set([...task.blocking, blockedPath])); + } else { + task.blocking = [blockedPath]; + } + + new Notice( + translate("contextMenus.task.dependencies.notices.blockingAdded", { count: 1 }) + ); + onUpdate?.(); + } catch (error) { + logger.error("Failed to add blocking dependency via selector:", { + category: "persistence", + operation: "add-blocking-dependency-via-selector", + error, + }); + new Notice(translate("contextMenus.task.dependencies.notices.updateFailed")); + } +} diff --git a/tests/unit/components/taskContextMenuDependencies.test.ts b/tests/unit/components/taskContextMenuDependencies.test.ts new file mode 100644 index 000000000..8aed0565f --- /dev/null +++ b/tests/unit/components/taskContextMenuDependencies.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, jest } from "@jest/globals"; +import { + addBlockedByDependency, + addBlockingDependency, +} from "../../../src/components/taskContextMenuDependencies"; +import type { TaskInfo } from "../../../src/types"; + +function makePlugin() { + return { + app: { + vault: { + // Non-TFile return routes formatDependencyLink through its basename-wikilink fallback. + getAbstractFileByPath: jest.fn().mockReturnValue(null), + }, + }, + settings: { useFrontmatterMarkdownLinks: false }, + updateTaskProperty: jest + .fn() + .mockImplementation(async (t: any, property: string, value: unknown) => ({ + ...t, + [property]: value, + })), + taskService: { + updateBlockingRelationships: jest.fn().mockResolvedValue(undefined), + }, + cacheManager: { + getTaskInfo: jest.fn().mockResolvedValue(null), + }, + } as any; +} + +function task(path: string, extra: Partial = {}): TaskInfo { + return { path, ...extra } as unknown as TaskInfo; +} + +const translate = (key: string) => key; + +describe("taskContextMenuDependencies", () => { + describe("addBlockedByDependency", () => { + it("writes the chosen reltype", async () => { + const plugin = makePlugin(); + const t = task("Tasks/dependent.md", { blockedBy: [] }); + await addBlockedByDependency({ + plugin, + task: t, + selectedTask: task("Tasks/blocker.md"), + reltype: "STARTTOSTART", + translate, + }); + expect(plugin.updateTaskProperty).toHaveBeenCalledTimes(1); + const [, property, value] = plugin.updateTaskProperty.mock.calls[0]; + expect(property).toBe("blockedBy"); + expect(value).toHaveLength(1); + expect(value[0].reltype).toBe("STARTTOSTART"); + }); + + it("defaults to FINISHTOSTART when no reltype is given", async () => { + const plugin = makePlugin(); + const t = task("Tasks/dependent.md", { blockedBy: [] }); + await addBlockedByDependency({ + plugin, + task: t, + selectedTask: task("Tasks/blocker.md"), + translate, + }); + const value = plugin.updateTaskProperty.mock.calls[0][2]; + expect(value[0].reltype).toBe("FINISHTOSTART"); + }); + + it("does not write when the selected task is itself", async () => { + const plugin = makePlugin(); + const t = task("Tasks/self.md", { blockedBy: [] }); + await addBlockedByDependency({ + plugin, + task: t, + selectedTask: task("Tasks/self.md"), + translate, + }); + expect(plugin.updateTaskProperty).not.toHaveBeenCalled(); + }); + + it("does not write a duplicate blocked-by edge", async () => { + const plugin = makePlugin(); + const t = task("Tasks/dependent.md", { + blockedBy: [{ uid: "blocker", reltype: "FINISHTOSTART" }], + }); + await addBlockedByDependency({ + plugin, + task: t, + selectedTask: task("Tasks/blocker.md"), + reltype: "FINISHTOSTART", + translate, + }); + expect(plugin.updateTaskProperty).not.toHaveBeenCalled(); + }); + }); + + describe("addBlockingDependency", () => { + it("passes the chosen reltype to the blocking write", async () => { + const plugin = makePlugin(); + const t = task("Tasks/blocker.md", { blocking: [] }); + await addBlockingDependency({ + plugin, + task: t, + selectedTask: task("Tasks/blocked.md"), + reltype: "FINISHTOFINISH", + translate, + }); + expect(plugin.taskService.updateBlockingRelationships).toHaveBeenCalledTimes(1); + const [, added, removed, entryMap] = + plugin.taskService.updateBlockingRelationships.mock.calls[0]; + expect(added).toEqual(["Tasks/blocked.md"]); + expect(removed).toEqual([]); + expect(entryMap["Tasks/blocked.md"].reltype).toBe("FINISHTOFINISH"); + }); + + it("does not write when already blocking the target", async () => { + const plugin = makePlugin(); + const t = task("Tasks/blocker.md", { blocking: ["Tasks/blocked.md"] }); + await addBlockingDependency({ + plugin, + task: t, + selectedTask: task("Tasks/blocked.md"), + reltype: "FINISHTOSTART", + translate, + }); + expect(plugin.taskService.updateBlockingRelationships).not.toHaveBeenCalled(); + }); + }); +}); From 3004cfd93f80fe82aa7fa28ab035f4241c8aa413 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 14:03:40 +1200 Subject: [PATCH 19/24] test: complete task-card expandedProjectsService mocks with blocking methods The card now calls isBlockingExpanded / isBlockedByExpanded / setBlockingExpanded / setBlockedByExpanded (added with expansion persistence in ee33f383), but these two suites still mocked only isExpanded/toggle, so they threw on CI and failed the coverage gate. Complete the mocks to match the service. --- tests/unit/ui/TaskCard.test.ts | 4 ++++ tests/unit/ui/taskCardSecondaryBadges.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/unit/ui/TaskCard.test.ts b/tests/unit/ui/TaskCard.test.ts index 025184b14..1c33a1653 100644 --- a/tests/unit/ui/TaskCard.test.ts +++ b/tests/unit/ui/TaskCard.test.ts @@ -193,6 +193,10 @@ describe('TaskCard Component', () => { expandedProjectsService: { isExpanded: jest.fn(() => false), toggle: jest.fn(() => true), + isBlockedByExpanded: jest.fn(() => false), + isBlockingExpanded: jest.fn(() => false), + setBlockedByExpanded: jest.fn(), + setBlockingExpanded: jest.fn(), }, i18n: { translate: jest.fn((key, vars) => { diff --git a/tests/unit/ui/taskCardSecondaryBadges.test.ts b/tests/unit/ui/taskCardSecondaryBadges.test.ts index 66c9c7e31..06a0472a4 100644 --- a/tests/unit/ui/taskCardSecondaryBadges.test.ts +++ b/tests/unit/ui/taskCardSecondaryBadges.test.ts @@ -58,6 +58,10 @@ function createPlugin(overrides: Partial = {}): TaskNotesPlugin expandedProjectsService: { isExpanded: jest.fn(() => false), toggle: jest.fn(() => true), + isBlockedByExpanded: jest.fn(() => false), + isBlockingExpanded: jest.fn(() => false), + setBlockedByExpanded: jest.fn(), + setBlockingExpanded: jest.fn(), }, ...overrides, } as unknown as TaskNotesPlugin; From 0c215786b3258b7d06a77760057b6fef7dbf41ca Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 14:13:36 +1200 Subject: [PATCH 20/24] refactor(dependencies): move the reltype menu builder into the tested module addReltypeMenuItems lived on TaskContextMenu where the Obsidian Menu made it awkward to unit-test, leaving the new menu-wiring below the new-code coverage bar. Move it into taskContextMenuDependencies.ts behind a minimal Menu seam and cover the type ordering, side-specific titles, and reltype threading. --- src/components/TaskContextMenu.ts | 59 +++++++---------- src/components/taskContextMenuDependencies.ts | 36 ++++++++++- .../taskContextMenuDependencies.test.ts | 64 +++++++++++++++++++ 3 files changed, 122 insertions(+), 37 deletions(-) diff --git a/src/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index b1421aaf6..157e0fe69 100644 --- a/src/components/TaskContextMenu.ts +++ b/src/components/TaskContextMenu.ts @@ -27,7 +27,11 @@ import { extractDependencyUid, formatDependencyLink, } from "../utils/dependencyUtils"; -import { addBlockedByDependency, addBlockingDependency } from "./taskContextMenuDependencies"; +import { + addBlockedByDependency, + addBlockingDependency, + addReltypeMenuItems, +} from "./taskContextMenuDependencies"; import { generateLink } from "../utils/linkUtils"; import { ContextMenu } from "./ContextMenu"; import { buildTimeblockPrefillForTask } from "../utils/timeblockPrefillUtils"; @@ -1021,10 +1025,15 @@ export class TaskContextMenu { subItem.setTitle(this.t("contextMenus.task.dependencies.addBlockedBy")); subItem.setIcon("link-2"); if (plugin.settings.enableAdvancedDependencyTypes) { - this.addReltypeMenuItems(getSubmenu(subItem), "blockedBy", (reltype) => { - this.menu.hide(); - void this.openBlockedBySelector(task, plugin, reltype); - }); + addReltypeMenuItems( + getSubmenu(subItem), + "blockedBy", + (key, params) => this.t(key, params), + (reltype) => { + this.menu.hide(); + void this.openBlockedBySelector(task, plugin, reltype); + } + ); } else { subItem.onClick(() => { this.menu.hide(); @@ -1083,10 +1092,15 @@ export class TaskContextMenu { subItem.setTitle(this.t("contextMenus.task.dependencies.addBlocking")); subItem.setIcon("git-branch-plus"); if (plugin.settings.enableAdvancedDependencyTypes) { - this.addReltypeMenuItems(getSubmenu(subItem), "blocking", (reltype) => { - this.menu.hide(); - void this.openBlockingSelector(task, plugin, reltype); - }); + addReltypeMenuItems( + getSubmenu(subItem), + "blocking", + (key, params) => this.t(key, params), + (reltype) => { + this.menu.hide(); + void this.openBlockingSelector(task, plugin, reltype); + } + ); } else { subItem.onClick(() => { this.menu.hide(); @@ -1142,33 +1156,6 @@ export class TaskContextMenu { } } - private addReltypeMenuItems( - submenu: Menu, - side: "blockedBy" | "blocking", - onPick: (reltype: TaskDependencyRelType) => void - ): void { - const order: TaskDependencyRelType[] = [ - "FINISHTOSTART", - "STARTTOSTART", - "FINISHTOFINISH", - "STARTTOFINISH", - ]; - const key: Record = { - FINISHTOSTART: "finishToStart", - STARTTOSTART: "startToStart", - FINISHTOFINISH: "finishToFinish", - STARTTOFINISH: "startToFinish", - }; - for (const reltype of order) { - submenu.addItem((item) => { - item.setTitle( - this.t(`contextMenus.task.dependencies.reltype.${side}.${key[reltype]}`) - ); - item.onClick(() => onPick(reltype)); - }); - } - } - private async openBlockedBySelector( task: TaskInfo, plugin: TaskNotesPlugin, diff --git a/src/components/taskContextMenuDependencies.ts b/src/components/taskContextMenuDependencies.ts index 77eefe440..06b6fa9d2 100644 --- a/src/components/taskContextMenuDependencies.ts +++ b/src/components/taskContextMenuDependencies.ts @@ -1,4 +1,4 @@ -import { Notice } from "obsidian"; +import { Menu, Notice } from "obsidian"; import TaskNotesPlugin from "../main"; import { TaskDependency, TaskDependencyRelType, TaskInfo } from "../types"; import { @@ -140,3 +140,37 @@ export async function addBlockingDependency({ new Notice(translate("contextMenus.task.dependencies.notices.updateFailed")); } } + +export type DependencyMenuSide = "blockedBy" | "blocking"; + +const RELTYPE_MENU_ORDER: TaskDependencyRelType[] = [ + "FINISHTOSTART", + "STARTTOSTART", + "FINISHTOFINISH", + "STARTTOFINISH", +]; + +const RELTYPE_MENU_KEY: Record = { + FINISHTOSTART: "finishToStart", + STARTTOSTART: "startToStart", + FINISHTOFINISH: "finishToFinish", + STARTTOFINISH: "startToFinish", +}; + +export function addReltypeMenuItems( + submenu: Menu, + side: DependencyMenuSide, + translate: DependencyTranslate, + onPick: (reltype: TaskDependencyRelType) => void +): void { + for (const reltype of RELTYPE_MENU_ORDER) { + submenu.addItem((item) => { + item.setTitle( + translate( + `contextMenus.task.dependencies.reltype.${side}.${RELTYPE_MENU_KEY[reltype]}` + ) + ); + item.onClick(() => onPick(reltype)); + }); + } +} diff --git a/tests/unit/components/taskContextMenuDependencies.test.ts b/tests/unit/components/taskContextMenuDependencies.test.ts index 8aed0565f..d30ef084d 100644 --- a/tests/unit/components/taskContextMenuDependencies.test.ts +++ b/tests/unit/components/taskContextMenuDependencies.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect, jest } from "@jest/globals"; import { addBlockedByDependency, addBlockingDependency, + addReltypeMenuItems, } from "../../../src/components/taskContextMenuDependencies"; +import type { Menu } from "obsidian"; import type { TaskInfo } from "../../../src/types"; function makePlugin() { @@ -33,6 +35,29 @@ function task(path: string, extra: Partial = {}): TaskInfo { return { path, ...extra } as unknown as TaskInfo; } +function fakeSubmenu() { + const items: Array<{ title: string; onClick: () => void }> = []; + const submenu = { + addItem(cb: (item: any) => void) { + const rec = { title: "", onClick: () => {} }; + const item: any = { + setTitle: (t: string) => { + rec.title = t; + return item; + }, + onClick: (fn: () => void) => { + rec.onClick = fn; + return item; + }, + }; + cb(item); + items.push(rec); + return submenu; + }, + }; + return { submenu: submenu as unknown as Menu, items }; +} + const translate = (key: string) => key; describe("taskContextMenuDependencies", () => { @@ -127,4 +152,43 @@ describe("taskContextMenuDependencies", () => { expect(plugin.taskService.updateBlockingRelationships).not.toHaveBeenCalled(); }); }); + + describe("addReltypeMenuItems", () => { + it("builds the four types in order with side titles and threads the reltype", () => { + const { submenu, items } = fakeSubmenu(); + const picks: string[] = []; + addReltypeMenuItems( + submenu, + "blocking", + (key) => key, + (rel) => { + picks.push(rel); + } + ); + + expect(items).toHaveLength(4); + expect(items.map((i) => i.title)).toEqual([ + "contextMenus.task.dependencies.reltype.blocking.finishToStart", + "contextMenus.task.dependencies.reltype.blocking.startToStart", + "contextMenus.task.dependencies.reltype.blocking.finishToFinish", + "contextMenus.task.dependencies.reltype.blocking.startToFinish", + ]); + + items.forEach((i) => i.onClick()); + expect(picks).toEqual([ + "FINISHTOSTART", + "STARTTOSTART", + "FINISHTOFINISH", + "STARTTOFINISH", + ]); + }); + + it("uses blocked-by titles for the blocked-by side", () => { + const { submenu, items } = fakeSubmenu(); + addReltypeMenuItems(submenu, "blockedBy", (key) => key, () => {}); + expect(items[0].title).toBe( + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart" + ); + }); + }); }); From 47c0f79ab9dad8a582ce415bdaace3eb1321724d Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 15:40:10 +1200 Subject: [PATCH 21/24] test(dependencies): cover forward blocking-predecessor accessors and lazy build getStartBlockingPredecessorPaths / getFinishBlockingPredecessorPaths and the build-indexes-on-first-query guard were unexercised, holding the branch's new-code coverage under the 80% gate. Add two DependencyCache cases so they run. --- .../utils/DependencyCache.constraints.test.ts | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/unit/utils/DependencyCache.constraints.test.ts b/tests/unit/utils/DependencyCache.constraints.test.ts index 1f78fb584..8c9a5a2d8 100644 --- a/tests/unit/utils/DependencyCache.constraints.test.ts +++ b/tests/unit/utils/DependencyCache.constraints.test.ts @@ -15,7 +15,7 @@ const ss = edge("STARTTOSTART"); const ff = edge("FINISHTOFINISH"); const sf = edge("STARTTOFINISH"); -async function buildCache(tasks: TaskSpec[]): Promise { +async function buildCache(tasks: TaskSpec[], build = true): Promise { const app = MockObsidian.createMockApp(); const fileByName = new Map(); for (const task of tasks) { @@ -46,7 +46,9 @@ async function buildCache(tasks: TaskSpec[]): Promise { } as never, (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) ); - await cache.buildIndexes(); + if (build) { + await cache.buildIndexes(); + } return cache; } @@ -164,4 +166,26 @@ describe("DependencyCache per-endpoint constraints (U3)", () => { expect(cache.isTaskStartBlocked(p("d1"))).toBe(true); expect(cache.isTaskBlocked(p("d2"))).toBe(false); }); + + it("forward blocking-predecessor accessors split constraints by endpoint", async () => { + const cache = await buildCache([ + { name: "a", status: "open" }, + { name: "b", status: "in-progress" }, + { name: "dep", status: "open", blockedBy: [fs("a"), ff("b")] }, + ]); + expect(cache.getStartBlockingPredecessorPaths(p("dep"))).toEqual([p("a")]); + expect(cache.getFinishBlockingPredecessorPaths(p("dep"))).toEqual([p("b")]); + }); + + it("lazily builds indexes on the first constraint query", async () => { + const cache = await buildCache( + [ + { name: "pred", status: "open" }, + { name: "dep", status: "open", blockedBy: [fs("pred")] }, + ], + false + ); + expect(cache.getStartBlockingPredecessorPaths(p("dep"))).toEqual([p("pred")]); + expect(cache.isTaskStartBlocked(p("dep"))).toBe(true); + }); }); From 35656c509eaa0db059897ddeea3f6234a29ebaab Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 19:09:21 +1200 Subject: [PATCH 22/24] fix(task-card): stop re-expanding relationship sub-lists on every render The blocked-by / blocking sub-list was rebuilt (clear + refetch + re-render) on every card render via restoreRelationshipExpansion, so a Bases TaskList / Kanban card recreated on EVERY_TASK_UPDATED re-fired the async expansion and oscillated open/closed on frontmatter edits. Expansion is now DOM-only: built when the user expands, not restored on later renders (matching main's behavior). --- src/ui/taskCardSecondaryBadges.ts | 40 ------------------------------- 1 file changed, 40 deletions(-) diff --git a/src/ui/taskCardSecondaryBadges.ts b/src/ui/taskCardSecondaryBadges.ts index b3659b62a..aee22cfc9 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -328,14 +328,6 @@ function renderDependencyToggles( }); if (toggle) { toggle.dataset.count = String(blockingCount); - restoreRelationshipExpansion({ - toggle, - expanded: plugin.expandedProjectsService?.isBlockingExpanded(task.path) ?? false, - expandedClass: "task-card__blocking-toggle--expanded", - render: () => handlers.toggleBlockingTasks(card, task, true), - plugin, - task, - }); } } @@ -351,42 +343,10 @@ function renderDependencyToggles( if (toggle) { toggle.dataset.count = String(blocked.count); toggle.dataset.blockedState = blocked.state; - restoreRelationshipExpansion({ - toggle, - expanded: plugin.expandedProjectsService?.isBlockedByExpanded(task.path) ?? false, - expandedClass: "task-card__blocked-toggle--expanded", - render: () => handlers.toggleBlockedByTasks(card, task, true), - plugin, - task, - }); } } } -// A card re-render rebuilds the toggle fresh, so the on/off state lives in the expansion -// service, not the DOM; re-open the relationship list here when the service says it was open. -function restoreRelationshipExpansion(options: { - toggle: HTMLElement; - expanded: boolean; - expandedClass: string; - render: () => Promise; - plugin: TaskNotesPlugin; - task: TaskInfo; -}): void { - if (!options.expanded) { - return; - } - options.toggle.classList.add(options.expandedClass); - options.render().catch((error: unknown) => { - getTaskCardBadgeLogger(options.plugin).error("Error restoring relationship expansion", { - category: "internal", - operation: "restore-relationship-expansion", - details: { taskPath: options.task.path }, - error, - }); - }); -} - export function renderTaskCardSecondaryBadges( options: RenderTaskCardSecondaryBadgesOptions ): void { From 101ca2524103e62cc1d830cad48f077640b1a9d0 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 19:17:34 +1200 Subject: [PATCH 23/24] refactor(task-card): drop now-unused blocked/blocking expansion persistence With the render-time restore gone, ExpandedProjectsService's blocked/blocking expansion tracking had only dead reads and write-only setters. Remove the two sets, their four accessors, and the rename/clear plumbing, plus the setter call sites in the toggle handlers. Blocked/blocking expansion is DOM-only now, so the handlers no longer need a plugin reference. Deletes the obsolete service test. --- src/services/ExpandedProjectsService.ts | 35 +------------ src/ui/taskCardSecondaryBadges.ts | 9 ++-- .../services/ExpandedProjectsService.test.ts | 51 ------------------- tests/unit/ui/TaskCard.test.ts | 4 -- tests/unit/ui/taskCardSecondaryBadges.test.ts | 4 -- 5 files changed, 4 insertions(+), 99 deletions(-) delete mode 100644 tests/unit/services/ExpandedProjectsService.test.ts diff --git a/src/services/ExpandedProjectsService.ts b/src/services/ExpandedProjectsService.ts index 99df702e4..a6aa347f8 100644 --- a/src/services/ExpandedProjectsService.ts +++ b/src/services/ExpandedProjectsService.ts @@ -4,8 +4,6 @@ export class ExpandedProjectsService { private plugin: TaskNotesPlugin; private expandedProjects: Set = new Set(); private collapsedDefaultExpandedProjects: Set = new Set(); - private readonly expandedBlockedBy: Set = new Set(); - private readonly expandedBlocking: Set = new Set(); constructor(plugin: TaskNotesPlugin) { this.plugin = plugin; @@ -71,41 +69,12 @@ export class ExpandedProjectsService { } } - isBlockedByExpanded(taskPath: string): boolean { - return this.expandedBlockedBy.has(taskPath); - } - - setBlockedByExpanded(taskPath: string, expanded: boolean): void { - if (expanded) { - this.expandedBlockedBy.add(taskPath); - } else { - this.expandedBlockedBy.delete(taskPath); - } - } - - isBlockingExpanded(taskPath: string): boolean { - return this.expandedBlocking.has(taskPath); - } - - setBlockingExpanded(taskPath: string, expanded: boolean): void { - if (expanded) { - this.expandedBlocking.add(taskPath); - } else { - this.expandedBlocking.delete(taskPath); - } - } - /** * Preserve expansion when a task file is renamed. */ renamePath(oldPath: string, newPath: string): void { if (oldPath === newPath) return; - for (const set of [ - this.expandedProjects, - this.collapsedDefaultExpandedProjects, - this.expandedBlockedBy, - this.expandedBlocking, - ]) { + for (const set of [this.expandedProjects, this.collapsedDefaultExpandedProjects]) { if (set.delete(oldPath)) { set.add(newPath); } @@ -125,8 +94,6 @@ export class ExpandedProjectsService { clearAll(): void { this.expandedProjects.clear(); this.collapsedDefaultExpandedProjects.clear(); - this.expandedBlockedBy.clear(); - this.expandedBlocking.clear(); } /** diff --git a/src/ui/taskCardSecondaryBadges.ts b/src/ui/taskCardSecondaryBadges.ts index aee22cfc9..e5e132ddd 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -173,7 +173,6 @@ function createChevronClickHandler( function createBlockingToggleClickHandler( task: TaskInfo, - plugin: TaskNotesPlugin, card: HTMLElement, handlers: TaskCardSecondaryBadgeHandlers ): () => void { @@ -184,7 +183,6 @@ function createBlockingToggleClickHandler( return; } const expanded = toggle.classList.toggle("task-card__blocking-toggle--expanded"); - plugin.expandedProjectsService?.setBlockingExpanded(task.path, expanded); await handlers.toggleBlockingTasks(card, task, expanded); })(); }; @@ -221,10 +219,9 @@ export function syncTaskCardBlockedByExpansionControls( export async function toggleTaskCardBlockedByExpansion( options: ToggleBlockedByExpansionOptions ): Promise { - const { card, task, plugin, handlers } = options; + const { card, task, handlers } = options; const expanded = !card.querySelector(".task-card__blocked-by"); syncTaskCardBlockedByExpansionControls(card, expanded); - plugin.expandedProjectsService?.setBlockedByExpanded(task.path, expanded); await handlers.toggleBlockedByTasks(card, task, expanded); } @@ -324,7 +321,7 @@ function renderDependencyToggles( className: "task-card__blocking-toggle is-visible", icon: "git-branch", tooltip: toggleLabel, - onClick: createBlockingToggleClickHandler(task, plugin, card, handlers), + onClick: createBlockingToggleClickHandler(task, card, handlers), }); if (toggle) { toggle.dataset.count = String(blockingCount); @@ -473,7 +470,7 @@ function updateBlockingToggle(options: UpdateTaskCardSecondaryBadgesOptions): vo className: "task-card__blocking-toggle is-visible", icon: "git-branch", tooltip: toggleLabel, - onClick: createBlockingToggleClickHandler(task, plugin, card, handlers), + onClick: createBlockingToggleClickHandler(task, card, handlers), }); if (!shouldExist) { diff --git a/tests/unit/services/ExpandedProjectsService.test.ts b/tests/unit/services/ExpandedProjectsService.test.ts deleted file mode 100644 index 33cfe7c3d..000000000 --- a/tests/unit/services/ExpandedProjectsService.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ExpandedProjectsService } from "../../../src/services/ExpandedProjectsService"; -import type TaskNotesPlugin from "../../../src/main"; - -function createService(): ExpandedProjectsService { - return new ExpandedProjectsService({} as TaskNotesPlugin); -} - -describe("ExpandedProjectsService relationship expansion state", () => { - it("tracks blocked-by expansion independently of blocking and subtasks", () => { - const svc = createService(); - expect(svc.isBlockedByExpanded("a.md")).toBe(false); - - svc.setBlockedByExpanded("a.md", true); - expect(svc.isBlockedByExpanded("a.md")).toBe(true); - expect(svc.isBlockingExpanded("a.md")).toBe(false); - expect(svc.isExpanded("a.md")).toBe(false); - - svc.setBlockedByExpanded("a.md", false); - expect(svc.isBlockedByExpanded("a.md")).toBe(false); - }); - - it("tracks blocking expansion independently of blocked-by", () => { - const svc = createService(); - svc.setBlockingExpanded("b.md", true); - expect(svc.isBlockingExpanded("b.md")).toBe(true); - expect(svc.isBlockedByExpanded("b.md")).toBe(false); - }); - - it("preserves relationship expansion across a rename", () => { - const svc = createService(); - svc.setBlockedByExpanded("old.md", true); - svc.setBlockingExpanded("old.md", true); - - svc.renamePath("old.md", "new.md"); - - expect(svc.isBlockedByExpanded("new.md")).toBe(true); - expect(svc.isBlockingExpanded("new.md")).toBe(true); - expect(svc.isBlockedByExpanded("old.md")).toBe(false); - }); - - it("clearAll resets relationship expansion", () => { - const svc = createService(); - svc.setBlockedByExpanded("a.md", true); - svc.setBlockingExpanded("a.md", true); - - svc.clearAll(); - - expect(svc.isBlockedByExpanded("a.md")).toBe(false); - expect(svc.isBlockingExpanded("a.md")).toBe(false); - }); -}); diff --git a/tests/unit/ui/TaskCard.test.ts b/tests/unit/ui/TaskCard.test.ts index 1c33a1653..025184b14 100644 --- a/tests/unit/ui/TaskCard.test.ts +++ b/tests/unit/ui/TaskCard.test.ts @@ -193,10 +193,6 @@ describe('TaskCard Component', () => { expandedProjectsService: { isExpanded: jest.fn(() => false), toggle: jest.fn(() => true), - isBlockedByExpanded: jest.fn(() => false), - isBlockingExpanded: jest.fn(() => false), - setBlockedByExpanded: jest.fn(), - setBlockingExpanded: jest.fn(), }, i18n: { translate: jest.fn((key, vars) => { diff --git a/tests/unit/ui/taskCardSecondaryBadges.test.ts b/tests/unit/ui/taskCardSecondaryBadges.test.ts index 06a0472a4..66c9c7e31 100644 --- a/tests/unit/ui/taskCardSecondaryBadges.test.ts +++ b/tests/unit/ui/taskCardSecondaryBadges.test.ts @@ -58,10 +58,6 @@ function createPlugin(overrides: Partial = {}): TaskNotesPlugin expandedProjectsService: { isExpanded: jest.fn(() => false), toggle: jest.fn(() => true), - isBlockedByExpanded: jest.fn(() => false), - isBlockingExpanded: jest.fn(() => false), - setBlockedByExpanded: jest.fn(), - setBlockingExpanded: jest.fn(), }, ...overrides, } as unknown as TaskNotesPlugin; From 9d8be168f446d97c647d3fb7d4bc10639f553d65 Mon Sep 17 00:00:00 2001 From: renatomen Date: Fri, 24 Jul 2026 23:33:58 +1200 Subject: [PATCH 24/24] fix(bases): skip the data-update re-render when the data is unchanged Activating a Bases leaf re-fires the onDataUpdated lifecycle with unchanged data, and the base scheduled a full re-render (empty the items container and recreate every card) unconditionally. That tore down a card's expanded blocked-by / blocking sub-list on a mere click or focus change, since the expansion is DOM-only and not restored on re-render. Gate the re-render on a data signature the same way CalendarView already does: when the signature is unchanged since the last render, skip. Implemented on the base so both TaskListView and KanbanView are covered. --- src/bases/BasesViewBase.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 037e91880..78cf67a10 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -2,6 +2,10 @@ import { Component, App, Notice } from "obsidian"; import type { BasesPropertyId, BasesQueryResult, BasesViewConfig, EventRef } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesDataAdapter } from "./BasesDataAdapter"; +import { + buildCalendarDataSignature, + buildCalendarDataSignaturePropertyIds, +} from "./calendarDataSignature"; import { PropertyMappingService } from "./PropertyMappingService"; import { TaskInfo } from "../types"; import { convertInternalToUserProperties } from "../utils/propertyMapping"; @@ -99,6 +103,7 @@ export abstract class BasesViewBase extends Component { protected taskUpdateListener: EventRef[] | null = null; protected updateDebounceTimer: number | null = null; protected dataUpdateDebounceTimer: number | null = null; + private lastDataUpdateSignature: string | null = null; private restoreConfigChangeHook: (() => void) | null = null; protected relevantPathsCache: Set = new Set(); @@ -187,6 +192,13 @@ export abstract class BasesViewBase extends Component { return; } + // Skip when data is unchanged (e.g. a leaf activation); re-rendering drops expanded card state. + const dataSignature = this.computeDataUpdateSignature(); + if (dataSignature !== null && dataSignature === this.lastDataUpdateSignature) { + return; + } + this.lastDataUpdateSignature = dataSignature; + this.dataUpdateDebounceTimer = scheduleBasesDataUpdateRender({ currentTimer: this.dataUpdateDebounceTimer, scheduler: this.getTimeoutScheduler(), @@ -215,6 +227,26 @@ export abstract class BasesViewBase extends Component { }); } + /** Data signature used to gate re-renders; null opts out of gating for this cycle. */ + protected computeDataUpdateSignature(): string | null { + try { + let visiblePropertyIds: readonly unknown[] = []; + try { + visiblePropertyIds = this.dataAdapter.getVisiblePropertyIds(); + } catch { + // visible-property config can be absent during early setup + } + const propertyIds = buildCalendarDataSignaturePropertyIds({ + mapField: (field) => this.plugin?.fieldMapper?.toUserField(field), + visiblePropertyIds, + showPropertyBasedEvents: false, + }); + return buildCalendarDataSignature(this.dataAdapter.extractDataItems(), propertyIds); + } catch { + return null; + } + } + /** * Update the cache of relevant paths for efficient update checking. * Called when data changes to avoid expensive lookups on every task update.