From 2bdc5894cfc68a875fb716aebaae1c162fe50120 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 961c3376b..36cd9bc28 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"; @@ -136,6 +136,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 0e77749a2..5d46f9a65 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 9fbf67f17..35907d860 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 4ce5ed354..901de86e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -732,13 +732,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 619c07bad53e36ef429ea41f206616c40c00b3ef 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 e28e799f5..6106b6f90 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -710,6 +710,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", @@ -723,6 +724,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", @@ -732,6 +734,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 7ebb09e0d..c32af1eb4 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2844,6 +2844,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "cb9ab4cdcceb9e2aeb360e177c24b02d93f30ac8" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "692e1e30972b496ecd275c59b3bdef98fc743fd4" @@ -2896,6 +2897,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "a138e2592f8e86664bee5d2261abd967cb1e1024" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "977a68aff1290a7f18c23491f18fd496635c4704" @@ -2932,6 +2934,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" @@ -11734,6 +11743,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "ef5bc07ca67951bfd0227f7cde1f9d628e93da09" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "3d6f9cec0a3f2f016e0d1d5d97750710f924e41a" @@ -11786,6 +11796,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "49bcf96e290b8b04ab476892243e71fa2f3c486a" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "9b8011e3aa98c24cba7a3abd3cfd47fa59d3f447" @@ -11822,6 +11833,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" @@ -20624,6 +20642,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "8df3b29da68c74050b5af7233aa7f88b32cba2cc" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "5a24d44f681e6afe77c73cff479d5c7828569810" @@ -20676,6 +20695,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "6edcf9c3483f8a2577d3a8175dac1e55219a8241" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "042f017c2648c2cd227ff2b0cf95a821e19cea54" @@ -20712,6 +20732,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" @@ -29514,6 +29541,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "9e0eaf71298108654fd791ac948dd6c8dd1b439e" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "c7ecc4029c4dd7625ebca6fe44c596c26338d981" @@ -29566,6 +29594,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "886556b844ad628bf72491209c1e20304e58c722" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "8d3d57a8b4f55f55dcc1d748c73d05ff110619cf" @@ -29602,6 +29631,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" @@ -38404,6 +38440,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "92fc9c57e556f7b81989589101b7e3b4cf77244e" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "f410df49c842cee9f0fd04207f66a9f7d084835e" @@ -38456,6 +38493,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "18bafeed301d12d6ecf88b2ad49467af8a9f3498" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "3b2c81ef0aabc2b45ca597bfc52ff601abaa9540" @@ -38492,6 +38530,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" @@ -47294,6 +47339,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "c041d0942c21612e1c4fd36563380da2781424bc" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "a5d6f43e85055d22939a19a1f69673400925464e" @@ -47346,6 +47392,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "8fbc8d895e59321d9d909fd12d9322147afddc98" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "f9a26783183369f7753926a8c50f866faffaddfc" @@ -47382,6 +47429,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" @@ -56184,6 +56238,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "652c5354d61a3dd8ee6f00b351a77d0d86d34a79" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "58d500bcc42fe10e16454b41f6c9caf89a3ec23e" @@ -56236,6 +56291,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "bb155f6460bf0b86f81e40d4cb310624c008ea76" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "7ca1e438ff083d0121ab00f1abc9c81aed81e5cf" @@ -56272,6 +56328,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" @@ -65074,6 +65137,7 @@ "source": "e6bd6dc7043b1d939ae805d795e5f3b6c2d4470b", "translation": "2b7120e8e1d1fdc14d8013fb0f784020d4179f3e" }, + "settings.taskProperties.taskStatuses.howTheyWork.category": null, "settings.taskProperties.taskStatuses.howTheyWork.autoArchive": { "source": "bd84a52c7f3268c53cbd87f48ca89f2224a7ac31", "translation": "bcea4aa6f70e5baa25c94c14da65f9473fcf697c" @@ -65126,6 +65190,7 @@ "source": "208409ffed559a03204711e8976b06e3710deca5", "translation": "4fc47c7b6e532be9bca0e26fb998757773d99999" }, + "settings.taskProperties.taskStatuses.fields.category": null, "settings.taskProperties.taskStatuses.fields.excludeFromCycle": { "source": "6e3d4319d8e9bc72452a8ea748a148aace623f36", "translation": "050a8fffceabd90128bd8a5b501c6dc71c67d346" @@ -65162,6 +65227,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 2a427eb2f..72aefb56b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1221,6 +1221,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: @@ -1241,6 +1243,7 @@ export const en: TranslationTree = { color: "Color:", icon: "Icon:", completed: "Completed:", + category: "Category:", excludeFromCycle: "Skip when cycling:", nextStatus: "Next status:", autoArchive: "Auto-archive:", @@ -1254,6 +1257,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 f523eaf0b..6be82436b 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 = activeWindow.createSpan(); badge.addClass("tasknotes-settings__card-status-badge"); diff --git a/src/settings/tabs/taskProperties/statusPropertyCard.ts b/src/settings/tabs/taskProperties/statusPropertyCard.ts index 0bffdf915..8f7fe97a2 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -19,6 +19,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 @@ -103,7 +120,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"), @@ -255,19 +272,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(); }); @@ -311,14 +341,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; @@ -417,9 +440,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 8208f4ebf..5fb053c09 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -498,6 +498,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 02e7cb28a5a1ce273edd01afc83281ed27b49ee1 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. --- 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 +- 8 files changed, 53 insertions(+), 125 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index 6106b6f90..c226fe0d1 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -709,7 +709,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", @@ -723,7 +722,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", @@ -737,10 +735,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 c32af1eb4..edb0c5ac9 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2840,10 +2840,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", @@ -2893,10 +2889,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", @@ -2937,10 +2929,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" @@ -11739,10 +11727,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", @@ -11792,10 +11776,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", @@ -11836,10 +11816,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" @@ -20638,10 +20614,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", @@ -20691,10 +20663,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", @@ -20735,10 +20703,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" @@ -29537,10 +29501,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", @@ -29590,10 +29550,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", @@ -29634,10 +29590,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" @@ -38436,10 +38388,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", @@ -38489,10 +38437,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", @@ -38533,10 +38477,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" @@ -47335,10 +47275,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", @@ -47388,10 +47324,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", @@ -47432,10 +47364,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" @@ -56234,10 +56162,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", @@ -56287,10 +56211,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", @@ -56331,10 +56251,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" @@ -65133,10 +65049,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", @@ -65186,10 +65098,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", @@ -65230,10 +65138,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 72aefb56b..099da1d6c 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1219,8 +1219,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: @@ -1242,7 +1240,6 @@ export const en: TranslationTree = { label: "Label:", color: "Color:", icon: "Icon:", - completed: "Completed:", category: "Category:", excludeFromCycle: "Skip when cycling:", nextStatus: "Next status:", @@ -1261,12 +1258,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 5d46f9a65..ebb049b8c 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 8f7fe97a2..a9d43a445 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -277,13 +277,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 43d4a6dae..499d03aab 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; @@ -211,3 +212,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 d611b392cf417c4c08145a98a96445389220bb2e 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. --- 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 +++++++--------- 12 files changed, 62 insertions(+), 77 deletions(-) diff --git a/i18n.manifest.json b/i18n.manifest.json index c226fe0d1..5676d78e9 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -732,9 +732,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 edb0c5ac9..299492b1f 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2928,7 +2928,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" @@ -11815,7 +11814,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" @@ -20702,7 +20700,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" @@ -29589,7 +29586,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" @@ -38476,7 +38472,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" @@ -47363,7 +47358,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" @@ -56250,7 +56244,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" @@ -65137,7 +65130,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 099da1d6c..df7c216bc 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1254,9 +1254,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 36cd9bc28..cf7381971 100644 --- a/src/services/StatusManager.ts +++ b/src/services/StatusManager.ts @@ -142,7 +142,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); @@ -278,6 +278,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 6be82436b..f1a07dc6e 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 = activeWindow.createSpan(); diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index ebb049b8c..b0630fff5 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 a9d43a445..983b7e3aa 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -21,16 +21,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 @@ -171,8 +171,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, @@ -273,26 +273,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 901de86e9..07d74483b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -733,8 +733,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"; @@ -747,7 +748,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 5fb053c09..7ed71557b 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -513,12 +513,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 499d03aab..32bd033a8 100644 --- a/tests/unit/settings/settingsPersistence.test.ts +++ b/tests/unit/settings/settingsPersistence.test.ts @@ -234,11 +234,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 100e8c422c023d8c44bc60bc39e6185e93b85da5 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 1818d5c51..9d3f7a433 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 3fbdbfda5..1fbe33c6b 100644 --- a/tests/helpers/mock-factories.ts +++ b/tests/helpers/mock-factories.ts @@ -529,6 +529,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 062dff9a4aca4025ff7f83db39e16d5a55fd932a 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 88b2ff3df..872083f9f 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"; import { createElementInDocument } from "../utils/documentDom"; @@ -218,18 +222,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: @@ -271,6 +285,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 07d74483b..b61e9bcad 100644 --- a/src/types.ts +++ b/src/types.ts @@ -491,8 +491,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 d89d52d5e..0539a2f6a 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"; @@ -337,23 +338,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 417701d0bf0c496862ff25c591a86f13b2a13470 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 4130db5dad296063cbcd0070888f2dbb7752df06 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. --- 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 ++++++++++++++++++ 14 files changed, 495 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/i18n.manifest.json b/i18n.manifest.json index 5676d78e9..57b1268a3 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1993,6 +1993,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 299492b1f..00e5d165f 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7964,6 +7964,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" @@ -16850,6 +16855,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" @@ -25736,6 +25746,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" @@ -34622,6 +34637,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" @@ -43508,6 +43528,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" @@ -52394,6 +52419,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" @@ -61280,6 +61310,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" @@ -70166,6 +70201,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 df7c216bc..c8397c822 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3277,6 +3277,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 434dd5111..bfee33b2a 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" }); @@ -435,13 +436,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 dedb877f1..5afeeb851 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -12,7 +12,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 { @@ -315,6 +318,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 @@ -338,18 +380,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; } } } @@ -522,19 +564,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; } @@ -545,14 +585,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 9d3f7a433..1cd0ab873 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 61548c553a36634d0237fc4075e15b671f9b4032 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. --- 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 ++++ 18 files changed, 845 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/i18n.manifest.json b/i18n.manifest.json index 57b1268a3..dcb6e60ea 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1568,6 +1568,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 00e5d165f..833d68a49 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -6264,6 +6264,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" @@ -15155,6 +15175,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" @@ -24046,6 +24086,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" @@ -32937,6 +32997,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" @@ -41828,6 +41908,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" @@ -50719,6 +50819,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" @@ -59610,6 +59730,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" @@ -68501,6 +68641,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 c8397c822..a79df602c 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2640,6 +2640,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 7e5ef57ae..6fd0a2967 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; @@ -117,13 +119,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 = []; } @@ -453,7 +472,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(); @@ -491,7 +511,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); @@ -525,7 +546,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:", { @@ -559,6 +580,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 cb5ba0888..5099fdf9d 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[]; @@ -227,6 +229,10 @@ function toTaskStringList(value: unknown): string[] { return []; } +function isSameReltypeAndGap(a: TaskDependency, b: TaskDependency): boolean { + return a.reltype === b.reltype && (a.gap ?? "") === (b.gap ?? ""); +} + function buildBlockingUpdates(input: TaskEditChangeInput): { blockingUpdates: BlockingUpdates; unresolvedBlockingEntries: string[]; @@ -247,9 +253,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 }; @@ -257,7 +275,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 899390967..c1d8705af 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -79,6 +79,7 @@ import { applyGoogleCalendarRecurringExceptionForScheduledChange, } from "./task-service/googleCalendarRecurringExceptions"; import { + type BlockingRelationshipAction, buildBlockedByTaskUpdate, buildBlockingRelationshipPathChanges, computeBlockedByUpdate, @@ -1597,7 +1598,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. @@ -1648,12 +1650,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 b0630fff5..0456a1ffc 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -356,6 +356,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 32d6b9882..df379823b 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -179,6 +179,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 1338aa9a2..fc1c12dff 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 40aa798aa67e816cb09c245c906bed6057dea4c8 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 dcb6e60ea..4a91b6b95 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -348,6 +348,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 833d68a49..ca404af4f 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -1396,6 +1396,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" @@ -10307,6 +10311,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" @@ -19218,6 +19226,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" @@ -28129,6 +28141,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" @@ -37040,6 +37056,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" @@ -45951,6 +45971,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" @@ -54862,6 +54886,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" @@ -63773,6 +63801,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 a79df602c..5fea3895a 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -514,6 +514,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 19f606961ea575f83d2ae86b43e967c708a09b71 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 7ce977d09..fe03eca6e 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 6e849bc14382493f3630552d7a8dccc3f5a59b6d 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. --- 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 ++- 12 files changed, 196 insertions(+), 55 deletions(-) diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index 872083f9f..08c642f26 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"; @@ -221,27 +220,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 cf7381971..840490d7d 100644 --- a/src/services/StatusManager.ts +++ b/src/services/StatusManager.ts @@ -145,8 +145,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 b61e9bcad..084e143b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -491,7 +491,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 1cd0ab873..1b1c1870b 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 0539a2f6a..37f5fe116 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"; @@ -337,27 +337,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 fc1c12dff..3d53346c0 100644 --- a/tests/unit/modals/taskEditChangeState.test.ts +++ b/tests/unit/modals/taskEditChangeState.test.ts @@ -194,6 +194,76 @@ describe("taskEditChangeState", () => { expect(result.changes.contexts).toBeUndefined(); }); + 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 d5bc25d4c7ac95f2108be39a8618a6d0c85cdbba 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 5afeeb851..ae3fc5cb1 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -214,6 +214,7 @@ function createProjectSubtasksToggleHandler( function createBlockingToggleClickHandler( task: TaskInfo, + plugin: TaskNotesPlugin, card: HTMLElement, handlers: TaskCardSecondaryBadgeHandlers ): () => void { @@ -224,6 +225,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); })(); }; @@ -260,9 +262,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); } @@ -373,10 +376,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, + }); } } @@ -392,10 +403,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 { @@ -534,7 +577,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 dfd434566bbd1fad146392cca608fb5a054fe3e9 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 4a91b6b95..6ed64e2c9 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -352,6 +352,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", @@ -738,6 +742,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 ca404af4f..0b01f1fb2 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -1400,6 +1400,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" @@ -2932,6 +2936,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" @@ -10315,6 +10320,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" @@ -11847,6 +11856,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" @@ -19230,6 +19240,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" @@ -20762,6 +20776,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" @@ -28145,6 +28160,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" @@ -29677,6 +29696,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" @@ -37060,6 +37080,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" @@ -38592,6 +38616,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" @@ -45975,6 +46000,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" @@ -47507,6 +47536,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" @@ -54890,6 +54920,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" @@ -56422,6 +56456,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" @@ -63805,6 +63840,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" @@ -65337,6 +65376,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 5fea3895a..3c5254926 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -522,6 +522,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", @@ -1266,6 +1273,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 0456a1ffc..107c4487a 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 983b7e3aa..f37ad1cb1 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -20,6 +20,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. @@ -37,6 +38,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 */ @@ -246,6 +273,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 7ed71557b..1ba2dad5b 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -1429,3 +1429,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 33892448348a7c4ab860c70df6252ff039d31f7e 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 6ed64e2c9..b3362f420 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -2030,6 +2030,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 0b01f1fb2..8826ab704 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -8010,6 +8010,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "3564edcba569e2d15e34f0b38542e0cdc5bae04f" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "86e4a49489b8cc0ced8c7249cfce2ae618268af3" @@ -16930,6 +16931,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "9f322ef2ec9a09368c7b0635c59b991157055425" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "f15a805078c511a9555a8a88c16b670a4b9c6f92" @@ -25850,6 +25852,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "46307ae570790f319bb1435f2c6652b116e3c8d7" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "9e64071edad38ec04234439e93121798318e2d06" @@ -34770,6 +34773,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "4b9503320c661915dc9dac425fd7fa084d7c67e1" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "c4a27cd7c9b3e2f6e9db4992ef231d61f66cbdba" @@ -43690,6 +43694,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "1c82e0b019f0faf4bb9d8ed4bf3f655d87b4eb7a" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "f67350f40f7ad2d24672f5e562f54a3bdb8d1103" @@ -52610,6 +52615,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "a84cad376851638e4537357667959951433ef52a" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "96c717aafcb524053df980be7f24731052107dd9" @@ -61530,6 +61536,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "c8b5589a0fffc49c324be3d5a717608734455df3" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "d05244686c421dd71e5cca6c8bc76879f43ca0cf" @@ -70450,6 +70457,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 3c5254926..4e1eab11c 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3335,6 +3335,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 ae3fc5cb1..7f254afbf 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -93,6 +93,21 @@ function shouldExpandSubtasksByDefault(plugin: TaskNotesPlugin): boolean { ); } +// 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 { if (!canToggleProjectSubtasks(plugin)) { return false; @@ -368,9 +383,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", @@ -568,9 +581,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 ea589d82d4595d0836de3ad684f968836f897cd9 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 b3362f420..aaabbede0 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -2030,7 +2030,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 4e1eab11c..7b5148479 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3335,7 +3335,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 c53921ec4f9218237ef8012627098e5b05089929 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 aaabbede0..248644e01 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1840,6 +1840,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 8826ab704..1a5681266 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -7265,6 +7265,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" @@ -16186,6 +16194,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" @@ -25107,6 +25123,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" @@ -34028,6 +34052,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" @@ -42949,6 +42981,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" @@ -51870,6 +51910,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" @@ -60791,6 +60839,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" @@ -69712,6 +69768,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 7b5148479..2c8c2a468 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -3051,6 +3051,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 4b7fac64931789419653e123595586a6d56152a6 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 ebbfb993eb3b62d693528a6b810484e6390c5c50 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 c3298de6f..78b01d325 100644 --- a/tests/unit/ui/taskCardSecondaryBadges.test.ts +++ b/tests/unit/ui/taskCardSecondaryBadges.test.ts @@ -59,6 +59,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 341c42e732a6fe24b3799c625250c136f24d1442 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 a0c876a6560a217ea5a85e51bd6e5d3b21f02e25 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 dbe24dc7233415da05673d2950c71a8062a8b29f 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 7f254afbf..b6d69763c 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -393,14 +393,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, - }); } } @@ -416,42 +408,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 e9871caf54e48f73999731d616121e846a39fbd1 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 b6d69763c..bbe66e8db 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -229,7 +229,6 @@ function createProjectSubtasksToggleHandler( function createBlockingToggleClickHandler( task: TaskInfo, - plugin: TaskNotesPlugin, card: HTMLElement, handlers: TaskCardSecondaryBadgeHandlers ): () => void { @@ -240,7 +239,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); })(); }; @@ -277,10 +275,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); } @@ -389,7 +386,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); @@ -548,7 +545,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 78b01d325..c3298de6f 100644 --- a/tests/unit/ui/taskCardSecondaryBadges.test.ts +++ b/tests/unit/ui/taskCardSecondaryBadges.test.ts @@ -59,10 +59,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 4021a4c332a9090376f48dfa58ff7b70652353ee 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 d7b6fa9ed..2a29b2d88 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"; @@ -100,6 +104,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(); @@ -188,6 +193,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(), @@ -216,6 +228,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 d3ac2cc32d1fc58d31f5ba51ac42512feb3664b1 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 107c4487a..b5d21908e 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 3d9714f4371fd9fa1562a2684d8ce1b6ab5da7fa 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 643880fd4..6d13bc8b7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -170,6 +170,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 20c8d4330..386c0ac75 100644 --- a/src/settings/TaskNotesSettingTab.ts +++ b/src/settings/TaskNotesSettingTab.ts @@ -170,12 +170,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); @@ -185,24 +218,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 d9ed1c6549fca50fe3ce0df42e1e3a74b396aa6d 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 248644e01..d1473a4cb 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1712,6 +1712,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 1a5681266..b3ca97108 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -6753,6 +6753,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" @@ -15682,6 +15689,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" @@ -24611,6 +24625,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" @@ -33540,6 +33561,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" @@ -42469,6 +42497,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" @@ -51398,6 +51433,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" @@ -60327,6 +60369,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" @@ -69256,6 +69305,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 2c8c2a468..da263ea8a 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2876,6 +2876,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 6d182cda8165e5e220e076dc1401d618ae145a80 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 b01ac8966431c17b762dc4fc83c3e6897f0d010d 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 d1473a4cb..49d6400ab 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -355,7 +355,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 b3ca97108..45a7760cc 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -1403,7 +1403,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" @@ -10339,7 +10339,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" @@ -19275,7 +19275,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" @@ -28211,7 +28211,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" @@ -37147,7 +37147,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" @@ -46083,7 +46083,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" @@ -55019,7 +55019,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" @@ -63955,7 +63955,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 da263ea8a..e34c6cd6b 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -526,8 +526,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 1ba2dad5b..c075d53fc 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -1430,14 +1430,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 eff6069f3b0f1d0bfe469bfd70ece2918bf38c5c 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 49d6400ab..1601fb58a 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -742,7 +742,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 45a7760cc..30f9549e0 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -2937,6 +2937,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" @@ -11873,6 +11875,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" @@ -20809,6 +20813,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" @@ -29745,6 +29751,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" @@ -38681,6 +38689,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" @@ -47617,6 +47627,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" @@ -56553,6 +56565,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" @@ -65489,6 +65503,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 e34c6cd6b..efd843806 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1273,7 +1273,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 f37ad1cb1..22a7558f0 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -20,7 +20,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. @@ -38,30 +38,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"); + }); } /** @@ -71,7 +102,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); @@ -159,19 +191,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", { @@ -207,18 +246,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 @@ -269,11 +315,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")); @@ -321,6 +368,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( @@ -413,7 +462,14 @@ function renderStatusList( } save(); - renderStatusList(container, plugin, save, translate, onStatusesChanged); + renderStatusList( + container, + plugin, + save, + translate, + onStatusesChanged, + refreshReadiness + ); if (onStatusesChanged) onStatusesChanged(); } })(); @@ -519,7 +575,7 @@ function renderStatusList( }); previousStatusValue = status.value; save(); - renderStatusList(container, plugin, save, translate, onStatusesChanged); + renderStatusList(container, plugin, save, translate, onStatusesChanged, refreshReadiness); }); labelInput.addEventListener("input", () => { @@ -584,7 +640,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 c075d53fc..80152403a 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -1449,3 +1449,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 8861866e4a844cfdde9e8e979580ab35090a8538 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 386c0ac75..50881e49f 100644 --- a/src/settings/TaskNotesSettingTab.ts +++ b/src/settings/TaskNotesSettingTab.ts @@ -174,11 +174,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; @@ -188,6 +188,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 b5d21908e..37c8ed195 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 22a7558f0..6ab4c5404 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -20,7 +20,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. @@ -74,13 +74,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), }) ); @@ -370,6 +364,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 118ceb0ba10c37b1229dcb810d091ee9742c7e65 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 43e90a2d66be15acfcca4b295dbad4d66d4afde7 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 1601fb58a..f6493e72e 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -1716,7 +1716,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 30f9549e0..7b0c16003 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -6757,7 +6757,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, @@ -15695,7 +15696,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, @@ -24633,7 +24635,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, @@ -33571,7 +33574,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, @@ -42509,7 +42513,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, @@ -51447,7 +51452,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, @@ -60385,7 +60391,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, @@ -69323,7 +69330,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 efd843806..4f257b559 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -2881,8 +2881,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");