From 317ebbf0df967532cb408fbe8b56c97c35fe4378 Mon Sep 17 00:00:00 2001 From: renatomen Date: Thu, 23 Jul 2026 12:53:07 +1200 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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. From 2e73960f6df8fb97c8fd511504829e97e2490a24 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 13:37:44 +1200 Subject: [PATCH 25/33] feat(dependencies): report which start categories a status set is missing The readiness row and the enable-time modal both need to name the absent category, which a boolean readiness check cannot carry. Scoped to Not started and Started; a missing Completed category is a separate concern. --- src/settings/defaults.ts | 11 ++++ .../settings/statusStartReadiness.test.ts | 64 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/unit/settings/statusStartReadiness.test.ts diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index afe8cfeb6..892b87740 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -104,6 +104,17 @@ export function countStatusCategories(statuses: StatusConfig[]): Record counts[category] === 0); +} + // Default priority configuration matches current hardcoded behavior export const DEFAULT_PRIORITIES: PriorityConfig[] = [ { diff --git a/tests/unit/settings/statusStartReadiness.test.ts b/tests/unit/settings/statusStartReadiness.test.ts new file mode 100644 index 000000000..e3d2d0bc6 --- /dev/null +++ b/tests/unit/settings/statusStartReadiness.test.ts @@ -0,0 +1,64 @@ +import { DEFAULT_STATUSES, findMissingStartCategories } 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("findMissingStartCategories", () => { + it("returns nothing missing for the stock statuses", () => { + expect(findMissingStartCategories(DEFAULT_STATUSES)).toEqual([]); + }); + + it("returns Started when no status carries it", () => { + expect( + findMissingStartCategories([ + status({ id: "a", category: "planned" }), + status({ id: "b", category: "completed", isCompleted: true }), + ]) + ).toEqual(["in-progress"]); + }); + + it("returns Not started when every status is Started or Completed", () => { + expect( + findMissingStartCategories([ + status({ id: "a", category: "in-progress" }), + status({ id: "b", category: "completed", isCompleted: true }), + ]) + ).toEqual(["planned"]); + }); + + it("returns both for an empty list", () => { + expect(findMissingStartCategories([])).toEqual(["planned", "in-progress"]); + }); + + it("returns both for an undefined list", () => { + expect(findMissingStartCategories(undefined)).toEqual(["planned", "in-progress"]); + }); + + it("counts an isCompleted-only status as Completed", () => { + expect( + findMissingStartCategories([ + status({ id: "a", isCompleted: true }), + status({ id: "b", category: "in-progress" }), + ]) + ).toEqual(["planned"]); + }); + + it("counts a status without a category as Not started", () => { + expect( + findMissingStartCategories([ + status({ id: "a" }), + status({ id: "b", category: "in-progress" }), + ]) + ).toEqual([]); + }); +}); From a9fdb1c8405f3b5220b53da33ae35f9f8a1dec52 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 13:47:15 +1200 Subject: [PATCH 26/33] feat(settings): open a settings sub-tab from outside the settings view Tab content is cached and revealed by class toggle, so a tab the user has already visited shows a DOM built before whatever prompted the navigation. The new entry point clears and re-renders instead; click and keyboard navigation keep the cheaper cache-and-reveal path. --- src/bootstrap/pluginRuntime.ts | 3 +- src/main.ts | 1 + src/settings/TaskNotesSettingTab.ts | 49 +++++-- .../settings/settingTabNavigation.test.ts | 127 ++++++++++++++++++ 4 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 tests/unit/settings/settingTabNavigation.test.ts diff --git a/src/bootstrap/pluginRuntime.ts b/src/bootstrap/pluginRuntime.ts index d56b93e9c..81e3ccf11 100644 --- a/src/bootstrap/pluginRuntime.ts +++ b/src/bootstrap/pluginRuntime.ts @@ -30,7 +30,8 @@ export async function initializePluginRuntime(plugin: TaskNotesPlugin): Promise< registerRibbonIcons(plugin); plugin.commandRegistry.register(); registerCliHandlers(plugin); - plugin.addSettingTab(new TaskNotesSettingTab(plugin.app, plugin)); + plugin.settingTab = new TaskNotesSettingTab(plugin.app, plugin); + plugin.addSettingTab(plugin.settingTab); } export async function cleanupPluginRuntime(plugin: TaskNotesPlugin): Promise { diff --git a/src/main.ts b/src/main.ts index d10796aa9..aa9abefc1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -168,6 +168,7 @@ export default class TaskNotesPlugin extends Plugin { taskActionCoordinator: import("./ui/TaskActionCoordinator").TaskActionCoordinator; settingsLifecycleService: import("./services/SettingsLifecycleService").SettingsLifecycleService; commandRegistry: import("./commands/TranslatedCommandRegistry").TranslatedCommandRegistry; + settingTab: import("./settings/TaskNotesSettingTab").TaskNotesSettingTab; // Editor services taskLinkDetectionService?: import("./services/TaskLinkDetectionService").TaskLinkDetectionService; diff --git a/src/settings/TaskNotesSettingTab.ts b/src/settings/TaskNotesSettingTab.ts index 20cf5c5ac..48a05141f 100644 --- a/src/settings/TaskNotesSettingTab.ts +++ b/src/settings/TaskNotesSettingTab.ts @@ -172,12 +172,45 @@ export class TaskNotesSettingTab extends PluginSettingTab { }); } + /** + * Opens a tab from outside the settings view, ignoring unknown ids. + * Always re-renders: cached content can predate the change that prompted the navigation. + */ + navigateToTab(tabId: string): void { + const tabConfig = this.getTabConfigurations().find((tab) => tab.id === tabId); + const tabContent = this.tabContents[tabId]; + if (!tabConfig || !tabContent) { + return; + } + + this.activeTab = tabId; + this.updateActiveTabElements(tabId); + + tabContent.empty(); + tabConfig.renderFn(tabContent, this.plugin, this.debouncedSave); + + this.focusTabButton(tabId); + } + private switchTab(tabId: string): void { // Update active tab state // const previousTab = this.activeTab; this.activeTab = tabId; - // Update tab button states + this.updateActiveTabElements(tabId); + + const activeTabContent = this.tabContents[tabId]; + if (activeTabContent && activeTabContent.children.length === 0) { + const tabConfig = this.getTabConfigurations().find((tab) => tab.id === tabId); + if (tabConfig) { + tabConfig.renderFn(activeTabContent, this.plugin, this.debouncedSave); + } + } + + this.focusTabButton(tabId); + } + + private updateActiveTabElements(tabId: string): void { this.containerEl.querySelectorAll(".settings-tab-button").forEach((button) => { const isActive = button.id === `tab-button-${tabId}`; button.classList.toggle("active", isActive); @@ -187,24 +220,14 @@ export class TaskNotesSettingTab extends PluginSettingTab { button.setAttribute("tabindex", isActive ? "0" : "-1"); }); - // Update tab content states this.containerEl.querySelectorAll(".settings-tab-content").forEach((content) => { const isActive = content.id === `settings-tab-${tabId}`; content.classList.toggle("active", isActive); content.classList.toggle("settings-view__tab-content--active", isActive); }); + } - // Render the new active tab content if it hasn't been rendered yet - const activeTabContent = this.tabContents[tabId]; - if (activeTabContent && activeTabContent.children.length === 0) { - // Find the tab configuration and render it - const tabConfig = this.getTabConfigurations().find((tab) => tab.id === tabId); - if (tabConfig) { - tabConfig.renderFn(activeTabContent, this.plugin, this.debouncedSave); - } - } - - // Focus the newly active tab button + private focusTabButton(tabId: string): void { window.setTimeout(() => { const activeTabButton = this.containerEl.querySelector( `#tab-button-${tabId}` diff --git a/tests/unit/settings/settingTabNavigation.test.ts b/tests/unit/settings/settingTabNavigation.test.ts new file mode 100644 index 000000000..310210f1e --- /dev/null +++ b/tests/unit/settings/settingTabNavigation.test.ts @@ -0,0 +1,127 @@ +import TaskNotesPlugin from "../../../src/main"; +import { TaskNotesSettingTab } from "../../../src/settings/TaskNotesSettingTab"; +import { DEFAULT_SETTINGS } from "../../../src/settings/defaults"; +import { createI18nService } from "../../../src/i18n"; +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); +}; + +function createTab(): { tab: TaskNotesSettingTab; plugin: TaskNotesPlugin } { + (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: JSON.parse( + JSON.stringify(DEFAULT_SETTINGS.customStatuses) + ) as StatusConfig[], + }; + (plugin as any).i18n = createI18nService(); + (plugin as any).registerEvent = jest.fn(); + (plugin as any).manifest = { version: "0.0.0" }; + (plugin as any).fieldMapper = { + isPropertyForField: jest.fn(() => false), + toUserField: jest.fn((field: any) => field), + toInternalField: jest.fn((field: any) => field), + }; + + return { tab: new TaskNotesSettingTab(app, plugin), plugin }; +} + +function tabContent(tab: TaskNotesSettingTab, tabId: string): HTMLElement { + return tab.containerEl.querySelector(`#settings-tab-${tabId}`) as HTMLElement; +} + +function tabButton(tab: TaskNotesSettingTab, tabId: string): HTMLElement { + return tab.containerEl.querySelector(`#tab-button-${tabId}`) as HTMLElement; +} + +describe("Settings tab programmatic navigation", () => { + it("renders a tab that has never been rendered", () => { + const { tab } = createTab(); + tab.display(); + expect(tabContent(tab, "features").children.length).toBe(0); + + tab.navigateToTab("features"); + + expect(tabContent(tab, "features").children.length).toBeGreaterThan(0); + }); + + it("re-renders an already rendered tab so it reflects settings changed since", () => { + const { tab, plugin } = createTab(); + plugin.settings.customStatuses[0].label = "Label before"; + tab.display(); + + tab.navigateToTab("task-properties"); + expect(tabContent(tab, "task-properties").textContent).toContain("Label before"); + + plugin.settings.customStatuses[0].label = "Label after"; + tab.navigateToTab("task-properties"); + + expect(tabContent(tab, "task-properties").textContent).toContain("Label after"); + expect(tabContent(tab, "task-properties").textContent).not.toContain("Label before"); + }); + + it("moves the active state onto the target button and off the previous one", () => { + const { tab } = createTab(); + tab.display(); + + tab.navigateToTab("features"); + + const general = tabButton(tab, "general"); + const features = tabButton(tab, "features"); + expect(features.classList.contains("active")).toBe(true); + expect(features.classList.contains("is-active")).toBe(true); + expect(features.getAttribute("aria-selected")).toBe("true"); + expect(general.classList.contains("active")).toBe(false); + expect(general.classList.contains("is-active")).toBe(false); + expect(general.getAttribute("aria-selected")).toBe("false"); + expect( + tabContent(tab, "features").classList.contains("settings-view__tab-content--active") + ).toBe(true); + expect( + tabContent(tab, "general").classList.contains("settings-view__tab-content--active") + ).toBe(false); + }); + + it("focuses the target tab button", () => { + jest.useFakeTimers(); + try { + const { tab } = createTab(); + document.body.appendChild(tab.containerEl); + tab.display(); + + tab.navigateToTab("features"); + jest.runOnlyPendingTimers(); + + expect(document.activeElement).toBe(tabButton(tab, "features")); + } finally { + jest.useRealTimers(); + } + }); + + it("ignores an unknown tab id", () => { + const { tab } = createTab(); + tab.display(); + + expect(() => tab.navigateToTab("dependencies")).not.toThrow(); + + expect(tabButton(tab, "general").classList.contains("active")).toBe(true); + expect(tabContent(tab, "general").classList.contains("active")).toBe(true); + }); + + it("does nothing when the settings view has never been displayed", () => { + const { tab } = createTab(); + + expect(() => tab.navigateToTab("features")).not.toThrow(); + }); +}); From 6ddc9ed9ac54be0b7d44a548f599a9f50aceff23 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 14:00:28 +1200 Subject: [PATCH 27/33] feat(dependencies): acknowledge missing start categories before enabling Turning on advanced dependency types with no Started status silently degrades start-anchored relationships to releasing on completion. The modal names the missing category and offers a route to the fix, so the acknowledgement is not a dead end. Resolves a three-way choice rather than a boolean, and treats dismissal as going back. --- i18n.manifest.json | 7 ++ i18n.state.json | 56 +++++++++ src/i18n/resources/en.ts | 12 ++ .../DependencyReadinessConfirmationModal.ts | 89 ++++++++++++++ .../dependencyReadinessConfirmation.test.ts | 111 ++++++++++++++++++ 5 files changed, 275 insertions(+) create mode 100644 src/modals/DependencyReadinessConfirmationModal.ts create mode 100644 tests/unit/modals/dependencyReadinessConfirmation.test.ts diff --git a/i18n.manifest.json b/i18n.manifest.json index 5e6a487d3..4ac04d7ec 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1707,6 +1707,13 @@ "modals.storageLocation.finalNote.switch": "18320a797158845d4d3f4f8bd4ca2ef763f15a77", "modals.storageLocation.buttons.migrate": "6149a2aa0c490673da7efc5dbf659a055c076aaa", "modals.storageLocation.buttons.switch": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", + "modals.dependencyReadiness.title": "708e148395a14028d3a5a53fbef8eb8a52ebf043", + "modals.dependencyReadiness.missing": "6f0ceebbe754b904050d85e4ab77fac9b1bf2c86", + "modals.dependencyReadiness.consequence": "5c8bcbfc462418ce13b3f863dde8e3365765f672", + "modals.dependencyReadiness.remedy": "ac55d0e50a8fae67dcc885b200cc8dc00fc2c88d", + "modals.dependencyReadiness.buttons.goBack": "e84712fbf4eee89d1d896d75da5b5b9ceca43eac", + "modals.dependencyReadiness.buttons.justEnable": "9eb4be85f026ff28a7c7a31e3be6de3e5e9f1ba3", + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": "f057a13c2fe88540ecde8079443743809c4bddf6", "modals.dueDate.title": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "modals.dueDate.taskLabel": "f8df43b746818dcb3d84938e6bd67c36239989aa", "modals.dueDate.sections.dateTime": "00064e0ca9d3ef59147d44f87b8c59772088ec13", diff --git a/i18n.state.json b/i18n.state.json index 1ce39808c..8afa005dd 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -6733,6 +6733,13 @@ "source": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "translation": "df7f83027b9bed1c2c26c22375b6ab9926f1eb65" }, + "modals.dependencyReadiness.title": null, + "modals.dependencyReadiness.missing": null, + "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.remedy": null, + "modals.dependencyReadiness.buttons.goBack": null, + "modals.dependencyReadiness.buttons.justEnable": null, + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": null, "modals.dueDate.title": { "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "translation": "ea17eb3a01fafaa08a2c6068709006374270596c" @@ -15638,6 +15645,13 @@ "source": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "translation": "1155ca941c4063adf6bbca335cdabf6435b05830" }, + "modals.dependencyReadiness.title": null, + "modals.dependencyReadiness.missing": null, + "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.remedy": null, + "modals.dependencyReadiness.buttons.goBack": null, + "modals.dependencyReadiness.buttons.justEnable": null, + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": null, "modals.dueDate.title": { "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "translation": "912590ebaf2895ce8451d7fbdd2fd45532f39c1e" @@ -24543,6 +24557,13 @@ "source": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "translation": "3e46765817dc6f2609415e3ac4d98ed15f1268ff" }, + "modals.dependencyReadiness.title": null, + "modals.dependencyReadiness.missing": null, + "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.remedy": null, + "modals.dependencyReadiness.buttons.goBack": null, + "modals.dependencyReadiness.buttons.justEnable": null, + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": null, "modals.dueDate.title": { "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "translation": "7536e48e850a487db99105674f802c5f8e7e69b1" @@ -33448,6 +33469,13 @@ "source": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "translation": "d0307d5cb896750ab9171a27ae7b4f90c4ef5b18" }, + "modals.dependencyReadiness.title": null, + "modals.dependencyReadiness.missing": null, + "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.remedy": null, + "modals.dependencyReadiness.buttons.goBack": null, + "modals.dependencyReadiness.buttons.justEnable": null, + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": null, "modals.dueDate.title": { "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "translation": "66bd7e64ba93ecb64a23e690462b5264b3906040" @@ -42353,6 +42381,13 @@ "source": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "translation": "c22873de7d8942c694cbb1f9fe90233e4a9c0632" }, + "modals.dependencyReadiness.title": null, + "modals.dependencyReadiness.missing": null, + "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.remedy": null, + "modals.dependencyReadiness.buttons.goBack": null, + "modals.dependencyReadiness.buttons.justEnable": null, + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": null, "modals.dueDate.title": { "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "translation": "3a62361e71f557ce405601c25bd33821c05e8c15" @@ -51258,6 +51293,13 @@ "source": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "translation": "963e91b6bb2a5c0619a93fb548590e63fb98f18b" }, + "modals.dependencyReadiness.title": null, + "modals.dependencyReadiness.missing": null, + "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.remedy": null, + "modals.dependencyReadiness.buttons.goBack": null, + "modals.dependencyReadiness.buttons.justEnable": null, + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": null, "modals.dueDate.title": { "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "translation": "e5b59351885f6cc4ffbc6bf25925c52e6246ca7f" @@ -60163,6 +60205,13 @@ "source": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "translation": "a1c0c23d9d221acd81e19e6483ccc3b8dab1f87e" }, + "modals.dependencyReadiness.title": null, + "modals.dependencyReadiness.missing": null, + "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.remedy": null, + "modals.dependencyReadiness.buttons.goBack": null, + "modals.dependencyReadiness.buttons.justEnable": null, + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": null, "modals.dueDate.title": { "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "translation": "23ba71c9ff6b67a5966dea1e8b9eaa24137a6575" @@ -69068,6 +69117,13 @@ "source": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "translation": "c1162f368fa18fed1e0d512536ea661c209b3ccf" }, + "modals.dependencyReadiness.title": null, + "modals.dependencyReadiness.missing": null, + "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.remedy": null, + "modals.dependencyReadiness.buttons.goBack": null, + "modals.dependencyReadiness.buttons.justEnable": null, + "modals.dependencyReadiness.buttons.enableAndOpenStatuses": null, "modals.dueDate.title": { "source": "ecf07cea2d2a6681f6074ac3d82bcae990c04a8d", "translation": "f026d9b9641613528baa06c0e18ed8d76071aab5" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 7ea9ca89f..825cac5ea 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2869,6 +2869,18 @@ export const en: TranslationTree = { switch: "Switch storage", }, }, + dependencyReadiness: { + title: "Enable advanced dependency types?", + missing: "No status is categorized as {categories}.", + consequence: + "Start-to-start and start-to-finish dependencies will release only when the predecessor completes, not when it starts.", + remedy: "To change that, assign the missing category to one of your existing statuses in the task properties settings.", + buttons: { + goBack: "Go back", + justEnable: "Just enable", + enableAndOpenStatuses: "Enable and go to statuses...", + }, + }, dueDate: { title: "Set due date", taskLabel: "Task: {title}", diff --git a/src/modals/DependencyReadinessConfirmationModal.ts b/src/modals/DependencyReadinessConfirmationModal.ts new file mode 100644 index 000000000..d497c65ed --- /dev/null +++ b/src/modals/DependencyReadinessConfirmationModal.ts @@ -0,0 +1,89 @@ +import { Modal, Setting } from "obsidian"; +import TaskNotesPlugin from "../main"; +import { StatusCategory } from "../types"; + +export type DependencyReadinessChoice = "go-back" | "enable" | "enable-and-open-statuses"; + +const CATEGORY_LABEL_KEYS: Record = { + planned: "settings.taskProperties.taskStatuses.badges.planned", + "in-progress": "settings.taskProperties.taskStatuses.badges.inProgress", + completed: "settings.taskProperties.taskStatuses.badges.completed", +}; + +const CHOICES: ReadonlyArray<[string, DependencyReadinessChoice]> = [ + ["goBack", "go-back"], + ["justEnable", "enable"], + ["enableAndOpenStatuses", "enable-and-open-statuses"], +]; + +/** + * Acknowledgement for enabling advanced dependency types while a start category has no + * status. Callers pass the missing categories; the modal reads no settings of its own. + */ +export class DependencyReadinessConfirmationModal extends Modal { + private plugin: TaskNotesPlugin; + private missingCategories: StatusCategory[]; + private resolve: ((choice: DependencyReadinessChoice) => void) | null = null; + + constructor(plugin: TaskNotesPlugin, missingCategories: StatusCategory[]) { + super(plugin.app); + this.plugin = plugin; + this.missingCategories = missingCategories; + } + + private t(key: string, params?: Record): string { + return this.plugin.i18n.translate(key, params); + } + + public show(): Promise { + return new Promise((resolve) => { + this.resolve = resolve; + this.open(); + }); + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + + new Setting(contentEl).setName(this.t("modals.dependencyReadiness.title")).setHeading(); + + const categories = this.missingCategories + .map((category) => this.t(CATEGORY_LABEL_KEYS[category])) + .join(" or "); + + contentEl.createEl("p", { + text: this.t("modals.dependencyReadiness.missing", { categories }), + }); + contentEl.createEl("p", { text: this.t("modals.dependencyReadiness.consequence") }); + contentEl.createEl("p", { text: this.t("modals.dependencyReadiness.remedy") }); + + const buttonContainer = contentEl.createDiv({ cls: "modal-button-container" }); + const buttons = CHOICES.map(([labelKey, choice]) => { + const button = buttonContainer.createEl("button", { + text: this.t(`modals.dependencyReadiness.buttons.${labelKey}`), + }); + button.addEventListener("click", () => { + this.settle(choice); + this.close(); + }); + return button; + }); + + const primaryButton = buttons[buttons.length - 1]; + primaryButton.classList.add("mod-cta"); + window.setTimeout(() => { + primaryButton.focus(); + }, 50); + } + + onClose(): void { + this.contentEl.empty(); + this.settle("go-back"); + } + + private settle(choice: DependencyReadinessChoice): void { + this.resolve?.(choice); + this.resolve = null; + } +} diff --git a/tests/unit/modals/dependencyReadinessConfirmation.test.ts b/tests/unit/modals/dependencyReadinessConfirmation.test.ts new file mode 100644 index 000000000..e5620c919 --- /dev/null +++ b/tests/unit/modals/dependencyReadinessConfirmation.test.ts @@ -0,0 +1,111 @@ +import TaskNotesPlugin from "../../../src/main"; +import { createI18nService } from "../../../src/i18n"; +import { DependencyReadinessConfirmationModal } from "../../../src/modals/DependencyReadinessConfirmationModal"; +import { StatusCategory } from "../../../src/types"; + +function createPlugin(): TaskNotesPlugin { + return { app: {}, i18n: createI18nService() } as unknown as TaskNotesPlugin; +} + +function openModal(missingCategories: StatusCategory[]) { + const modal = new DependencyReadinessConfirmationModal(createPlugin(), missingCategories); + const choice = modal.show(); + return { modal, choice }; +} + +function buttons(modal: DependencyReadinessConfirmationModal): HTMLButtonElement[] { + return Array.from(modal.contentEl.querySelectorAll(".modal-button-container button")); +} + +function bodyText(modal: DependencyReadinessConfirmationModal): string { + return modal.contentEl.textContent ?? ""; +} + +describe("Dependency readiness confirmation modal", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("resolves go-back when the first button is pressed", async () => { + const { modal, choice } = openModal(["in-progress"]); + buttons(modal)[0].click(); + await expect(choice).resolves.toBe("go-back"); + }); + + it("resolves enable when the second button is pressed", async () => { + const { modal, choice } = openModal(["in-progress"]); + buttons(modal)[1].click(); + await expect(choice).resolves.toBe("enable"); + }); + + it("resolves enable-and-open-statuses when the third button is pressed", async () => { + const { modal, choice } = openModal(["in-progress"]); + buttons(modal)[2].click(); + await expect(choice).resolves.toBe("enable-and-open-statuses"); + }); + + it("resolves go-back when the modal closes without a button press", async () => { + const { modal, choice } = openModal(["in-progress"]); + modal.onClose(); + await expect(choice).resolves.toBe("go-back"); + }); + + it("keeps the pressed outcome when the modal closes afterwards", async () => { + const { modal, choice } = openModal(["in-progress"]); + buttons(modal)[1].click(); + modal.onClose(); + await expect(choice).resolves.toBe("enable"); + }); + + it("names Started when only Started is missing", () => { + const { modal } = openModal(["in-progress"]); + expect(bodyText(modal)).toContain("No status is categorized as Started."); + }); + + it("names Not started when only Not started is missing", () => { + const { modal } = openModal(["planned"]); + expect(bodyText(modal)).toContain("No status is categorized as Not started."); + }); + + it("names both categories when both are missing", () => { + const { modal } = openModal(["planned", "in-progress"]); + expect(bodyText(modal)).toContain("No status is categorized as Not started or Started."); + }); + + it("states that start-based dependencies release on completion rather than on start", () => { + const { modal } = openModal(["in-progress"]); + expect(bodyText(modal)).toContain( + "release only when the predecessor completes, not when it starts" + ); + }); + + it("states the remedy of assigning the category in task properties", () => { + const { modal } = openModal(["in-progress"]); + expect(bodyText(modal)).toContain( + "assign the missing category to one of your existing statuses in the task properties settings" + ); + }); + + it("puts the primary button last and gives it focus", () => { + const { modal } = openModal(["in-progress"]); + const rendered = buttons(modal); + + expect(rendered.map((button) => button.textContent)).toEqual([ + "Go back", + "Just enable", + "Enable and go to statuses...", + ]); + expect(rendered[2].classList.contains("mod-cta")).toBe(true); + expect(rendered.slice(0, 2).some((button) => button.classList.contains("mod-cta"))).toBe( + false + ); + + const focusSpy = jest.spyOn(rendered[2], "focus"); + jest.advanceTimersByTime(50); + expect(focusSpy).toHaveBeenCalled(); + }); +}); From 7d562b204dbb5a0a796ad3f713372e3cad19a7ec Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 14:07:45 +1200 Subject: [PATCH 28/33] feat(dependencies): gate enabling advanced types on acknowledgement The toggle now asks before taking effect when a start category has no status, and each answer lands distinctly: going back leaves it off, enabling commits, and the repair path commits then opens the statuses tab. Obsidian flips the toggle before the handler runs, so every path rebuilds the tab from the stored value; the repair path empties it instead, so returning shows the repaired state rather than the warning captured on the way out. --- src/settings/tabs/featuresTab.ts | 59 +++++- .../featuresTabDependencyGate.test.ts | 173 ++++++++++++++++++ 2 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 tests/unit/settings/featuresTabDependencyGate.test.ts diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index 1d614e51d..0a9e56437 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -18,7 +18,8 @@ import { normalizeThemeColor, } from "../../utils/themeColors"; import { configureThemeColorInput } from "../components/CardComponent"; -import { countStatusCategories } from "../defaults"; +import { countStatusCategories, findMissingStartCategories } from "../defaults"; +import { DependencyReadinessConfirmationModal } from "../../modals/DependencyReadinessConfirmationModal"; async function getInitializedPomodoroService(plugin: TaskNotesPlugin) { if (!plugin.pomodoroService) { @@ -998,11 +999,8 @@ export function renderFeaturesTab( 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(); - renderFeaturesTab(container, plugin, save); - }, + setValue: (value: boolean) => + applyAdvancedDependencyTypesToggle(value, plugin, container, save), }) ); @@ -1053,3 +1051,52 @@ export function renderFeaturesTab( } ); } + +/** + * Applies an advanced-dependency-types toggle change, asking for acknowledgement first when a + * start category has no status. Re-evaluates the status set on every enable; stores no answer. + */ +export async function applyAdvancedDependencyTypesToggle( + value: boolean, + plugin: TaskNotesPlugin, + container: HTMLElement, + save: () => void +): Promise { + const missingCategories = value + ? findMissingStartCategories(plugin.settings.customStatuses) + : []; + + if (missingCategories.length === 0) { + plugin.settings.enableAdvancedDependencyTypes = value; + save(); + renderFeaturesTab(container, plugin, save); + return; + } + + // Obsidian flips the toggle before this runs, so every path — a rejected modal included — + // has to rebuild the tab from the stored value. + let finish = () => renderFeaturesTab(container, plugin, save); + try { + const choice = await new DependencyReadinessConfirmationModal( + plugin, + missingCategories + ).show(); + if (choice === "go-back") { + return; + } + + plugin.settings.enableAdvancedDependencyTypes = true; + save(); + + if (choice === "enable-and-open-statuses") { + // Left empty rather than re-rendered so returning to Features rebuilds it against + // the repaired status set instead of the warning captured here. + finish = () => { + container.empty(); + plugin.settingTab?.navigateToTab("task-properties"); + }; + } + } finally { + finish(); + } +} diff --git a/tests/unit/settings/featuresTabDependencyGate.test.ts b/tests/unit/settings/featuresTabDependencyGate.test.ts new file mode 100644 index 000000000..c18f30ef0 --- /dev/null +++ b/tests/unit/settings/featuresTabDependencyGate.test.ts @@ -0,0 +1,173 @@ +import TaskNotesPlugin from "../../../src/main"; +import { DEFAULT_SETTINGS, DEFAULT_STATUSES } from "../../../src/settings/defaults"; +import { createI18nService } from "../../../src/i18n"; +import { applyAdvancedDependencyTypesToggle } from "../../../src/settings/tabs/featuresTab"; +import { + DependencyReadinessConfirmationModal, + DependencyReadinessChoice, +} from "../../../src/modals/DependencyReadinessConfirmationModal"; +import { StatusConfig } from "../../../src/types"; + +jest.mock("../../../src/modals/DependencyReadinessConfirmationModal", () => ({ + DependencyReadinessConfirmationModal: jest.fn(), +})); + +(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); +}; + +const ModalMock = DependencyReadinessConfirmationModal as unknown as jest.Mock; + +function answers(choice: DependencyReadinessChoice): void { + ModalMock.mockImplementation(() => ({ show: () => Promise.resolve(choice) })); +} + +function rejects(error: Error): void { + ModalMock.mockImplementation(() => ({ show: () => Promise.reject(error) })); +} + +function statusesWithout(category: string): StatusConfig[] { + return (JSON.parse(JSON.stringify(DEFAULT_STATUSES)) as StatusConfig[]).filter( + (status) => status.category !== category + ); +} + +function createHarness(customStatuses: 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); + const settings = { ...DEFAULT_SETTINGS, customStatuses, enableAdvancedDependencyTypes: false }; + (plugin as any).settings = settings; + (plugin as any).i18n = createI18nService(); + (plugin as any).registerEvent = jest.fn(); + (plugin as any).manifest = { version: "0.0.0" }; + (plugin as any).fieldMapper = { + isPropertyForField: jest.fn(() => false), + toUserField: jest.fn((field: any) => field), + toInternalField: jest.fn((field: any) => field), + }; + const navigateToTab = jest.fn(); + (plugin as any).settingTab = { navigateToTab }; + + const container = document.createElement("div"); + const save = jest.fn(); + + return { + settings, + container, + save, + navigateToTab, + toggle: (value: boolean) => + applyAdvancedDependencyTypesToggle(value, plugin, container, save), + }; +} + +describe("Advanced dependency types toggle — readiness gate", () => { + it("opens the modal with the missing categories when Started has no status", async () => { + const harness = createHarness(statusesWithout("in-progress")); + answers("go-back"); + + await harness.toggle(true); + + expect(ModalMock).toHaveBeenCalledTimes(1); + expect(ModalMock.mock.calls[0][1]).toEqual(["in-progress"]); + }); + + it("leaves the setting off when the user goes back", async () => { + const harness = createHarness(statusesWithout("in-progress")); + answers("go-back"); + + await harness.toggle(true); + + expect(harness.settings.enableAdvancedDependencyTypes).toBe(false); + expect(harness.container.childElementCount).toBeGreaterThan(0); + }); + + it("turns the setting on without navigating when the user just enables", async () => { + const harness = createHarness(statusesWithout("in-progress")); + answers("enable"); + + await harness.toggle(true); + + expect(harness.settings.enableAdvancedDependencyTypes).toBe(true); + expect(harness.save).toHaveBeenCalled(); + expect(harness.navigateToTab).not.toHaveBeenCalled(); + expect(harness.container.childElementCount).toBeGreaterThan(0); + }); + + it("turns the setting on, empties the tab and opens task properties on enable and go", async () => { + const harness = createHarness(statusesWithout("in-progress")); + answers("enable-and-open-statuses"); + + await harness.toggle(true); + + expect(harness.settings.enableAdvancedDependencyTypes).toBe(true); + expect(harness.container.childElementCount).toBe(0); + expect(harness.navigateToTab).toHaveBeenCalledWith("task-properties"); + }); + + it("enables without a modal when both start categories are present", async () => { + const harness = createHarness(JSON.parse(JSON.stringify(DEFAULT_STATUSES))); + + await harness.toggle(true); + + expect(ModalMock).not.toHaveBeenCalled(); + expect(harness.settings.enableAdvancedDependencyTypes).toBe(true); + expect(harness.navigateToTab).not.toHaveBeenCalled(); + }); + + it("never opens the modal when the setting is turned off", async () => { + const harness = createHarness(statusesWithout("in-progress")); + harness.settings.enableAdvancedDependencyTypes = true; + + await harness.toggle(false); + + expect(ModalMock).not.toHaveBeenCalled(); + expect(harness.settings.enableAdvancedDependencyTypes).toBe(false); + }); + + it("asks again on the next enable attempt, remembering no acknowledgement", async () => { + const harness = createHarness(statusesWithout("in-progress")); + answers("enable"); + + await harness.toggle(true); + await harness.toggle(false); + await harness.toggle(true); + + expect(ModalMock).toHaveBeenCalledTimes(2); + expect(harness.settings.enableAdvancedDependencyTypes).toBe(true); + }); + + it("leaves the setting off and still resyncs the tab when the modal rejects", async () => { + const harness = createHarness(statusesWithout("in-progress")); + rejects(new Error("modal failed")); + + await expect(harness.toggle(true)).rejects.toThrow("modal failed"); + + expect(harness.settings.enableAdvancedDependencyTypes).toBe(false); + expect(harness.container.childElementCount).toBeGreaterThan(0); + }); + + it("re-reads the status set on each attempt, so repairing statuses changes the outcome", async () => { + const harness = createHarness(statusesWithout("in-progress")); + answers("go-back"); + + await harness.toggle(true); + expect(ModalMock).toHaveBeenCalledTimes(1); + + harness.settings.customStatuses = JSON.parse( + JSON.stringify(DEFAULT_STATUSES) + ) as StatusConfig[]; + await harness.toggle(true); + + expect(ModalMock).toHaveBeenCalledTimes(1); + expect(harness.settings.enableAdvancedDependencyTypes).toBe(true); + }); +}); From 3ab916045c80225e523d69fe19a8ff6da8d32630 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 14:17:22 +1200 Subject: [PATCH 29/33] feat(dependencies): make the status readiness warning visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's only signal was a left border on the setting item, which Obsidian's native settings group card suppresses, so the warning never rendered. The colour now rides on the row's own text with a decorative glyph, and the copy drops to the counts plus what is missing — the consequence and the remedy are the modal's job. Removes the two border rules that never applied. --- i18n.manifest.json | 2 +- i18n.state.json | 16 ++-- src/i18n/resources/en.ts | 3 +- src/settings/tabs/featuresTab.ts | 48 +++++++---- styles/settings-view.css | 12 +-- .../settings/dependencyReadinessRow.test.ts | 84 +++++++++++++++++++ 6 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 tests/unit/settings/dependencyReadinessRow.test.ts diff --git a/i18n.manifest.json b/i18n.manifest.json index 4ac04d7ec..a404c191e 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -354,7 +354,7 @@ "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.dependencies.readiness.missing": "6f0ceebbe754b904050d85e4ab77fac9b1bf2c86", "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 8afa005dd..d01d84e90 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -1399,7 +1399,7 @@ "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.dependencies.readiness.missing": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "f98acefe1cdc9c888903a42f94689b0e6d7bef41" @@ -10311,7 +10311,7 @@ "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.dependencies.readiness.missing": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "a7f016a7bf869a4faec082a631841e0f359e46c2" @@ -19223,7 +19223,7 @@ "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.dependencies.readiness.missing": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "a32a664acc6d5279c6600620c1a3fb0a8bb8cd1b" @@ -28135,7 +28135,7 @@ "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.dependencies.readiness.missing": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "7277ee93a6bc575b91eacbff0045075f71fa9b22" @@ -37047,7 +37047,7 @@ "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.dependencies.readiness.missing": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "1e85b7120ae8c808302b3a235b6f91916bbf7de8" @@ -45959,7 +45959,7 @@ "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.dependencies.readiness.missing": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "e4840351a15b65cb43b38b53ce73a1b976f8993f" @@ -54871,7 +54871,7 @@ "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.dependencies.readiness.missing": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "ed325e2a0e049939e66b927ab4ad76ff76319842" @@ -63783,7 +63783,7 @@ "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.dependencies.readiness.missing": null, "settings.features.inlineTasks.header": { "source": "a0f498bf59b33369a6a0aca916b487fd04ea40a7", "translation": "29a6c4edbeac9373f6e112eee10387b97be35a25" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 825cac5ea..fb8b06281 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -525,8 +525,7 @@ export const en: TranslationTree = { 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.", + missing: "No status is categorized as {categories}.", }, }, inlineTasks: { diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index 0a9e56437..227ad7f02 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -20,6 +20,13 @@ import { import { configureThemeColorInput } from "../components/CardComponent"; import { countStatusCategories, findMissingStartCategories } from "../defaults"; import { DependencyReadinessConfirmationModal } from "../../modals/DependencyReadinessConfirmationModal"; +import { StatusCategory } from "../../types"; + +const STATUS_CATEGORY_LABEL_KEYS: Record = { + planned: "settings.taskProperties.taskStatuses.badges.planned", + "in-progress": "settings.taskProperties.taskStatuses.badges.inProgress", + completed: "settings.taskProperties.taskStatuses.badges.completed", +}; async function getInitializedPomodoroService(plugin: TaskNotesPlugin) { if (!plugin.pomodoroService) { @@ -1006,22 +1013,35 @@ export function renderFeaturesTab( if (plugin.settings.enableAdvancedDependencyTypes) { const counts = countStatusCategories(plugin.settings.customStatuses); - const startReady = counts["in-progress"] > 0 && counts.planned > 0; + const missingCategories = findMissingStartCategories(plugin.settings.customStatuses); + const countsText = translate("settings.features.dependencies.readiness.counts", { + notStarted: counts.planned, + started: counts["in-progress"], + completed: counts.completed, + }); 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" + + if (missingCategories.length === 0) { + setting.setDesc( + `${countsText} ${translate("settings.features.dependencies.readiness.ready")}` + ); + return; + } + + setting.setDesc(countsText); + const warning = setting.descEl.createDiv({ cls: "tn-start-readiness-warning" }); + warning.createSpan({ + cls: "tn-start-readiness-warning__glyph", + text: "!", + attr: { "aria-hidden": "true" }, + }); + warning.appendText( + translate("settings.features.dependencies.readiness.missing", { + categories: missingCategories + .map((category) => translate(STATUS_CATEGORY_LABEL_KEYS[category])) + .join(" or "), + }) ); }); } diff --git a/styles/settings-view.css b/styles/settings-view.css index a52a1fe98..1c1de5faf 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -1414,14 +1414,14 @@ body.is-mobile .tasknotes-plugin .tasknotes-settings__card-action-btn { } } -.setting-item.tn-dependency-readiness--ok { - border-inline-start: 3px solid var(--color-green); - padding-inline-start: var(--size-4-2); +/* Obsidian's settings group card owns the row's edges, so the warning rides on its text. */ +.tn-start-readiness-warning { + font-weight: 500; + color: var(--text-warning); } -.setting-item.tn-dependency-readiness--warn { - border-inline-start: 3px solid var(--color-orange); - padding-inline-start: var(--size-4-2); +.tn-start-readiness-warning__glyph { + margin-inline-end: var(--size-2-1); } .tn-status-category-advisory { diff --git a/tests/unit/settings/dependencyReadinessRow.test.ts b/tests/unit/settings/dependencyReadinessRow.test.ts new file mode 100644 index 000000000..41e6ca99d --- /dev/null +++ b/tests/unit/settings/dependencyReadinessRow.test.ts @@ -0,0 +1,84 @@ +import TaskNotesPlugin from "../../../src/main"; +import { DEFAULT_SETTINGS, DEFAULT_STATUSES } from "../../../src/settings/defaults"; +import { createI18nService } from "../../../src/i18n"; +import { renderFeaturesTab } from "../../../src/settings/tabs/featuresTab"; +import { StatusConfig } from "../../../src/types"; + +function statusesWithout(category: string): StatusConfig[] { + return (JSON.parse(JSON.stringify(DEFAULT_STATUSES)) as StatusConfig[]).filter( + (status) => status.category !== category + ); +} + +function renderFeatures(customStatuses: StatusConfig[], enabled: boolean): HTMLElement { + (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, + enableAdvancedDependencyTypes: enabled, + }; + (plugin as any).i18n = createI18nService(); + (plugin as any).registerEvent = jest.fn(); + (plugin as any).manifest = { version: "0.0.0" }; + (plugin as any).fieldMapper = { + isPropertyForField: jest.fn(() => false), + toUserField: jest.fn((field: any) => field), + toInternalField: jest.fn((field: any) => field), + }; + + const container = document.createElement("div"); + renderFeaturesTab(container, plugin, jest.fn()); + return container; +} + +describe("Dependencies status readiness row", () => { + it("marks the missing category with a decorative glyph and the warning class", () => { + const container = renderFeatures(statusesWithout("in-progress"), true); + + const warning = container.querySelector(".tn-start-readiness-warning"); + const glyph = warning?.querySelector(".tn-start-readiness-warning__glyph"); + + expect(glyph?.textContent).toBe("!"); + expect(glyph?.getAttribute("aria-hidden")).toBe("true"); + expect(warning?.textContent).toBe("!No status is categorized as Started."); + }); + + it("leaves the consequence and the remedy to the modal", () => { + const container = renderFeatures(statusesWithout("in-progress"), true); + + expect(container.textContent).not.toContain("need at least one not-started status"); + expect(container.textContent).not.toContain( + "Categorize a status in the task properties settings." + ); + }); + + it("carries no warning treatment when both start categories have a status", () => { + const container = renderFeatures(JSON.parse(JSON.stringify(DEFAULT_STATUSES)), true); + + expect(container.querySelector(".tn-start-readiness-warning")).toBeNull(); + expect(container.querySelector(".tn-start-readiness-warning__glyph")).toBeNull(); + expect(container.textContent).toContain("Start-based dependencies are ready."); + }); + + it("counts the configured statuses by category", () => { + expect(renderFeatures(statusesWithout("in-progress"), true).textContent).toContain( + "Not started: 2, Started: 0, Completed: 1." + ); + expect( + renderFeatures(JSON.parse(JSON.stringify(DEFAULT_STATUSES)), true).textContent + ).toContain("Not started: 2, Started: 1, Completed: 1."); + }); + + it("says nothing about readiness while advanced dependency types are off", () => { + const container = renderFeatures(statusesWithout("in-progress"), false); + + expect(container.textContent).not.toContain("Status readiness"); + expect(container.querySelector(".tn-start-readiness-warning")).toBeNull(); + }); +}); From 25015c1990510be56b50511d28c4e8085ab235ae Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 14:30:23 +1200 Subject: [PATCH 30/33] feat(dependencies): show start-category readiness where statuses are edited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advisory rendered inside a card that starts collapsed, so it was invisible on arrival — including when the enable-time modal had just sent the user here to read it. It now sits above the card, gains a ready state and a link back to the dependency settings, and repaints from the category dropdown's existing change handler so assigning the category clears it without rebuilding the list. --- i18n.manifest.json | 4 +- i18n.state.json | 16 ++ src/i18n/resources/en.ts | 5 +- src/settings/tabs/taskProperties/index.ts | 5 +- .../tabs/taskProperties/statusPropertyCard.ts | 144 +++++++++----- src/settings/tabs/taskPropertiesTab.ts | 7 +- styles/settings-view.css | 17 ++ .../settings/statusReadinessIndicator.test.ts | 185 ++++++++++++++++++ 8 files changed, 335 insertions(+), 48 deletions(-) create mode 100644 tests/unit/settings/statusReadinessIndicator.test.ts diff --git a/i18n.manifest.json b/i18n.manifest.json index a404c191e..c908780d0 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -737,7 +737,9 @@ "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.categoryAdvisory": "6e27aa626e795a003e35be77c319f25d785bc18f", + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": "4b32fa62757c50c7b5fef820fdcde24c7f110656", + "settings.taskProperties.taskStatuses.categoryReady": "6cdaf59bc49a2d02417fae7302673ee15e685cda", "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 d01d84e90..0ff840c4f 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2917,6 +2917,8 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.categoryAdvisory": null, + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": null, + "settings.taskProperties.taskStatuses.categoryReady": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "bd5e5868fd22e54aa9b5018245a7dac8304bd1f6" @@ -11829,6 +11831,8 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.categoryAdvisory": null, + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": null, + "settings.taskProperties.taskStatuses.categoryReady": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "6181bea9a3b42002b16368c96c4b7ec405424a9d" @@ -20741,6 +20745,8 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.categoryAdvisory": null, + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": null, + "settings.taskProperties.taskStatuses.categoryReady": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "b9270228ed590ded12e0538f71a4adaa6702b218" @@ -29653,6 +29659,8 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.categoryAdvisory": null, + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": null, + "settings.taskProperties.taskStatuses.categoryReady": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "d7a8c7b2efbbd6760f19259c206a211ffc3b0674" @@ -38565,6 +38573,8 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.categoryAdvisory": null, + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": null, + "settings.taskProperties.taskStatuses.categoryReady": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "f6882f9122f9b4e5f620c536e1d062ebc3225517" @@ -47477,6 +47487,8 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.categoryAdvisory": null, + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": null, + "settings.taskProperties.taskStatuses.categoryReady": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "5a9415cfc781eb37f56b19b6451cd9da046630de" @@ -56389,6 +56401,8 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.categoryAdvisory": null, + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": null, + "settings.taskProperties.taskStatuses.categoryReady": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "0c211b5422e7e0d78ec042fbd5154781a79bcd90" @@ -65301,6 +65315,8 @@ "settings.taskProperties.taskStatuses.badges.planned": null, "settings.taskProperties.taskStatuses.badges.inProgress": null, "settings.taskProperties.taskStatuses.categoryAdvisory": null, + "settings.taskProperties.taskStatuses.categoryAdvisoryLink": null, + "settings.taskProperties.taskStatuses.categoryReady": null, "settings.taskProperties.taskStatuses.deleteConfirm": { "source": "179626d7a4d216da031ac7ca2ec87e0ed97bc5fc", "translation": "20035d63eaafed20bd3b4001a0dca01e09b82186" diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index fb8b06281..b844c8541 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1266,7 +1266,10 @@ export const en: TranslationTree = { 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.", + "Advanced dependency types are on, but no {categories} status exists. Start-based dependencies can't release as authored until every start category has a status.", + categoryAdvisoryLink: "Open dependency settings", + categoryReady: + "Advanced dependency types are on and every start category has a status. Start-based dependencies release as authored.", deleteConfirm: 'Are you sure you want to delete the status "{label}"?', }, taskPriorities: { diff --git a/src/settings/tabs/taskProperties/index.ts b/src/settings/tabs/taskProperties/index.ts index b61688fc0..028859eb1 100644 --- a/src/settings/tabs/taskProperties/index.ts +++ b/src/settings/tabs/taskProperties/index.ts @@ -1,6 +1,9 @@ // Re-export all property card modules export { renderTitlePropertyCard } from "./titlePropertyCard"; -export { renderStatusPropertyCard } from "./statusPropertyCard"; +export { + renderStatusPropertyCard, + renderStatusReadinessIndicator, +} from "./statusPropertyCard"; export { renderPriorityPropertyCard } from "./priorityPropertyCard"; export { renderProjectsPropertyCard } from "./projectsPropertyCard"; export { renderTagsPropertyCard } from "./tagsPropertyCard"; diff --git a/src/settings/tabs/taskProperties/statusPropertyCard.ts b/src/settings/tabs/taskProperties/statusPropertyCard.ts index 77bf9a5cf..1e5037a8c 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -19,7 +19,7 @@ import { import { createIconInput } from "../../components/IconSuggest"; import { createNLPTriggerRows, createPropertyDescription, TranslateFn } from "./helpers"; import { StatusCategory, STATUS_CATEGORIES } from "../../../types"; -import { countStatusCategories } from "../../defaults"; +import { findMissingStartCategories } 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. @@ -37,30 +37,61 @@ 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, +/** + * Renders the start-category readiness state into `host`, replacing whatever it holds; + * renders nothing while advanced dependency types are off. + */ +export function renderStatusReadinessIndicator( + host: HTMLElement, plugin: TaskNotesPlugin, translate: TranslateFn ): void { + host.empty(); if (!plugin.settings.enableAdvancedDependencyTypes) { return; } - const counts = countStatusCategories(plugin.settings.customStatuses ?? []); - const missing = STATUS_CATEGORIES.filter((category) => counts[category] === 0); + + const missing = findMissingStartCategories(plugin.settings.customStatuses); + const indicator = host.createDiv({ cls: "tn-status-category-advisory" }); + if (missing.length === 0) { + indicator.addClass("tn-status-category-advisory--ready"); + indicator.createSpan({ + cls: "tn-status-category-advisory__glyph", + text: "✓", + attr: { "aria-hidden": "true" }, + }); + indicator.appendText(translate("settings.taskProperties.taskStatuses.categoryReady")); 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 }) + + indicator.addClass("tn-status-category-advisory--warning"); + indicator.createSpan({ + cls: "tn-status-category-advisory__glyph", + text: "!", + attr: { "aria-hidden": "true" }, + }); + indicator.appendText( + translate("settings.taskProperties.taskStatuses.categoryAdvisory", { + categories: missing + .map((category) => + translate( + `settings.taskProperties.taskStatuses.badges.${categoryI18nKey(category)}` + ) + ) + .join(" or "), + }) ); + + const link = indicator.createEl("a", { + cls: "tn-status-category-advisory__link", + text: translate("settings.taskProperties.taskStatuses.categoryAdvisoryLink"), + href: "#", + }); + link.addEventListener("click", (event) => { + event.preventDefault(); + plugin.settingTab?.navigateToTab("features"); + }); } /** @@ -70,7 +101,8 @@ export function renderStatusPropertyCard( container: HTMLElement, plugin: TaskNotesPlugin, save: () => void, - translate: TranslateFn + translate: TranslateFn, + refreshReadiness?: () => void ): void { const propertyKeyInput = createCardInput("text", "status", plugin.settings.fieldMapping.status); @@ -158,19 +190,26 @@ export function renderStatusPropertyCard( // Render status value cards const statusListContainer = statusValuesContent.createDiv("tasknotes-statuses-container"); - renderStatusList(statusListContainer, plugin, save, translate, () => { - // Re-render the default select when statuses change - defaultSelect.empty(); - plugin.settings.customStatuses.forEach((status) => { - const option = defaultSelect.createEl("option", { - value: status.value, - text: status.label || status.value, + renderStatusList( + statusListContainer, + plugin, + save, + translate, + () => { + // Re-render the default select when statuses change + defaultSelect.empty(); + plugin.settings.customStatuses.forEach((status) => { + const option = defaultSelect.createEl("option", { + value: status.value, + text: status.label || status.value, + }); + if (status.value === plugin.settings.defaultTaskStatus) { + option.selected = true; + } }); - if (status.value === plugin.settings.defaultTaskStatus) { - option.selected = true; - } - }); - }); + }, + refreshReadiness + ); // Add status button const addStatusButton = statusValuesContent.createEl("button", { @@ -206,18 +245,25 @@ export function renderStatusPropertyCard( }; plugin.settings.customStatuses.push(newStatus); save(); - renderStatusList(statusListContainer, plugin, save, translate, () => { - defaultSelect.empty(); - plugin.settings.customStatuses.forEach((status) => { - const option = defaultSelect.createEl("option", { - value: status.value, - text: status.label || status.value, + renderStatusList( + statusListContainer, + plugin, + save, + translate, + () => { + defaultSelect.empty(); + plugin.settings.customStatuses.forEach((status) => { + const option = defaultSelect.createEl("option", { + value: status.value, + text: status.label || status.value, + }); + if (status.value === plugin.settings.defaultTaskStatus) { + option.selected = true; + } }); - if (status.value === plugin.settings.defaultTaskStatus) { - option.selected = true; - } - }); - }); + }, + refreshReadiness + ); }; // Toggle collapse @@ -268,11 +314,12 @@ function renderStatusList( plugin: TaskNotesPlugin, save: () => void, translate: TranslateFn, - onStatusesChanged?: () => void + onStatusesChanged?: () => void, + refreshReadiness?: () => void ): void { container.empty(); - renderStatusCategoryAdvisory(container, plugin, translate); + if (refreshReadiness) refreshReadiness(); if (!plugin.settings.customStatuses || plugin.settings.customStatuses.length === 0) { showCardEmptyState(container, translate("settings.taskProperties.taskStatuses.emptyState")); @@ -320,6 +367,8 @@ function renderStatusList( metaContainer.appendChild(createCategoryBadge(status.category, translate)); } save(); + // Refreshed in place; a renderStatusList rebuild would drop this dropdown mid-edit. + if (refreshReadiness) refreshReadiness(); }); const excludeFromCycleToggle = createCardToggle( @@ -404,7 +453,14 @@ function renderStatusList( } save(); - renderStatusList(container, plugin, save, translate, onStatusesChanged); + renderStatusList( + container, + plugin, + save, + translate, + onStatusesChanged, + refreshReadiness + ); if (onStatusesChanged) onStatusesChanged(); } } @@ -510,7 +566,7 @@ function renderStatusList( }); previousStatusValue = status.value; save(); - renderStatusList(container, plugin, save, translate, onStatusesChanged); + renderStatusList(container, plugin, save, translate, onStatusesChanged, refreshReadiness); }); labelInput.addEventListener("input", () => { @@ -575,7 +631,7 @@ function renderStatusList( plugin.settings.customStatuses = reorderedStatuses; save(); - renderStatusList(container, plugin, save, translate, onStatusesChanged); + renderStatusList(container, plugin, save, translate, onStatusesChanged, refreshReadiness); }); }); } diff --git a/src/settings/tabs/taskPropertiesTab.ts b/src/settings/tabs/taskPropertiesTab.ts index fb34b3ec3..cf9ecc69e 100644 --- a/src/settings/tabs/taskPropertiesTab.ts +++ b/src/settings/tabs/taskPropertiesTab.ts @@ -11,6 +11,7 @@ import { createCardInput } from "../components/CardComponent"; import { renderTitlePropertyCard, renderStatusPropertyCard, + renderStatusReadinessIndicator, renderPriorityPropertyCard, renderProjectsPropertyCard, renderTagsPropertyCard, @@ -53,7 +54,11 @@ export function renderTaskPropertiesTab( renderTitlePropertyCard(container, plugin, save, translate); // Status Property Card - renderStatusPropertyCard(container, plugin, save, translate); + const statusReadinessHost = container.createDiv(); + const refreshStatusReadiness = () => + renderStatusReadinessIndicator(statusReadinessHost, plugin, translate); + refreshStatusReadiness(); + renderStatusPropertyCard(container, plugin, save, translate, refreshStatusReadiness); // Priority Property Card renderPriorityPropertyCard(container, plugin, save, translate); diff --git a/styles/settings-view.css b/styles/settings-view.css index 1c1de5faf..66d0577d3 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -1433,3 +1433,20 @@ body.is-mobile .tasknotes-plugin .tasknotes-settings__card-action-btn { color: var(--text-muted); font-size: var(--font-ui-small); } + +.tn-status-category-advisory--ready { + border-inline-start-color: var(--color-green); +} + +.tn-status-category-advisory__glyph { + margin-inline-end: var(--size-2-1); + font-weight: 500; +} + +.tn-status-category-advisory--warning .tn-status-category-advisory__glyph { + color: var(--text-warning); +} + +.tn-status-category-advisory__link { + margin-inline-start: var(--size-2-2); +} diff --git a/tests/unit/settings/statusReadinessIndicator.test.ts b/tests/unit/settings/statusReadinessIndicator.test.ts new file mode 100644 index 000000000..40b6a69eb --- /dev/null +++ b/tests/unit/settings/statusReadinessIndicator.test.ts @@ -0,0 +1,185 @@ +import TaskNotesPlugin from "../../../src/main"; +import { DEFAULT_SETTINGS } from "../../../src/settings/defaults"; +import { createI18nService } from "../../../src/i18n"; +import { + renderStatusPropertyCard, + renderStatusReadinessIndicator, +} from "../../../src/settings/tabs/taskProperties/statusPropertyCard"; +import { StatusCategory, 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); +}; + +function createStatus(value: string, category: StatusCategory, order: number): StatusConfig { + return { + id: value, + value, + label: value, + color: "#808080", + isCompleted: category === "completed", + category, + order, + autoArchive: false, + autoArchiveDelay: 5, + }; +} + +const ALL_CATEGORIES: StatusConfig[] = [ + createStatus("todo", "planned", 0), + createStatus("doing", "in-progress", 1), + createStatus("done", "completed", 2), +]; + +function renderTab(customStatuses: StatusConfig[], enableAdvancedDependencyTypes: boolean) { + (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, + defaultTaskStatus: customStatuses[0].value, + enableAdvancedDependencyTypes, + }; + (plugin as any).registerEvent = jest.fn(); + (plugin as any).manifest = { version: "0.0.0" }; + const i18n = createI18nService(); + (plugin as any).i18n = i18n; + const navigateToTab = jest.fn(); + (plugin as any).settingTab = { navigateToTab }; + + const translate = (key: string, params?: Record) => + i18n.translate(key, params); + const container = document.createElement("div"); + const readinessHost = container.createDiv(); + const refreshReadiness = () => renderStatusReadinessIndicator(readinessHost, plugin, translate); + refreshReadiness(); + renderStatusPropertyCard(container, plugin, jest.fn(), translate, refreshReadiness); + + return { container, readinessHost, navigateToTab, translate }; +} + +function indicator(container: HTMLElement): HTMLElement | null { + return container.querySelector(".tn-status-category-advisory"); +} + +function categorySelects(container: HTMLElement): HTMLSelectElement[] { + return Array.from(container.querySelectorAll("select")).filter((select) => + Array.from(select.options).some((option) => option.value === "planned") + ); +} + +function choose(select: HTMLSelectElement, value: string): void { + select.value = value; + select.dispatchEvent(new Event("change")); +} + +describe("Status readiness indicator", () => { + it("warns and names the missing start category", () => { + const { container, translate } = renderTab( + [ALL_CATEGORIES[0], ALL_CATEGORIES[2]].map((status) => ({ ...status })), + true + ); + + const el = indicator(container); + expect(el?.classList.contains("tn-status-category-advisory--warning")).toBe(true); + expect(el?.textContent).toContain( + translate("settings.taskProperties.taskStatuses.badges.inProgress") + ); + }); + + it("renders the warning in the tab container, outside the status card", () => { + const { container, readinessHost } = renderTab( + [ALL_CATEGORIES[0], ALL_CATEGORIES[2]].map((status) => ({ ...status })), + true + ); + + const el = indicator(container); + expect(readinessHost.contains(el as Node)).toBe(true); + expect(el?.closest(".tasknotes-settings__card")).toBeNull(); + expect(container.firstElementChild).toBe(readinessHost); + }); + + it("renders the ready state when both start categories have a status", () => { + const { container, translate } = renderTab( + ALL_CATEGORIES.map((status) => ({ ...status })), + true + ); + + const el = indicator(container); + expect(el?.classList.contains("tn-status-category-advisory--ready")).toBe(true); + expect(el?.textContent).toContain( + translate("settings.taskProperties.taskStatuses.categoryReady") + ); + }); + + it("renders nothing while advanced dependency types are off", () => { + const { container } = renderTab( + [ALL_CATEGORIES[0], ALL_CATEGORIES[2]].map((status) => ({ ...status })), + false + ); + + expect(indicator(container)).toBeNull(); + }); + + it("treats a missing Completed category as ready", () => { + const { container } = renderTab( + [ALL_CATEGORIES[0], ALL_CATEGORIES[1]].map((status) => ({ ...status })), + true + ); + + expect(indicator(container)?.classList.contains("tn-status-category-advisory--ready")).toBe( + true + ); + }); + + it("flips to ready when a status becomes Started, keeping the status list in place", () => { + const { container } = renderTab( + [ALL_CATEGORIES[0], ALL_CATEGORIES[2]].map((status) => ({ ...status })), + true + ); + const statusList = container.querySelector(".tasknotes-statuses-container"); + const select = categorySelects(container)[1]; + + choose(select, "in-progress"); + + expect(indicator(container)?.classList.contains("tn-status-category-advisory--ready")).toBe( + true + ); + expect(container.querySelector(".tasknotes-statuses-container")).toBe(statusList); + expect(categorySelects(container)[1]).toBe(select); + }); + + it("flips back to a warning when the last Started status changes category", () => { + const { container, translate } = renderTab( + ALL_CATEGORIES.map((status) => ({ ...status })), + true + ); + + choose(categorySelects(container)[1], "completed"); + + const el = indicator(container); + expect(el?.classList.contains("tn-status-category-advisory--warning")).toBe(true); + expect(el?.textContent).toContain( + translate("settings.taskProperties.taskStatuses.badges.inProgress") + ); + }); + + it("opens the Dependencies settings from the warning link", () => { + const { container, navigateToTab } = renderTab( + [ALL_CATEGORIES[0], ALL_CATEGORIES[2]].map((status) => ({ ...status })), + true + ); + + container.querySelector(".tn-status-category-advisory__link")?.click(); + + expect(navigateToTab).toHaveBeenCalledWith("features"); + }); +}); From d41df08d9d927903755ad7d46cda703958bb90f4 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 15:01:40 +1200 Subject: [PATCH 31/33] refactor(dependencies): share the readiness vocabulary and keep both surfaces current Consolidates the category-label mapping that had grown three copies, so a badge key rename can no longer silently break one readiness surface while the others keep working, and stops the Features row tallying the status set twice per render. Also makes tab invalidation symmetric. The readiness state is shown on two tabs but changed from either, and the settings pane reveals cached tab content on a click, so a change made on one tab left the other asserting a state that was no longer true -- in the worst case the statuses-tab indicator never appeared at all after enabling the feature from Features. Each surface now discards the other's cached content when it changes what that surface reports. The repair path also waits for the destination to take over before discarding the pane it came from, so a refused navigation cannot leave both blank. --- .../DependencyReadinessConfirmationModal.ts | 13 +++----- src/settings/TaskNotesSettingTab.ts | 13 ++++++-- src/settings/defaults.ts | 20 ++++++++++- src/settings/tabs/featuresTab.ts | 33 ++++++++++--------- .../tabs/taskProperties/statusPropertyCard.ts | 11 ++----- src/settings/tabs/taskPropertiesTab.ts | 1 - .../featuresTabDependencyGate.test.ts | 5 +-- .../settings/settingTabNavigation.test.ts | 22 +++++++++++++ .../statusPropertyCard.lifecycle.test.ts | 1 + .../settings/statusReadinessIndicator.test.ts | 5 +-- 10 files changed, 83 insertions(+), 41 deletions(-) diff --git a/src/modals/DependencyReadinessConfirmationModal.ts b/src/modals/DependencyReadinessConfirmationModal.ts index d497c65ed..6d29770a6 100644 --- a/src/modals/DependencyReadinessConfirmationModal.ts +++ b/src/modals/DependencyReadinessConfirmationModal.ts @@ -1,15 +1,10 @@ import { Modal, Setting } from "obsidian"; import TaskNotesPlugin from "../main"; import { StatusCategory } from "../types"; +import { describeMissingStartCategories } from "../settings/defaults"; export type DependencyReadinessChoice = "go-back" | "enable" | "enable-and-open-statuses"; -const CATEGORY_LABEL_KEYS: Record = { - planned: "settings.taskProperties.taskStatuses.badges.planned", - "in-progress": "settings.taskProperties.taskStatuses.badges.inProgress", - completed: "settings.taskProperties.taskStatuses.badges.completed", -}; - const CHOICES: ReadonlyArray<[string, DependencyReadinessChoice]> = [ ["goBack", "go-back"], ["justEnable", "enable"], @@ -48,9 +43,9 @@ export class DependencyReadinessConfirmationModal extends Modal { new Setting(contentEl).setName(this.t("modals.dependencyReadiness.title")).setHeading(); - const categories = this.missingCategories - .map((category) => this.t(CATEGORY_LABEL_KEYS[category])) - .join(" or "); + const categories = describeMissingStartCategories(this.missingCategories, (key) => + this.t(key) + ); contentEl.createEl("p", { text: this.t("modals.dependencyReadiness.missing", { categories }), diff --git a/src/settings/TaskNotesSettingTab.ts b/src/settings/TaskNotesSettingTab.ts index 48a05141f..642f1ffa5 100644 --- a/src/settings/TaskNotesSettingTab.ts +++ b/src/settings/TaskNotesSettingTab.ts @@ -176,11 +176,11 @@ export class TaskNotesSettingTab extends PluginSettingTab { * Opens a tab from outside the settings view, ignoring unknown ids. * Always re-renders: cached content can predate the change that prompted the navigation. */ - navigateToTab(tabId: string): void { + navigateToTab(tabId: string): boolean { const tabConfig = this.getTabConfigurations().find((tab) => tab.id === tabId); const tabContent = this.tabContents[tabId]; if (!tabConfig || !tabContent) { - return; + return false; } this.activeTab = tabId; @@ -190,6 +190,15 @@ export class TaskNotesSettingTab extends PluginSettingTab { tabConfig.renderFn(tabContent, this.plugin, this.debouncedSave); this.focusTabButton(tabId); + return true; + } + + /** + * Discards a tab's cached content so the next activation rebuilds it. Needed when one tab + * changes state another tab reports, which the cache-and-reveal switch would otherwise hide. + */ + invalidateTab(tabId: string): void { + this.tabContents[tabId]?.empty(); } private switchTab(tabId: string): void { diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 892b87740..01ee83ce3 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -1,4 +1,5 @@ import { FieldMapping, StatusCategory, StatusConfig, PriorityConfig } from "../types"; +import type { TranslationKey } from "../i18n"; import { TaskNotesSettings, TaskCreationDefaults, @@ -106,15 +107,32 @@ export function countStatusCategories(statuses: StatusConfig[]): Record = { + planned: "settings.taskProperties.taskStatuses.badges.planned", + "in-progress": "settings.taskProperties.taskStatuses.badges.inProgress", + completed: "settings.taskProperties.taskStatuses.badges.completed", +}; + /** * Reports which of Not started / Started no status carries, in badge order; empty means * start-based dependency edges can release as authored. */ export function findMissingStartCategories(statuses?: StatusConfig[] | null): StatusCategory[] { - const counts = countStatusCategories(statuses ?? []); + return missingStartCategories(countStatusCategories(statuses ?? [])); +} + +/** The same answer for callers that already tallied the set, without normalizing it twice. */ +export function missingStartCategories(counts: Record): StatusCategory[] { return START_CATEGORIES.filter((category) => counts[category] === 0); } +export function describeMissingStartCategories( + missing: StatusCategory[], + translate: (key: TranslationKey) => string +): string { + return missing.map((category) => translate(STATUS_CATEGORY_LABEL_KEYS[category])).join(" or "); +} + // 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 227ad7f02..c2d2df293 100644 --- a/src/settings/tabs/featuresTab.ts +++ b/src/settings/tabs/featuresTab.ts @@ -18,15 +18,13 @@ import { normalizeThemeColor, } from "../../utils/themeColors"; import { configureThemeColorInput } from "../components/CardComponent"; -import { countStatusCategories, findMissingStartCategories } from "../defaults"; +import { + countStatusCategories, + describeMissingStartCategories, + findMissingStartCategories, + missingStartCategories, +} from "../defaults"; import { DependencyReadinessConfirmationModal } from "../../modals/DependencyReadinessConfirmationModal"; -import { StatusCategory } from "../../types"; - -const STATUS_CATEGORY_LABEL_KEYS: Record = { - planned: "settings.taskProperties.taskStatuses.badges.planned", - "in-progress": "settings.taskProperties.taskStatuses.badges.inProgress", - completed: "settings.taskProperties.taskStatuses.badges.completed", -}; async function getInitializedPomodoroService(plugin: TaskNotesPlugin) { if (!plugin.pomodoroService) { @@ -1013,7 +1011,7 @@ export function renderFeaturesTab( if (plugin.settings.enableAdvancedDependencyTypes) { const counts = countStatusCategories(plugin.settings.customStatuses); - const missingCategories = findMissingStartCategories(plugin.settings.customStatuses); + const missingCategories = missingStartCategories(counts); const countsText = translate("settings.features.dependencies.readiness.counts", { notStarted: counts.planned, started: counts["in-progress"], @@ -1038,9 +1036,7 @@ export function renderFeaturesTab( }); warning.appendText( translate("settings.features.dependencies.readiness.missing", { - categories: missingCategories - .map((category) => translate(STATUS_CATEGORY_LABEL_KEYS[category])) - .join(" or "), + categories: describeMissingStartCategories(missingCategories, translate), }) ); }); @@ -1089,6 +1085,7 @@ export async function applyAdvancedDependencyTypesToggle( if (missingCategories.length === 0) { plugin.settings.enableAdvancedDependencyTypes = value; save(); + plugin.settingTab?.invalidateTab("task-properties"); renderFeaturesTab(container, plugin, save); return; } @@ -1107,13 +1104,17 @@ export async function applyAdvancedDependencyTypesToggle( plugin.settings.enableAdvancedDependencyTypes = true; save(); + plugin.settingTab?.invalidateTab("task-properties"); if (choice === "enable-and-open-statuses") { - // Left empty rather than re-rendered so returning to Features rebuilds it against - // the repaired status set instead of the warning captured here. + // Discarded only once the destination has taken over, so a refused navigation + // cannot leave both panes blank. finish = () => { - container.empty(); - plugin.settingTab?.navigateToTab("task-properties"); + if (plugin.settingTab?.navigateToTab("task-properties")) { + container.empty(); + } else { + renderFeaturesTab(container, plugin, save); + } }; } } finally { diff --git a/src/settings/tabs/taskProperties/statusPropertyCard.ts b/src/settings/tabs/taskProperties/statusPropertyCard.ts index 1e5037a8c..136b29adc 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -19,7 +19,7 @@ import { import { createIconInput } from "../../components/IconSuggest"; import { createNLPTriggerRows, createPropertyDescription, TranslateFn } from "./helpers"; import { StatusCategory, STATUS_CATEGORIES } from "../../../types"; -import { findMissingStartCategories } from "../../defaults"; +import { describeMissingStartCategories, findMissingStartCategories } 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. @@ -73,13 +73,7 @@ export function renderStatusReadinessIndicator( }); indicator.appendText( translate("settings.taskProperties.taskStatuses.categoryAdvisory", { - categories: missing - .map((category) => - translate( - `settings.taskProperties.taskStatuses.badges.${categoryI18nKey(category)}` - ) - ) - .join(" or "), + categories: describeMissingStartCategories(missing, translate), }) ); @@ -369,6 +363,7 @@ function renderStatusList( save(); // Refreshed in place; a renderStatusList rebuild would drop this dropdown mid-edit. if (refreshReadiness) refreshReadiness(); + plugin.settingTab?.invalidateTab("features"); }); const excludeFromCycleToggle = createCardToggle( diff --git a/src/settings/tabs/taskPropertiesTab.ts b/src/settings/tabs/taskPropertiesTab.ts index cf9ecc69e..70c7eaa2f 100644 --- a/src/settings/tabs/taskPropertiesTab.ts +++ b/src/settings/tabs/taskPropertiesTab.ts @@ -57,7 +57,6 @@ export function renderTaskPropertiesTab( const statusReadinessHost = container.createDiv(); const refreshStatusReadiness = () => renderStatusReadinessIndicator(statusReadinessHost, plugin, translate); - refreshStatusReadiness(); renderStatusPropertyCard(container, plugin, save, translate, refreshStatusReadiness); // Priority Property Card diff --git a/tests/unit/settings/featuresTabDependencyGate.test.ts b/tests/unit/settings/featuresTabDependencyGate.test.ts index c18f30ef0..3494df501 100644 --- a/tests/unit/settings/featuresTabDependencyGate.test.ts +++ b/tests/unit/settings/featuresTabDependencyGate.test.ts @@ -53,8 +53,9 @@ function createHarness(customStatuses: StatusConfig[]) { toUserField: jest.fn((field: any) => field), toInternalField: jest.fn((field: any) => field), }; - const navigateToTab = jest.fn(); - (plugin as any).settingTab = { navigateToTab }; + const navigateToTab = jest.fn().mockReturnValue(true); + const invalidateTab = jest.fn(); + (plugin as any).settingTab = { navigateToTab, invalidateTab }; const container = document.createElement("div"); const save = jest.fn(); diff --git a/tests/unit/settings/settingTabNavigation.test.ts b/tests/unit/settings/settingTabNavigation.test.ts index 310210f1e..757e07319 100644 --- a/tests/unit/settings/settingTabNavigation.test.ts +++ b/tests/unit/settings/settingTabNavigation.test.ts @@ -56,6 +56,28 @@ describe("Settings tab programmatic navigation", () => { expect(tabContent(tab, "features").children.length).toBeGreaterThan(0); }); + it("reports whether the requested tab was activated", () => { + const { tab } = createTab(); + tab.display(); + + expect(tab.navigateToTab("features")).toBe(true); + expect(tab.navigateToTab("no-such-tab")).toBe(false); + }); + + it("rebuilds an invalidated tab on the next click-driven activation", () => { + const { tab, plugin } = createTab(); + tab.display(); + tab.navigateToTab("task-properties"); + expect(tabContent(tab, "task-properties").children.length).toBeGreaterThan(0); + + plugin.settings.customStatuses[0].label = "Renamed after render"; + tab.invalidateTab("task-properties"); + tabButton(tab, "features").click(); + tabButton(tab, "task-properties").click(); + + expect(tabContent(tab, "task-properties").textContent).toContain("Renamed after render"); + }); + it("re-renders an already rendered tab so it reflects settings changed since", () => { const { tab, plugin } = createTab(); plugin.settings.customStatuses[0].label = "Label before"; diff --git a/tests/unit/settings/statusPropertyCard.lifecycle.test.ts b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts index 8d3bba770..d64b71e86 100644 --- a/tests/unit/settings/statusPropertyCard.lifecycle.test.ts +++ b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts @@ -41,6 +41,7 @@ function renderCard(status: StatusConfig) { defaultTaskStatus: status.value, }; (plugin as any).registerEvent = jest.fn(); + (plugin as any).settingTab = { invalidateTab: jest.fn(), navigateToTab: jest.fn() }; (plugin as any).manifest = { version: "0.0.0" }; const i18n = createI18nService(); (plugin as any).i18n = i18n; diff --git a/tests/unit/settings/statusReadinessIndicator.test.ts b/tests/unit/settings/statusReadinessIndicator.test.ts index 40b6a69eb..91737a51e 100644 --- a/tests/unit/settings/statusReadinessIndicator.test.ts +++ b/tests/unit/settings/statusReadinessIndicator.test.ts @@ -52,8 +52,9 @@ function renderTab(customStatuses: StatusConfig[], enableAdvancedDependencyTypes (plugin as any).manifest = { version: "0.0.0" }; const i18n = createI18nService(); (plugin as any).i18n = i18n; - const navigateToTab = jest.fn(); - (plugin as any).settingTab = { navigateToTab }; + const navigateToTab = jest.fn().mockReturnValue(true); + const invalidateTab = jest.fn(); + (plugin as any).settingTab = { navigateToTab, invalidateTab }; const translate = (key: string, params?: Record) => i18n.translate(key, params); From 34218b7fa42806ace803be3180fd5e14b7cb65f3 Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 19:46:44 +1200 Subject: [PATCH 32/33] fix(dependencies): rebuild dependency state when a status is recategorized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dependency cache memoizes started/finished per file at index time, and the cache-settings snapshot that gates its rebuild covers taskTag, folders, indexing and field mapping — not the status set. Assigning a Started category therefore refreshed StatusManager while leaving every already-indexed verdict stale, so start-to-start and start-to-finish edges stayed blocked until the predecessor note changed or the plugin reloaded. That is exactly the repair the readiness modal sends the user to make. Track the classification inputs the cache actually reads — a status's value, completed flag and category — and rebuild the dependency indexes when they change. The task cache keeps its own narrower trigger, so recolouring or renaming a status no longer implies a filter-index rebuild and a data-changed broadcast. --- src/services/SettingsLifecycleService.ts | 40 ++++- ...ngsLifecycleDependencyInvalidation.test.ts | 142 ++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 tests/unit/services/settingsLifecycleDependencyInvalidation.test.ts diff --git a/src/services/SettingsLifecycleService.ts b/src/services/SettingsLifecycleService.ts index 3f5b8dc22..69901f6e3 100644 --- a/src/services/SettingsLifecycleService.ts +++ b/src/services/SettingsLifecycleService.ts @@ -28,6 +28,7 @@ interface TimeTrackingSettingsSnapshot { export class SettingsLifecycleService { private previousCacheSettings: CacheSettingsSnapshot | null = null; + private previousStatusClassification: string | null = null; private previousTimeTrackingSettings: TimeTrackingSettingsSnapshot | null = null; private autoStopTimeTrackingListener: unknown = null; private saveSettingsPromise: Promise | null = null; @@ -37,6 +38,7 @@ export class SettingsLifecycleService { captureCurrentSettings(): void { this.updatePreviousCacheSettings(); + this.updatePreviousStatusClassification(); this.updatePreviousTimeTrackingSettings(); } @@ -91,6 +93,7 @@ export class SettingsLifecycleService { this.plugin.apiService?.syncWebhookSettings?.(); const cacheSettingsChanged = this.haveCacheSettingsChanged(); + const statusClassificationChanged = this.hasStatusClassificationChanged(); const timeTrackingSettingsChanged = this.haveTimeTrackingSettingsChanged(); this.plugin.fieldMapper?.updateMapping(this.plugin.settings.fieldMapping); @@ -108,10 +111,18 @@ export class SettingsLifecycleService { operation: "cache-related-settings-changed-updating-cache-configuration", }); this.plugin.cacheManager.updateConfig(this.plugin.settings); - this.plugin.dependencyCache?.updateConfig(this.plugin.settings); this.updatePreviousCacheSettings(); } + if (cacheSettingsChanged || statusClassificationChanged) { + tasknotesLogger.debug("Rebuilding dependency indexes for the current configuration", { + category: "configuration", + operation: "rebuilding-dependency-indexes-for-the-current-configuration", + }); + this.plugin.dependencyCache?.updateConfig(this.plugin.settings); + this.updatePreviousStatusClassification(); + } + this.plugin.injectCustomStyles(); if (timeTrackingSettingsChanged) { @@ -141,6 +152,7 @@ export class SettingsLifecycleService { this.plugin.cacheManager.updateConfig(this.plugin.settings); this.plugin.dependencyCache?.updateConfig(this.plugin.settings); this.updatePreviousCacheSettings(); + this.updatePreviousStatusClassification(); this.setupTimeTrackingEventListeners(); this.plugin.injectCustomStyles(); @@ -225,6 +237,28 @@ export class SettingsLifecycleService { ); } + /** + * The dependency cache memoizes started/finished per file, so refreshing `StatusManager` + * alone leaves those verdicts stale when a status is recategorized. + */ + private hasStatusClassificationChanged(): boolean { + if (this.previousStatusClassification === null) { + return true; + } + + return this.buildStatusClassification() !== this.previousStatusClassification; + } + + private buildStatusClassification(): string { + return JSON.stringify( + (this.plugin.settings.customStatuses ?? []).map((status) => [ + status.value, + status.isCompleted === true, + status.category ?? null, + ]) + ); + } + private haveTimeTrackingSettingsChanged(): boolean { if (!this.previousTimeTrackingSettings) { return true; @@ -246,6 +280,10 @@ export class SettingsLifecycleService { }; } + private updatePreviousStatusClassification(): void { + this.previousStatusClassification = this.buildStatusClassification(); + } + private updatePreviousTimeTrackingSettings(): void { this.previousTimeTrackingSettings = { autoStopTimeTrackingOnComplete: this.plugin.settings.autoStopTimeTrackingOnComplete, diff --git a/tests/unit/services/settingsLifecycleDependencyInvalidation.test.ts b/tests/unit/services/settingsLifecycleDependencyInvalidation.test.ts new file mode 100644 index 000000000..5e979f86c --- /dev/null +++ b/tests/unit/services/settingsLifecycleDependencyInvalidation.test.ts @@ -0,0 +1,142 @@ +import { jest } from "@jest/globals"; +import { SettingsLifecycleService } from "../../../src/services/SettingsLifecycleService"; +import { DEFAULT_SETTINGS } from "../../../src/settings/defaults"; +import type { TaskNotesSettings } from "../../../src/types/settings"; + +function cloneSettings(): TaskNotesSettings { + return JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) as TaskNotesSettings; +} + +function createLifecyclePlugin(): any { + return { + settings: cloneSettings(), + saveSettingsDataOnly: jest.fn().mockResolvedValue(undefined), + apiService: { syncWebhookSettings: jest.fn() }, + fieldMapper: { + updateMapping: jest.fn(), + updateUserFields: jest.fn(), + updateConfiguredValues: jest.fn(), + }, + statusManager: { + updateStatuses: jest.fn(), + isCompletedStatus: jest.fn(), + }, + priorityManager: { + updatePriorities: jest.fn(), + }, + cacheManager: { updateConfig: jest.fn() }, + dependencyCache: { updateConfig: jest.fn() }, + injectCustomStyles: jest.fn(), + statusBarService: { updateVisibility: jest.fn() }, + mdbaseSpecService: { onSettingsChanged: jest.fn() }, + filterService: { refreshFilterOptions: jest.fn() }, + notifyDataChanged: jest.fn(), + emitter: { + trigger: jest.fn(), + on: jest.fn(), + offref: jest.fn(), + }, + }; +} + +function createSettledService(): { + plugin: any; + service: SettingsLifecycleService; +} { + const plugin = createLifecyclePlugin(); + const service = new SettingsLifecycleService(plugin); + service.captureCurrentSettings(); + return { plugin, service }; +} + +function firstStatusWithCategory(settings: TaskNotesSettings, category: string) { + const status = settings.customStatuses.find((candidate) => candidate.category === category); + if (!status) { + throw new Error(`no stock status is categorized ${category}`); + } + return status; +} + +describe("settings lifecycle dependency-cache invalidation", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("rebuilds dependency state when a status changes category", async () => { + const { plugin, service } = createSettledService(); + + firstStatusWithCategory(plugin.settings, "planned").category = "in-progress"; + await service.saveSettings(); + + expect(plugin.dependencyCache.updateConfig).toHaveBeenCalledWith(plugin.settings); + }); + + it("rebuilds dependency state when a status changes its completed flag", async () => { + const { plugin, service } = createSettledService(); + + firstStatusWithCategory(plugin.settings, "in-progress").isCompleted = true; + await service.saveSettings(); + + expect(plugin.dependencyCache.updateConfig).toHaveBeenCalledWith(plugin.settings); + }); + + it("rebuilds dependency state when a status the cache classifies is removed", async () => { + const { plugin, service } = createSettledService(); + + plugin.settings.customStatuses = plugin.settings.customStatuses.filter( + (status: { category?: string }) => status.category !== "in-progress" + ); + await service.saveSettings(); + + expect(plugin.dependencyCache.updateConfig).toHaveBeenCalledWith(plugin.settings); + }); + + it("leaves the task cache alone when only a status category changed", async () => { + const { plugin, service } = createSettledService(); + + firstStatusWithCategory(plugin.settings, "planned").category = "in-progress"; + await service.saveSettings(); + + expect(plugin.cacheManager.updateConfig).not.toHaveBeenCalled(); + }); + + it("does not rebuild dependency state when no classification input changed", async () => { + const { plugin, service } = createSettledService(); + + plugin.settings.showTrackedTasksInStatusBar = !plugin.settings.showTrackedTasksInStatusBar; + await service.saveSettings(); + + expect(plugin.dependencyCache.updateConfig).not.toHaveBeenCalled(); + }); + + it("does not rebuild dependency state when a status changes something the cache never reads", async () => { + const { plugin, service } = createSettledService(); + + firstStatusWithCategory(plugin.settings, "planned").color = "#123456"; + await service.saveSettings(); + + expect(plugin.dependencyCache.updateConfig).not.toHaveBeenCalled(); + }); + + it("rebuilds dependency state once per status change rather than on every later save", async () => { + const { plugin, service } = createSettledService(); + + firstStatusWithCategory(plugin.settings, "planned").category = "in-progress"; + await service.saveSettings(); + plugin.dependencyCache.updateConfig.mockClear(); + + await service.saveSettings(); + + expect(plugin.dependencyCache.updateConfig).not.toHaveBeenCalled(); + }); + + it("still rebuilds both caches when a cache-scoped setting changes", async () => { + const { plugin, service } = createSettledService(); + + plugin.settings.excludedFolders = "archive"; + await service.saveSettings(); + + expect(plugin.cacheManager.updateConfig).toHaveBeenCalledWith(plugin.settings); + expect(plugin.dependencyCache.updateConfig).toHaveBeenCalledWith(plugin.settings); + }); +}); From e789d7d0414abcffb59bf072254667de47e7c01e Mon Sep 17 00:00:00 2001 From: renatomen Date: Sun, 26 Jul 2026 20:20:55 +1200 Subject: [PATCH 33/33] fix(dependencies): state the consequence that matches the missing category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal carried one consequence line for both trigger conditions, and it was wrong for one of them. Because countStatusCategories normalizes every status into exactly one bucket, no Not started category means every status is either Started or Completed — so isStarted answers true for all of them and start-based edges release immediately rather than only on completion. The modal told that user the opposite of what would happen, at the moment they were deciding whether to enable the feature. Split the string in two and pick by which category is absent: a missing Started still warns that start-based edges wait for completion, while a missing Not started warns that they stop gating altogether. Both missing lands on the same warning, since every remaining status is Completed and therefore started. Also clears two analyzer findings on this work: the modal's injected members are readonly, and the navigation test asserts an empty child list with toHaveLength. --- i18n.manifest.json | 3 ++- i18n.state.json | 24 ++++++++++++------- src/i18n/resources/en.ts | 8 +++++-- .../DependencyReadinessConfirmationModal.ts | 13 +++++++--- .../dependencyReadinessConfirmation.test.ts | 16 ++++++++++++- .../settings/settingTabNavigation.test.ts | 2 +- 6 files changed, 50 insertions(+), 16 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index c908780d0..b9b7404cc 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1711,7 +1711,8 @@ "modals.storageLocation.buttons.switch": "51bd93f90cb59c08ac41590cef8747ee8f4b583d", "modals.dependencyReadiness.title": "708e148395a14028d3a5a53fbef8eb8a52ebf043", "modals.dependencyReadiness.missing": "6f0ceebbe754b904050d85e4ab77fac9b1bf2c86", - "modals.dependencyReadiness.consequence": "5c8bcbfc462418ce13b3f863dde8e3365765f672", + "modals.dependencyReadiness.consequence.releasesOnCompletion": "5c8bcbfc462418ce13b3f863dde8e3365765f672", + "modals.dependencyReadiness.consequence.releasesImmediately": "f2fd73dffc1adf732ecd7fc490c170f441d4fe44", "modals.dependencyReadiness.remedy": "ac55d0e50a8fae67dcc885b200cc8dc00fc2c88d", "modals.dependencyReadiness.buttons.goBack": "e84712fbf4eee89d1d896d75da5b5b9ceca43eac", "modals.dependencyReadiness.buttons.justEnable": "9eb4be85f026ff28a7c7a31e3be6de3e5e9f1ba3", diff --git a/i18n.state.json b/i18n.state.json index 0ff840c4f..f017eab2d 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -6737,7 +6737,8 @@ }, "modals.dependencyReadiness.title": null, "modals.dependencyReadiness.missing": null, - "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.consequence.releasesOnCompletion": null, + "modals.dependencyReadiness.consequence.releasesImmediately": null, "modals.dependencyReadiness.remedy": null, "modals.dependencyReadiness.buttons.goBack": null, "modals.dependencyReadiness.buttons.justEnable": null, @@ -15651,7 +15652,8 @@ }, "modals.dependencyReadiness.title": null, "modals.dependencyReadiness.missing": null, - "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.consequence.releasesOnCompletion": null, + "modals.dependencyReadiness.consequence.releasesImmediately": null, "modals.dependencyReadiness.remedy": null, "modals.dependencyReadiness.buttons.goBack": null, "modals.dependencyReadiness.buttons.justEnable": null, @@ -24565,7 +24567,8 @@ }, "modals.dependencyReadiness.title": null, "modals.dependencyReadiness.missing": null, - "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.consequence.releasesOnCompletion": null, + "modals.dependencyReadiness.consequence.releasesImmediately": null, "modals.dependencyReadiness.remedy": null, "modals.dependencyReadiness.buttons.goBack": null, "modals.dependencyReadiness.buttons.justEnable": null, @@ -33479,7 +33482,8 @@ }, "modals.dependencyReadiness.title": null, "modals.dependencyReadiness.missing": null, - "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.consequence.releasesOnCompletion": null, + "modals.dependencyReadiness.consequence.releasesImmediately": null, "modals.dependencyReadiness.remedy": null, "modals.dependencyReadiness.buttons.goBack": null, "modals.dependencyReadiness.buttons.justEnable": null, @@ -42393,7 +42397,8 @@ }, "modals.dependencyReadiness.title": null, "modals.dependencyReadiness.missing": null, - "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.consequence.releasesOnCompletion": null, + "modals.dependencyReadiness.consequence.releasesImmediately": null, "modals.dependencyReadiness.remedy": null, "modals.dependencyReadiness.buttons.goBack": null, "modals.dependencyReadiness.buttons.justEnable": null, @@ -51307,7 +51312,8 @@ }, "modals.dependencyReadiness.title": null, "modals.dependencyReadiness.missing": null, - "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.consequence.releasesOnCompletion": null, + "modals.dependencyReadiness.consequence.releasesImmediately": null, "modals.dependencyReadiness.remedy": null, "modals.dependencyReadiness.buttons.goBack": null, "modals.dependencyReadiness.buttons.justEnable": null, @@ -60221,7 +60227,8 @@ }, "modals.dependencyReadiness.title": null, "modals.dependencyReadiness.missing": null, - "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.consequence.releasesOnCompletion": null, + "modals.dependencyReadiness.consequence.releasesImmediately": null, "modals.dependencyReadiness.remedy": null, "modals.dependencyReadiness.buttons.goBack": null, "modals.dependencyReadiness.buttons.justEnable": null, @@ -69135,7 +69142,8 @@ }, "modals.dependencyReadiness.title": null, "modals.dependencyReadiness.missing": null, - "modals.dependencyReadiness.consequence": null, + "modals.dependencyReadiness.consequence.releasesOnCompletion": null, + "modals.dependencyReadiness.consequence.releasesImmediately": null, "modals.dependencyReadiness.remedy": null, "modals.dependencyReadiness.buttons.goBack": null, "modals.dependencyReadiness.buttons.justEnable": null, diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index b844c8541..4f3370693 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2874,8 +2874,12 @@ export const en: TranslationTree = { dependencyReadiness: { title: "Enable advanced dependency types?", missing: "No status is categorized as {categories}.", - consequence: - "Start-to-start and start-to-finish dependencies will release only when the predecessor completes, not when it starts.", + consequence: { + releasesOnCompletion: + "Start-to-start and start-to-finish dependencies will release only when the predecessor completes, not when it starts.", + releasesImmediately: + "Every status already counts as started, so start-to-start and start-to-finish dependencies will release immediately instead of holding the task until the predecessor starts.", + }, remedy: "To change that, assign the missing category to one of your existing statuses in the task properties settings.", buttons: { goBack: "Go back", diff --git a/src/modals/DependencyReadinessConfirmationModal.ts b/src/modals/DependencyReadinessConfirmationModal.ts index 6d29770a6..38e1733d6 100644 --- a/src/modals/DependencyReadinessConfirmationModal.ts +++ b/src/modals/DependencyReadinessConfirmationModal.ts @@ -16,8 +16,8 @@ const CHOICES: ReadonlyArray<[string, DependencyReadinessChoice]> = [ * status. Callers pass the missing categories; the modal reads no settings of its own. */ export class DependencyReadinessConfirmationModal extends Modal { - private plugin: TaskNotesPlugin; - private missingCategories: StatusCategory[]; + private readonly plugin: TaskNotesPlugin; + private readonly missingCategories: StatusCategory[]; private resolve: ((choice: DependencyReadinessChoice) => void) | null = null; constructor(plugin: TaskNotesPlugin, missingCategories: StatusCategory[]) { @@ -30,6 +30,13 @@ export class DependencyReadinessConfirmationModal extends Modal { return this.plugin.i18n.translate(key, params); } + /** Without a Not started status every remaining status counts as started, which inverts the degradation. */ + private consequenceKey(): string { + return this.missingCategories.includes("planned") + ? "modals.dependencyReadiness.consequence.releasesImmediately" + : "modals.dependencyReadiness.consequence.releasesOnCompletion"; + } + public show(): Promise { return new Promise((resolve) => { this.resolve = resolve; @@ -50,7 +57,7 @@ export class DependencyReadinessConfirmationModal extends Modal { contentEl.createEl("p", { text: this.t("modals.dependencyReadiness.missing", { categories }), }); - contentEl.createEl("p", { text: this.t("modals.dependencyReadiness.consequence") }); + contentEl.createEl("p", { text: this.t(this.consequenceKey()) }); contentEl.createEl("p", { text: this.t("modals.dependencyReadiness.remedy") }); const buttonContainer = contentEl.createDiv({ cls: "modal-button-container" }); diff --git a/tests/unit/modals/dependencyReadinessConfirmation.test.ts b/tests/unit/modals/dependencyReadinessConfirmation.test.ts index e5620c919..b3e2a6bdd 100644 --- a/tests/unit/modals/dependencyReadinessConfirmation.test.ts +++ b/tests/unit/modals/dependencyReadinessConfirmation.test.ts @@ -76,13 +76,27 @@ describe("Dependency readiness confirmation modal", () => { expect(bodyText(modal)).toContain("No status is categorized as Not started or Started."); }); - it("states that start-based dependencies release on completion rather than on start", () => { + it("states that start-based dependencies wait for completion when no status is Started", () => { const { modal } = openModal(["in-progress"]); expect(bodyText(modal)).toContain( "release only when the predecessor completes, not when it starts" ); }); + it("states that start-based dependencies stop gating when no status is Not started", () => { + const { modal } = openModal(["planned"]); + expect(bodyText(modal)).toContain( + "release immediately instead of holding the task until the predecessor starts" + ); + }); + + it("states that start-based dependencies stop gating when both categories are missing", () => { + const { modal } = openModal(["planned", "in-progress"]); + expect(bodyText(modal)).toContain( + "release immediately instead of holding the task until the predecessor starts" + ); + }); + it("states the remedy of assigning the category in task properties", () => { const { modal } = openModal(["in-progress"]); expect(bodyText(modal)).toContain( diff --git a/tests/unit/settings/settingTabNavigation.test.ts b/tests/unit/settings/settingTabNavigation.test.ts index 757e07319..5ba5c7771 100644 --- a/tests/unit/settings/settingTabNavigation.test.ts +++ b/tests/unit/settings/settingTabNavigation.test.ts @@ -49,7 +49,7 @@ describe("Settings tab programmatic navigation", () => { it("renders a tab that has never been rendered", () => { const { tab } = createTab(); tab.display(); - expect(tabContent(tab, "features").children.length).toBe(0); + expect(tabContent(tab, "features").children).toHaveLength(0); tab.navigateToTab("features");