diff --git a/docs/features/task-management.md b/docs/features/task-management.md index 3a21b5ff3..e9e39885b 100644 --- a/docs/features/task-management.md +++ b/docs/features/task-management.md @@ -153,7 +153,11 @@ Whenever a dependency is added, TaskNotes updates the upstream note’s `blockin - The task context menu provides the same selector, enabling dependency management directly from the Task List, Kanban, and calendar views. - Task cards show a fork icon whenever a task blocks other work. Clicking it expands an inline list of downstream tasks without triggering the parent card’s modal, so you can inspect dependents in place. -These controls currently create and manage finish-to-start style blockers. Advanced `reltype` values and `gap` data are preserved in frontmatter, but blocking evaluation is currently based on whether unresolved dependencies exist rather than relationship-type-specific scheduling rules. +By default, dependencies use finish-to-start. Enable **Features → Dependencies → Advanced dependency types** to choose a relationship type and an optional lag per dependency from both the "Blocked by" and "Blocking" lists in the edit modal. + +Blocking is evaluated per endpoint: finish-to-start and start-to-start gate a task's *start*, while finish-to-finish and start-to-finish gate its *finish*. An edge releases as soon as the predecessor reaches the point the relationship requires — started for a start-to-\* edge, completed for a finish-to-\* edge — so a task reads "Blocked · start" or "Blocked · finish" only while it is genuinely constrained. + +TaskNotes-rendered views (Task List, Kanban, cards) reflect this computed state. A native `.base` filter on "blocked by", however, matches by existence only: a Bases filter formula evaluates a single note's frontmatter and cannot read a predecessor's status, so it cannot distinguish a released edge from a live one. Use a TaskNotes-rendered view when you need constraint-aware blocking. ![Task context menu](../assets/feature-task-context-menu.png) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 121110a03..9669ad73d 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -31,3 +31,12 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ``` --> + +## Added + +- Dependency blocking now distinguishes a task's start from its finish, following the predecessor relationship type (finish-to-start, start-to-start, finish-to-finish, start-to-finish). A task shows "Blocked · start" when it cannot begin yet and "Blocked · finish" when it can begin but cannot complete; the signal clears the moment the predecessor reaches the point the relationship requires — started for a start-to-* edge, completed for a finish-to-* edge. A satisfied dependency stays listed under the task but no longer counts toward the blocked signal. +- The task edit modal can set each dependency's relationship type and an optional lag from both the "Blocked by" and "Blocking" lists, with a plain-language summary that names both tasks so the direction is never ambiguous. Editing a "Blocking" edge writes the relationship to the blocked task's own frontmatter. Advanced relationship types are off by default. + +## Changed + +- Task statuses now have a lifecycle category — Not started, Started, or Completed — shown as a pill in the status settings, replacing the previous "Completed" toggle. Existing completed statuses stay Completed; the rest default to Not started. If you keep a custom "in progress" status, set its category to **Started** in Settings → Task Properties so that start-based dependencies (start-to-start / start-to-finish) release when it begins — until then it is treated as Not started. diff --git a/i18n.manifest.json b/i18n.manifest.json index a30b9bbec..5e6a487d3 100644 --- a/i18n.manifest.json +++ b/i18n.manifest.json @@ -347,6 +347,14 @@ "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.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", @@ -704,7 +712,7 @@ "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", "settings.taskProperties.taskStatuses.addNew.name": "d8f65cf551e5eeb9b07f3a1279ba4ed8b0172a46", @@ -717,7 +725,7 @@ "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", "settings.taskProperties.taskStatuses.fields.autoArchive": "ebba9937a4826ac159c5c5fd87a98562f3aff675", @@ -727,6 +735,9 @@ "settings.taskProperties.taskStatuses.placeholders.icon": "6c7a4a074ff62754681360bda7c3994c8029742b", "settings.taskProperties.taskStatuses.placeholders.nextStatusDefault": "0516a99c523425cc4a3eee70cab344e056e41db6", "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", @@ -1561,6 +1572,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", @@ -1804,6 +1835,14 @@ "contextMenus.task.dependencies.removeBlockedBy": "7d38375d41dfda37e2adcdaea68e95312a792312", "contextMenus.task.dependencies.removeBlocking": "29f784e16f2ca0df6d37a9fb6f29c3a186937738", "contextMenus.task.dependencies.unknownDependency": "bc7819b34ff87570745fbe461e36a16f80e562ce", + "contextMenus.task.dependencies.reltype.blocking.finishToStart": "b50cfe466b096494591205ea9cfab3526cba9054", + "contextMenus.task.dependencies.reltype.blocking.startToStart": "25eb197f4962b676253b09c8aa866c26bd3bc642", + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": "4c05dbcb794856b1af7968e4b10a986a7c662b42", + "contextMenus.task.dependencies.reltype.blocking.startToFinish": "8635c71ae358909a9d8db530e18b9920be387a64", + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": "96d8d50fbcc29b664f7880e1ad48b6fb9a5da51f", + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": "aaf413f633282c6874dc3b04f2c701803acaa67b", + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": "687b6b600b1e26e581f09e9f7df0192f740a7112", + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": "07bc71f9865dcde8b4303e9d5b2f74674704e284", "contextMenus.task.dependencies.inputPlaceholder": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "contextMenus.task.dependencies.notices.noEntries": "e5145350fdd7dee5b4a532bea740c844b64d938e", "contextMenus.task.dependencies.notices.blockedByAdded": "5b40b08d56beaf43181cea5aeb55474c47a3e347", @@ -1986,9 +2025,15 @@ "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", + "ui.taskCard.blockingToggleBreakdown": "57d886e2f4236976638cb06097f510606848015b", "ui.taskCard.priorityAriaLabel": "28f478cb8ee60d0167b0507b87fa966e52061d81", "ui.taskCard.taskOptions": "d7788a93bb9a5f6b6770830ead3075c0b91cc9a3", "ui.taskCard.recurrenceTooltip": "468ddecacd77613fb8597819d97008bc336a76e8", diff --git a/i18n.state.json b/i18n.state.json index 01d90a504..1ce39808c 100644 --- a/i18n.state.json +++ b/i18n.state.json @@ -1392,6 +1392,14 @@ "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.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" @@ -2820,10 +2828,7 @@ "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", "translation": "692e1e30972b496ecd275c59b3bdef98fc743fd4" @@ -2872,10 +2877,7 @@ "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", "translation": "977a68aff1290a7f18c23491f18fd496635c4704" @@ -2912,6 +2914,9 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "be274c6e71f4a5837b1f8124da980ac92a56ac7e" }, + "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" @@ -6248,6 +6253,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" @@ -7220,6 +7245,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "d0b00a9fadc88a3867b81d199cf03ae96a7b2f7b" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "bbd106824000d1d212bf07cc7aa6a94e663e150d" @@ -7948,6 +7981,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" @@ -7960,6 +7998,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "3564edcba569e2d15e34f0b38542e0cdc5bae04f" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "86e4a49489b8cc0ced8c7249cfce2ae618268af3" @@ -10258,6 +10297,14 @@ "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.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" @@ -11686,10 +11733,7 @@ "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", "translation": "3d6f9cec0a3f2f016e0d1d5d97750710f924e41a" @@ -11738,10 +11782,7 @@ "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", "translation": "9b8011e3aa98c24cba7a3abd3cfd47fa59d3f447" @@ -11778,6 +11819,9 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "76a3d55ad062a9d4590170afbe43b600933f564d" }, + "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" @@ -15114,6 +15158,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" @@ -16086,6 +16150,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "452c8a125bc1f5fb0593d47a3f43ecf184ad5aa5" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "74706e43408a7303fab3739e018c8d757f29e63d" @@ -16814,6 +16886,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" @@ -16826,6 +16903,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "9f322ef2ec9a09368c7b0635c59b991157055425" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "f15a805078c511a9555a8a88c16b670a4b9c6f92" @@ -19124,6 +19202,14 @@ "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.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" @@ -20552,10 +20638,7 @@ "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", "translation": "5a24d44f681e6afe77c73cff479d5c7828569810" @@ -20604,10 +20687,7 @@ "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", "translation": "042f017c2648c2cd227ff2b0cf95a821e19cea54" @@ -20644,6 +20724,9 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "3bc5972000ab5d6e76d30edba6b124eaf7f3bc15" }, + "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" @@ -23980,6 +24063,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" @@ -24952,6 +25055,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "f3cfc645bf78ba57f3f6efc9c599ee641d79f705" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "55e731b268bb8200261b2e75b325ac6c4103ccea" @@ -25680,6 +25791,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" @@ -25692,6 +25808,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "46307ae570790f319bb1435f2c6652b116e3c8d7" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "9e64071edad38ec04234439e93121798318e2d06" @@ -27990,6 +28107,14 @@ "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.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" @@ -29418,10 +29543,7 @@ "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", "translation": "c7ecc4029c4dd7625ebca6fe44c596c26338d981" @@ -29470,10 +29592,7 @@ "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", "translation": "8d3d57a8b4f55f55dcc1d748c73d05ff110619cf" @@ -29510,6 +29629,9 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "1cae18a9a401bd5c4ec499d517e0c653ad6a5a1b" }, + "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" @@ -32846,6 +32968,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" @@ -33818,6 +33960,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "75e9dd8f7631851abc9a581082b573daf0509b2e" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "e0dd7b3c859584e950f686781a00a66187bb5656" @@ -34546,6 +34696,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" @@ -34558,6 +34713,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "4b9503320c661915dc9dac425fd7fa084d7c67e1" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "c4a27cd7c9b3e2f6e9db4992ef231d61f66cbdba" @@ -36856,6 +37012,14 @@ "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.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" @@ -38284,10 +38448,7 @@ "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", "translation": "f410df49c842cee9f0fd04207f66a9f7d084835e" @@ -38336,10 +38497,7 @@ "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", "translation": "3b2c81ef0aabc2b45ca597bfc52ff601abaa9540" @@ -38376,6 +38534,9 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "1f74613ee3eeed953e1aa998a0b081df5eb6949f" }, + "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" @@ -41712,6 +41873,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" @@ -42684,6 +42865,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "8916b6394ae9c12d585417ece28011981631a95c" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "6c226bb2ba5792cc5f751bb02582c8f84c793148" @@ -43412,6 +43601,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" @@ -43424,6 +43618,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "1c82e0b019f0faf4bb9d8ed4bf3f655d87b4eb7a" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "f67350f40f7ad2d24672f5e562f54a3bdb8d1103" @@ -45722,6 +45917,14 @@ "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.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" @@ -47150,10 +47353,7 @@ "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", "translation": "a5d6f43e85055d22939a19a1f69673400925464e" @@ -47202,10 +47402,7 @@ "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", "translation": "f9a26783183369f7753926a8c50f866faffaddfc" @@ -47242,6 +47439,9 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "dbeb4db1889042371ea9e0acdbed584b61f923dc" }, + "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" @@ -50578,6 +50778,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" @@ -51550,6 +51770,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "11ab623f76c151807f276bc04b876def4ed0fea8" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "bad56640a3a190acbd85aa0301b5fc3ed614f661" @@ -52278,6 +52506,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" @@ -52290,6 +52523,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "a84cad376851638e4537357667959951433ef52a" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "96c717aafcb524053df980be7f24731052107dd9" @@ -54588,6 +54822,14 @@ "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.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" @@ -56016,10 +56258,7 @@ "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", "translation": "58d500bcc42fe10e16454b41f6c9caf89a3ec23e" @@ -56068,10 +56307,7 @@ "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", "translation": "7ca1e438ff083d0121ab00f1abc9c81aed81e5cf" @@ -56108,6 +56344,9 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "016bc923f7ff6c70ac895a10fe7d5205b7fb35f0" }, + "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" @@ -59444,6 +59683,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" @@ -60416,6 +60675,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "43b44f1eccaa91c8b3bc855cb2e135aea9ee41b8" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "0082275ed30d02e8430271ace5fb52e365d46552" @@ -61144,6 +61411,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" @@ -61156,6 +61428,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "c8b5589a0fffc49c324be3d5a717608734455df3" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "d05244686c421dd71e5cca6c8bc76879f43ca0cf" @@ -63454,6 +63727,14 @@ "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.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" @@ -64882,10 +65163,7 @@ "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", "translation": "bcea4aa6f70e5baa25c94c14da65f9473fcf697c" @@ -64934,10 +65212,7 @@ "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", "translation": "050a8fffceabd90128bd8a5b501c6dc71c67d346" @@ -64974,6 +65249,9 @@ "source": "1798b3ba42ee08ea09d9720b2d9c0181abbfe3e4", "translation": "e99b48a29bdfdc828623c75b4e5f41367c3c076c" }, + "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" @@ -68310,6 +68588,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" @@ -69282,6 +69580,14 @@ "source": "bc7819b34ff87570745fbe461e36a16f80e562ce", "translation": "d9c32a4c3dda1c37ab78fe57a1fe60c62b1a81eb" }, + "contextMenus.task.dependencies.reltype.blocking.finishToStart": null, + "contextMenus.task.dependencies.reltype.blocking.startToStart": null, + "contextMenus.task.dependencies.reltype.blocking.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blocking.startToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToStart": null, + "contextMenus.task.dependencies.reltype.blockedBy.finishToFinish": null, + "contextMenus.task.dependencies.reltype.blockedBy.startToFinish": null, "contextMenus.task.dependencies.inputPlaceholder": { "source": "67c2a027f7b139a8b9103081eb4fb418e135cd38", "translation": "cde761a18a3d3b85182fc314d5496a2fe766580e" @@ -70010,6 +70316,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" @@ -70022,6 +70333,7 @@ "source": "17e5c29b584df3fa8161559141c209308994dccb", "translation": "bac942705dd9850c28f2a6716dcede66a2c5c806" }, + "ui.taskCard.blockingToggleBreakdown": null, "ui.taskCard.priorityAriaLabel": { "source": "28f478cb8ee60d0167b0507b87fa966e52061d81", "translation": "8783c8a8decc50ccc704bfb179c3a43374df33a8" diff --git a/src/bases/BasesViewBase.ts b/src/bases/BasesViewBase.ts index 037e91880..78cf67a10 100644 --- a/src/bases/BasesViewBase.ts +++ b/src/bases/BasesViewBase.ts @@ -2,6 +2,10 @@ import { Component, App, Notice } from "obsidian"; import type { BasesPropertyId, BasesQueryResult, BasesViewConfig, EventRef } from "obsidian"; import TaskNotesPlugin from "../main"; import { BasesDataAdapter } from "./BasesDataAdapter"; +import { + buildCalendarDataSignature, + buildCalendarDataSignaturePropertyIds, +} from "./calendarDataSignature"; import { PropertyMappingService } from "./PropertyMappingService"; import { TaskInfo } from "../types"; import { convertInternalToUserProperties } from "../utils/propertyMapping"; @@ -99,6 +103,7 @@ export abstract class BasesViewBase extends Component { protected taskUpdateListener: EventRef[] | null = null; protected updateDebounceTimer: number | null = null; protected dataUpdateDebounceTimer: number | null = null; + private lastDataUpdateSignature: string | null = null; private restoreConfigChangeHook: (() => void) | null = null; protected relevantPathsCache: Set = new Set(); @@ -187,6 +192,13 @@ export abstract class BasesViewBase extends Component { return; } + // Skip when data is unchanged (e.g. a leaf activation); re-rendering drops expanded card state. + const dataSignature = this.computeDataUpdateSignature(); + if (dataSignature !== null && dataSignature === this.lastDataUpdateSignature) { + return; + } + this.lastDataUpdateSignature = dataSignature; + this.dataUpdateDebounceTimer = scheduleBasesDataUpdateRender({ currentTimer: this.dataUpdateDebounceTimer, scheduler: this.getTimeoutScheduler(), @@ -215,6 +227,26 @@ export abstract class BasesViewBase extends Component { }); } + /** Data signature used to gate re-renders; null opts out of gating for this cycle. */ + protected computeDataUpdateSignature(): string | null { + try { + let visiblePropertyIds: readonly unknown[] = []; + try { + visiblePropertyIds = this.dataAdapter.getVisiblePropertyIds(); + } catch { + // visible-property config can be absent during early setup + } + const propertyIds = buildCalendarDataSignaturePropertyIds({ + mapField: (field) => this.plugin?.fieldMapper?.toUserField(field), + visiblePropertyIds, + showPropertyBasedEvents: false, + }); + return buildCalendarDataSignature(this.dataAdapter.extractDataItems(), propertyIds); + } catch { + return null; + } + } + /** * Update the cache of relevant paths for efficient update checking. * Called when data changes to avoid expensive lookups on every task update. diff --git a/src/bases/helpers.ts b/src/bases/helpers.ts index 1e87bbee5..87bd999b6 100644 --- a/src/bases/helpers.ts +++ b/src/bases/helpers.ts @@ -8,7 +8,10 @@ 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, + deriveBlockingState, +} from "../utils/dependencyUtils"; import { stringifyUnknown, stringifyUnknownArray } from "../utils/stringUtils"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; @@ -216,19 +219,14 @@ function createTaskInfoFromProperties( const timeEntries = toTimeEntries(props.timeEntries); const totalTrackedTime = timeEntries ? calculateTotalTimeSpent(timeEntries) : 0; - // Get dependency information from DependencyCache if plugin is available - let isBlocked = false; - let blockingTasks: string[] = []; - let isBlocking = false; - if (plugin?.dependencyCache && basesItem.path) { - // Use DependencyCache for status-aware blocking check - isBlocked = plugin.dependencyCache.isTaskBlocked(basesItem.path); - blockingTasks = plugin.dependencyCache.getBlockedTaskPaths(basesItem.path); - isBlocking = blockingTasks.length > 0; - } else { - // Fallback when plugin not available: use simple existence check - isBlocked = Array.isArray(props.blockedBy) && props.blockedBy.length > 0; - } + const { startBlocked, finishBlocked, blockingTasks, isBlockingStart, isBlockingFinish } = + deriveBlockingState( + plugin?.dependencyCache, + basesItem.path ?? "", + normalizeDependencyList(props.blockedBy) ?? [] + ); + const isBlocked = startBlocked || finishBlocked; + const isBlocking = blockingTasks.length > 0; return { title: @@ -269,6 +267,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/components/TaskContextMenu.ts b/src/components/TaskContextMenu.ts index 7760f07b3..157e0fe69 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,12 @@ import { DEFAULT_DEPENDENCY_RELTYPE, extractDependencyUid, formatDependencyLink, - normalizeDependencyEntry, } from "../utils/dependencyUtils"; +import { + addBlockedByDependency, + addBlockingDependency, + addReltypeMenuItems, +} from "./taskContextMenuDependencies"; import { generateLink } from "../utils/linkUtils"; import { ContextMenu } from "./ContextMenu"; import { buildTimeblockPrefillForTask } from "../utils/timeblockPrefillUtils"; @@ -1020,10 +1024,22 @@ 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) { + 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(); + void this.openBlockedBySelector(task, plugin); + }); + } }); const blockedByEntries = task.blockedBy ?? []; @@ -1075,10 +1091,22 @@ 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) { + 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(); + void this.openBlockingSelector(task, plugin); + }); + } }); const blockingEntries = task.blocking ?? []; @@ -1128,22 +1156,11 @@ 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); - } - } - 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 +1179,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 +1204,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 +1244,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..06b6fa9d2 --- /dev/null +++ b/src/components/taskContextMenuDependencies.ts @@ -0,0 +1,176 @@ +import { Menu, 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")); + } +} + +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/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 0926250b6..7ea9ca89f 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -513,6 +513,22 @@ 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.", + }, + 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", description: "Settings for task links and checkbox-to-task conversion in notes.", @@ -1212,8 +1228,8 @@ 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: "Auto-archive: When enabled, tasks will be automatically archived after the specified delay (1-1440 minutes)", orderNote: @@ -1233,7 +1249,7 @@ export const en: TranslationTree = { label: "Label:", color: "Color:", icon: "Icon:", - completed: "Completed:", + category: "Category:", excludeFromCycle: "Skip when cycling:", nextStatus: "Next status:", autoArchive: "Auto-archive:", @@ -1247,7 +1263,11 @@ export const en: TranslationTree = { }, badges: { completed: "Completed", + 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: { @@ -2631,6 +2651,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", @@ -2992,6 +3044,20 @@ export const en: TranslationTree = { removeBlockedBy: "Remove blocked-by…", removeBlocking: "Remove blocking…", unknownDependency: "Unknown", + reltype: { + blocking: { + finishToStart: "Finish → start · they can't start until this finishes", + startToStart: "Start → start · they can't start until this starts", + finishToFinish: "Finish → finish · they can't finish until this finishes", + startToFinish: "Start → finish · they can't finish until this starts", + }, + blockedBy: { + finishToStart: "Finish → start · this can't start until that finishes", + startToStart: "Start → start · this can't start until that starts", + finishToFinish: "Finish → finish · this can't finish until that finishes", + startToFinish: "Start → finish · this can't finish until that starts", + }, + }, inputPlaceholder: "[[Task Note]]", notices: { noEntries: "Please enter at least one task", @@ -3268,9 +3334,15 @@ 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", + blockingToggleBreakdown: "Blocking {count} tasks:\n{start} to start\n{finish} to finish", priorityAriaLabel: "Priority: {label}", taskOptions: "Task options", recurrenceTooltip: "{label}: {value}", diff --git a/src/modals/TaskEditModal.ts b/src/modals/TaskEditModal.ts index f800917b1..0f4a7140a 100644 --- a/src/modals/TaskEditModal.ts +++ b/src/modals/TaskEditModal.ts @@ -13,6 +13,7 @@ import { stringifyUnknown } from "../utils/stringUtils"; import { ConfirmationModal, showConfirmationModal } from "./ConfirmationModal"; import { createCompletionsCalendarSection } from "./taskEditCompletions"; import { BlockingUpdates } from "./taskEditChanges"; +import { findBlockingEdgeDependency } from "./taskModalDependencies"; import { createTaskModalActionButtons } from "./taskModalActionButtons"; import { showTaskModalReminderContextMenu } from "./taskModalActionMenus"; import { buildTaskEditChangesFromModalState } from "./taskEditChangeState"; @@ -37,7 +38,8 @@ export class TaskEditModal extends TaskModal { private skippedInstancesChanges: string[] = []; private initialBlockedBy: TaskDependency[] = []; private initialBlockingPaths: string[] = []; - private pendingBlockingUpdates: BlockingUpdates = { added: [], removed: [], raw: {} }; + private initialBlockingEntries: Record = {}; + private pendingBlockingUpdates: BlockingUpdates = { added: [], removed: [], modified: [], raw: {} }; private unresolvedBlockingEntries: string[] = []; private initialTags = ""; private isShowingConfirmation = false; @@ -116,13 +118,30 @@ export class TaskEditModal extends TaskModal { ); this.initialBlockedBy = this.blockedByItems.map((item) => ({ ...item.dependency })); - this.blockingItems = (this.task.blocking ?? []).map((path) => - this.createDependencyItemFromPath(path) - ); + this.blockingItems = (this.task.blocking ?? []).map((path) => { + const item = this.createDependencyItemFromPath(path); + const blockingTask = this.plugin.cacheManager.getCachedTaskInfoSync(path); + const real = blockingTask + ? findBlockingEdgeDependency(this.plugin, this.task.path, blockingTask) + : null; + if (real) { + item.dependency = { ...item.dependency, reltype: real.reltype }; + if (real.gap) { + item.dependency.gap = real.gap; + } + } + return item; + }); this.initialBlockingPaths = this.blockingItems .filter((item) => item.path) .map((item) => item.path!); - this.pendingBlockingUpdates = { added: [], removed: [], raw: {} }; + this.initialBlockingEntries = {}; + for (const item of this.blockingItems) { + if (item.path) { + this.initialBlockingEntries[item.path] = { ...item.dependency }; + } + } + this.pendingBlockingUpdates = { added: [], removed: [], modified: [], raw: {} }; this.unresolvedBlockingEntries = []; } @@ -452,7 +471,8 @@ export class TaskEditModal extends TaskModal { const changes = this.getChanges({ includeConversionWrite: true }); const hasBlockingChanges = this.pendingBlockingUpdates.added.length > 0 || - this.pendingBlockingUpdates.removed.length > 0; + this.pendingBlockingUpdates.removed.length > 0 || + this.pendingBlockingUpdates.modified.length > 0; const hasTaskChanges = Object.keys(changes).length > 0; const hasSubtaskChanges = this.hasSubtaskChanges(); @@ -490,7 +510,8 @@ export class TaskEditModal extends TaskModal { updatedTask, this.pendingBlockingUpdates.added, this.pendingBlockingUpdates.removed, - this.pendingBlockingUpdates.raw + this.pendingBlockingUpdates.raw, + this.pendingBlockingUpdates.modified ); const refreshed = await this.plugin.cacheManager.getTaskInfo(updatedTask.path); @@ -524,7 +545,7 @@ export class TaskEditModal extends TaskModal { new Notice(this.t("modals.taskEdit.notices.dependenciesUpdateSuccess")); } - this.pendingBlockingUpdates = { added: [], removed: [], raw: {} }; + this.pendingBlockingUpdates = { added: [], removed: [], modified: [], raw: {} }; this.unresolvedBlockingEntries = []; } catch (error) { tasknotesLogger.error("Failed to update task:", { @@ -558,6 +579,7 @@ export class TaskEditModal extends TaskModal { initialBlockedBy: this.initialBlockedBy, blockingItems: this.blockingItems, initialBlockingPaths: this.initialBlockingPaths, + initialBlockingEntries: this.initialBlockingEntries, details: this.details, originalDetails: this.originalDetails, completedInstancesChanges: this.completedInstancesChanges, diff --git a/src/modals/TaskModal.ts b/src/modals/TaskModal.ts index 7635f8a3c..5537c916e 100644 --- a/src/modals/TaskModal.ts +++ b/src/modals/TaskModal.ts @@ -16,7 +16,7 @@ import { } from "./taskModalDetailsEditor"; import { splitFrontmatterAndBody } from "../utils/helpers"; import { ProjectSelectModal } from "./ProjectSelectModal"; -import { TaskDependency, Reminder } from "../types"; +import { TaskDependency, TaskDependencyRelType, Reminder } from "../types"; import { DEFAULT_DEPENDENCY_RELTYPE, formatDependencyLink } from "../utils/dependencyUtils"; import { type LinkServices } from "../ui/renderers/linkRenderer"; import { generateLink } from "../utils/linkUtils"; @@ -35,10 +35,12 @@ import { createDependencyItemFromFile as createDependencyItemFromFileHelper, createDependencyItemFromPath as createDependencyItemFromPathHelper, DependencyItem, + type DependencyListSide, getBlockedByDependencyCandidates, getBlockingDependencyCandidates, removeDependencyItemAtIndex, renderDependencyList, + updateDependencyItemAtIndex, } from "./taskModalDependencies"; import { createTaskModalContextsField, @@ -182,23 +184,50 @@ export abstract class TaskModal extends Modal { } protected renderBlockedByList(): void { - void this.renderDependencyList(this.blockedByList, this.blockedByItems, (index) => { - this.blockedByItems = removeDependencyItemAtIndex(this.blockedByItems, index); - this.renderBlockedByList(); + void this.renderDependencyList(this.blockedByList, this.blockedByItems, "blocked-by", { + onRemove: (index) => { + this.blockedByItems = removeDependencyItemAtIndex(this.blockedByItems, index); + this.renderBlockedByList(); + }, + onReltypeChange: (index, reltype) => { + this.blockedByItems = updateDependencyItemAtIndex(this.blockedByItems, index, { + reltype, + }); + this.renderBlockedByList(); + }, + onGapChange: (index, gap) => { + this.blockedByItems = updateDependencyItemAtIndex(this.blockedByItems, index, { gap }); + }, }); } protected renderBlockingList(): void { - void this.renderDependencyList(this.blockingList, this.blockingItems, (index) => { - this.blockingItems = removeDependencyItemAtIndex(this.blockingItems, index); - this.renderBlockingList(); + void this.renderDependencyList(this.blockingList, this.blockingItems, "blocking", { + onRemove: (index) => { + this.blockingItems = removeDependencyItemAtIndex(this.blockingItems, index); + this.renderBlockingList(); + }, + onReltypeChange: (index, reltype) => { + this.blockingItems = updateDependencyItemAtIndex(this.blockingItems, index, { + reltype, + }); + this.renderBlockingList(); + }, + onGapChange: (index, gap) => { + this.blockingItems = updateDependencyItemAtIndex(this.blockingItems, index, { gap }); + }, }); } private async renderDependencyList( listEl: HTMLElement | undefined, items: DependencyItem[], - onRemove: (index: number) => void + side: DependencyListSide, + handlers: { + onRemove: (index: number) => void; + onReltypeChange: (index: number, reltype: TaskDependencyRelType) => void; + onGapChange: (index: number, gap: string | undefined) => void; + } ): Promise { if (!listEl) { return; @@ -210,7 +239,12 @@ export abstract class TaskModal extends Modal { items, linkServices: this.getLinkServices(), translate: (key, params) => this.t(key, params), - onRemove, + side, + selfName: this.title.trim() || this.t("modals.task.dependencies.thisTask"), + showReltypeControls: this.plugin.settings.enableAdvancedDependencyTypes, + onRemove: handlers.onRemove, + onReltypeChange: handlers.onReltypeChange, + onGapChange: handlers.onGapChange, }); } diff --git a/src/modals/taskEditChangeState.ts b/src/modals/taskEditChangeState.ts index c6e34df16..d30e579a9 100644 --- a/src/modals/taskEditChangeState.ts +++ b/src/modals/taskEditChangeState.ts @@ -39,6 +39,7 @@ export interface TaskEditModalChangeState { initialBlockedBy: TaskDependency[]; blockingItems: DependencyItemLike[]; initialBlockingPaths: string[]; + initialBlockingEntries?: Record; details: string; originalDetails: string; completedInstancesChanges: string[]; @@ -91,6 +92,7 @@ export function buildTaskEditChangesFromModalState( initialBlockedBy: input.initialBlockedBy, blockingItems: input.blockingItems, initialBlockingPaths: input.initialBlockingPaths, + initialBlockingEntries: input.initialBlockingEntries, details: input.details, originalDetails: input.originalDetails, completedInstancesChanges: input.completedInstancesChanges, diff --git a/src/modals/taskEditChanges.ts b/src/modals/taskEditChanges.ts index aa359cda8..0e41f941e 100644 --- a/src/modals/taskEditChanges.ts +++ b/src/modals/taskEditChanges.ts @@ -15,6 +15,7 @@ interface DependencyItem { export interface BlockingUpdates { added: string[]; removed: string[]; + modified: string[]; raw: Record; } @@ -37,6 +38,7 @@ export interface TaskEditChangeInput { initialBlockedBy: TaskDependency[]; blockingItems: DependencyItem[]; initialBlockingPaths: string[]; + initialBlockingEntries?: Record; details: string; originalDetails: string; completedInstancesChanges: string[]; @@ -218,6 +220,10 @@ function normalizeProjectList(projects: string[]): string[] { .filter((project) => project.length > 0); } +function isSameReltypeAndGap(a: TaskDependency, b: TaskDependency): boolean { + return a.reltype === b.reltype && (a.gap ?? "") === (b.gap ?? ""); +} + function buildBlockingUpdates(input: TaskEditChangeInput): { blockingUpdates: BlockingUpdates; unresolvedBlockingEntries: string[]; @@ -238,9 +244,21 @@ function buildBlockingUpdates(input: TaskEditChangeInput): { const newPathSet = new Set(newBlockingPaths); const added = newBlockingPaths.filter((path) => !originalPaths.has(path)); const removed = input.initialBlockingPaths.filter((path) => !newPathSet.has(path)); + const initialEntries = input.initialBlockingEntries ?? {}; + const modified = newBlockingPaths.filter((path) => { + if (!originalPaths.has(path)) { + return false; + } + const current = resolvedBlocking.get(path); + const initial = initialEntries[path]; + if (!current || !initial) { + return false; + } + return !isSameReltypeAndGap(current, initial); + }); const raw: Record = {}; - for (const path of added) { + for (const path of [...added, ...modified]) { const dependency = resolvedBlocking.get(path); if (dependency) { raw[path] = { ...dependency }; @@ -248,7 +266,7 @@ function buildBlockingUpdates(input: TaskEditChangeInput): { } return { - blockingUpdates: { added, removed, raw }, + blockingUpdates: { added, removed, modified, raw }, unresolvedBlockingEntries, }; } diff --git a/src/modals/taskModalDependencies.ts b/src/modals/taskModalDependencies.ts index e55e90733..4b945a2e1 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,118 @@ 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.max = "100000"; + gapValue.step = "1"; + 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/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/src/services/ExpandedProjectsService.ts b/src/services/ExpandedProjectsService.ts index b398b2d1c..a6aa347f8 100644 --- a/src/services/ExpandedProjectsService.ts +++ b/src/services/ExpandedProjectsService.ts @@ -74,11 +74,10 @@ export class ExpandedProjectsService { */ 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]) { + if (set.delete(oldPath)) { + set.add(newPath); + } } } diff --git a/src/services/StatusManager.ts b/src/services/StatusManager.ts index c94604f39..a1a83d6e6 100644 --- a/src/services/StatusManager.ts +++ b/src/services/StatusManager.ts @@ -1,4 +1,4 @@ -import { StatusConfig } from "../types"; +import { StatusConfig, StatusCategory } from "../types"; import { normalizeStatusConfigValue } from "../core/fieldMapping"; import { isSupportedColorValue, normalizeThemeColor } from "../utils/themeColors"; @@ -137,6 +137,29 @@ 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. A Not-started (planned) status is not started. + */ + isStarted(statusValue: string): boolean { + if (this.isCompletedStatus(statusValue)) { + return true; + } + return this.getCategory(statusValue) === "in-progress"; + } + + /** + * 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 */ @@ -262,6 +285,7 @@ export class StatusManager { label: "New status", color: "#808080", isCompleted: false, + category: "planned", excludeFromCycle: false, order, autoArchive: false, diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 533b325c2..c69fbbda9 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -75,6 +75,7 @@ import { applyGoogleCalendarRecurringExceptionForScheduledChange, } from "./task-service/googleCalendarRecurringExceptions"; import { + type BlockingRelationshipAction, buildBlockedByTaskUpdate, buildBlockingRelationshipPathChanges, computeBlockedByUpdate, @@ -1464,7 +1465,8 @@ export class TaskService { currentTask: TaskInfo, addedBlockedTaskPaths: string[], removedBlockedTaskPaths: string[], - rawEntries: Record = {} + rawEntries: Record = {}, + modifiedBlockedTaskPaths: string[] = [] ): Promise { // This method is called when the current task's "blocking" list is updated in the UI. // The current task is the one blocking other tasks. @@ -1515,12 +1517,38 @@ export class TaskService { await this.updateTask(blockedTask, updates); } + + // Rewrite the reltype/gap on edges that already exist in the blocked task's blockedBy — + // the canonical location — without touching the set membership. + for (const blockedTaskPath of modifiedBlockedTaskPaths) { + if (uniqueAdditions.includes(blockedTaskPath) || uniqueRemovals.includes(blockedTaskPath)) { + continue; + } + const blockedTask = await this.plugin.cacheManager.getTaskInfo(blockedTaskPath); + if (!blockedTask) { + continue; + } + + const updates = buildBlockedByTaskUpdate( + this.computeBlockedByUpdate( + blockedTask, + currentTask.path, + "modify", + rawEntries[blockedTaskPath] + ) + ); + if (!updates) { + continue; + } + + await this.updateTask(blockedTask, updates); + } } private computeBlockedByUpdate( blockedTask: TaskInfo, blockingTaskPath: string, - action: "add" | "remove", + action: BlockingRelationshipAction, rawEntry?: TaskDependency | string ): TaskDependency[] | null { return computeBlockedByUpdate({ diff --git a/src/services/task-service/taskBlockingRelationships.ts b/src/services/task-service/taskBlockingRelationships.ts index 9f8ba368b..13ce39746 100644 --- a/src/services/task-service/taskBlockingRelationships.ts +++ b/src/services/task-service/taskBlockingRelationships.ts @@ -4,7 +4,7 @@ import { normalizeDependencyEntry, } from "../../utils/dependencyUtils"; -export type BlockingRelationshipAction = "add" | "remove"; +export type BlockingRelationshipAction = "add" | "remove" | "modify"; export interface BlockingRelationshipPathChanges { uniqueAdditions: string[]; @@ -69,6 +69,21 @@ export function computeBlockedByUpdate({ modified = true; continue; } + if (action === "modify") { + const normalizedIncoming = rawEntry ? normalizeDependencyEntry(rawEntry) : null; + if (normalizedIncoming) { + const updated: TaskDependency = { + uid: entry.uid, + reltype: normalizedIncoming.reltype, + }; + if (normalizedIncoming.gap) { + updated.gap = normalizedIncoming.gap; + } + result.push(updated); + modified = true; + continue; + } + } } result.push(entry); } diff --git a/src/settings/components/CardComponent.ts b/src/settings/components/CardComponent.ts index bed55dbe6..301718353 100644 --- a/src/settings/components/CardComponent.ts +++ b/src/settings/components/CardComponent.ts @@ -516,7 +516,13 @@ 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" + | "default" = "default" ): HTMLElement { const badge = activeDocument.createElement("span"); badge.addClass("tasknotes-settings__card-status-badge"); diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 106920c5d..afe8cfeb6 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -1,4 +1,4 @@ -import { FieldMapping, StatusConfig, PriorityConfig } from "../types"; +import { FieldMapping, StatusCategory, StatusConfig, PriorityConfig } from "../types"; import { TaskNotesSettings, TaskCreationDefaults, @@ -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,34 @@ export const DEFAULT_STATUSES: StatusConfig[] = [ }, ]; +/** + * 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) => { + if (status.isCompleted || status.category === "completed") { + if (status.isCompleted && status.category === "completed") { + return status; + } + return { ...status, isCompleted: true, category: "completed" }; + } + if (status.category === "planned" || status.category === "in-progress") { + return status; + } + return { ...status, category: "planned" }; + }); +} + +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[] = [ { @@ -330,6 +362,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/settings/settingsPersistence.ts b/src/settings/settingsPersistence.ts index a04d3c5bf..fd7e235b6 100644 --- a/src/settings/settingsPersistence.ts +++ b/src/settings/settingsPersistence.ts @@ -1,5 +1,5 @@ import { normalizePath } from "obsidian"; -import { DEFAULT_NLP_TRIGGERS, DEFAULT_SETTINGS } from "./defaults"; +import { DEFAULT_NLP_TRIGGERS, DEFAULT_SETTINGS, normalizeStatusCategories } from "./defaults"; import { hasMissingMigratedSettings } from "./settingsMigration"; import type { TaskCreationDefaults, TaskNotesSettings } from "../types/settings"; import { initializeFieldConfig } from "../utils/fieldConfigDefaults"; @@ -231,7 +231,9 @@ export function buildSettingsFromLoadedData(data: LoadedSettingsData | null): Se loadedData?.modalFieldsConfig, loadedData?.userFields ), - customStatuses: loadedData?.customStatuses || DEFAULT_SETTINGS.customStatuses, + customStatuses: normalizeStatusCategories( + loadedData?.customStatuses || DEFAULT_SETTINGS.customStatuses + ), customPriorities: loadedData?.customPriorities || DEFAULT_SETTINGS.customPriorities, savedViews: loadedData?.savedViews || DEFAULT_SETTINGS.savedViews, }; diff --git a/src/settings/tabs/featuresTab.ts b/src/settings/tabs/featuresTab.ts index 8de3be064..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) { @@ -983,6 +984,52 @@ 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(); + 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" + ); + }); + } + } + ); + // Debug Logging Section createSettingGroup( container, diff --git a/src/settings/tabs/taskProperties/statusPropertyCard.ts b/src/settings/tabs/taskProperties/statusPropertyCard.ts index 5c0a3314c..77bf9a5cf 100644 --- a/src/settings/tabs/taskProperties/statusPropertyCard.ts +++ b/src/settings/tabs/taskProperties/statusPropertyCard.ts @@ -18,6 +18,50 @@ import { } from "../../components/CardComponent"; 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. +const categoryI18nKey = (category: StatusCategory): string => + category === "in-progress" ? "inProgress" : category; + +function createCategoryBadge( + category: StatusCategory | undefined, + translate: TranslateFn +): HTMLElement { + const variant = category ?? "planned"; + return createStatusBadge( + translate(`settings.taskProperties.taskStatuses.badges.${categoryI18nKey(variant)}`), + variant + ); +} + +// 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 @@ -102,7 +146,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"), @@ -153,8 +197,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, @@ -228,6 +272,8 @@ function renderStatusList( ): void { container.empty(); + renderStatusCategoryAdvisory(container, plugin, translate); + if (!plugin.settings.customStatuses || plugin.settings.customStatuses.length === 0) { showCardEmptyState(container, translate("settings.taskProperties.taskStatuses.emptyState")); return; @@ -254,19 +300,24 @@ function renderStatusList( status.icon || "" ); - const completedToggle = createCardToggle(status.isCompleted || false, (value) => { - status.isCompleted = value; + const categorySelect = createCardSelect( + STATUS_CATEGORIES.map((category) => ({ + value: category, + label: translate( + `settings.taskProperties.taskStatuses.badges.${categoryI18nKey(category)}` + ), + })), + status.category ?? "planned" + ); + + categorySelect.addEventListener("change", () => { + const value = categorySelect.value as StatusCategory; + status.category = value; + status.isCompleted = value === "completed"; const metaContainer = statusCard?.querySelector(".tasknotes-settings__card-meta"); if (metaContainer) { metaContainer.empty(); - if (status.isCompleted) { - metaContainer.appendChild( - createStatusBadge( - translate("settings.taskProperties.taskStatuses.badges.completed"), - "completed" - ) - ); - } + metaContainer.appendChild(createCategoryBadge(status.category, translate)); } save(); }); @@ -310,14 +361,7 @@ function renderStatusList( status.autoArchiveDelay || 5 ); - const metaElements = status.isCompleted - ? [ - createStatusBadge( - translate("settings.taskProperties.taskStatuses.badges.completed"), - "completed" - ), - ] - : []; + const metaElements = [createCategoryBadge(status.category, translate)]; let statusCard: HTMLElement; @@ -408,9 +452,9 @@ function renderStatusList( }, { label: translate( - "settings.taskProperties.taskStatuses.fields.completed" + "settings.taskProperties.taskStatuses.fields.category" ), - input: completedToggle, + input: categorySelect, }, { label: translate( diff --git a/src/types.ts b/src/types.ts index c968e6bea..8199913a4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -490,8 +490,12 @@ export interface TaskInfo { basesData?: unknown; // Raw Bases data for formula computation (internal use) blockedBy?: TaskDependency[]; // Dependencies that must be satisfied before this task can start blocking?: string[]; // Task paths that this task is blocking - isBlocked?: boolean; // True if any blocking dependency is incomplete + isBlocked?: boolean; 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 @@ -728,13 +732,23 @@ export interface FieldMapping { sortOrder: string; // Numeric ordering within column (lower = higher) } +/** + * 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"; + +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; 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/src/types/settings.ts b/src/types/settings.ts index 960391ce5..cde892cfb 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -177,6 +177,8 @@ export interface TaskNotesSettings { // Relationships widget settings (unified subtasks, projects, and dependencies) showRelationships: boolean; relationshipsPosition: "top" | "bottom"; + // Gates the RFC 9253 reltype/gap authoring controls in the task dependency lists. + enableAdvancedDependencyTypes: boolean; // Task card in note settings showTaskCardInNote: boolean; showCompletedTaskStrikethrough: boolean; diff --git a/src/ui/taskCardMetadata.ts b/src/ui/taskCardMetadata.ts index 16eb9a292..fc35c22bc 100644 --- a/src/ui/taskCardMetadata.ts +++ b/src/ui/taskCardMetadata.ts @@ -10,7 +10,7 @@ import { updateMetadataVisibility, type TaskCardPropertyOptions, } from "./taskCardProperties"; -import { getBlockedByTaskPaths } from "./taskCardRelationships"; +import { getBlockedByTaskPaths, resolveBlockedConstraint } from "./taskCardRelationships"; import { prepareInteractiveControl } from "./taskCardIndicators"; import { toggleTaskCardBlockedByExpansion, @@ -55,32 +55,36 @@ function createBlockedMetadataPill(config: RenderTaskCardMetadataConfig): HTMLEl return null; } - const blockedLabel = plugin.i18n.translate("ui.taskCard.blockedBadge"); - const blockedByPaths = getBlockedByTaskPaths(task, plugin.app); - const blockedCount = task.blockedBy?.length ?? 0; - const pillText = blockedCount > 0 ? `${blockedLabel} (${blockedCount})` : blockedLabel; + const blocked = resolveBlockedConstraint(task, plugin.app, plugin.dependencyCache); + const isFinish = blocked.state === "finish"; + const blockedLabel = plugin.i18n.translate( + isFinish ? "ui.taskCard.blockedFinish" : "ui.taskCard.blockedStart" + ); + const blockedTooltip = plugin.i18n.translate( + isFinish ? "ui.taskCard.blockedFinishTooltip" : "ui.taskCard.blockedStartTooltip" + ); + const pillText = `${blockedLabel} (${blocked.count})`; const blockedPill = metadataLine.createSpan({ - cls: "task-card__metadata-pill task-card__metadata-pill--blocked", + cls: `task-card__metadata-pill task-card__metadata-pill--blocked ${ + isFinish ? "is-finish-blocked" : "is-start-blocked" + }`, text: pillText, }); - if (blockedByPaths.length > 0) { - const toggleLabel = `${blockedLabel} (${blockedByPaths.length})`; + if (getBlockedByTaskPaths(task, plugin.app).length > 0) { prepareInteractiveControl(blockedPill); - blockedPill.setAttribute("aria-label", toggleLabel); + blockedPill.setAttribute("aria-label", pillText); blockedPill.setAttribute( "aria-expanded", String(Boolean(card.querySelector(".task-card__blocked-by"))) ); - setTooltip(blockedPill, toggleLabel, { placement: "top" }); + setTooltip(blockedPill, blockedTooltip, { placement: "top" }); blockedPill.addEventListener("click", (event) => { event.stopPropagation(); onBlockedByToggle(); }); } else { - setTooltip(blockedPill, plugin.i18n.translate("ui.taskCard.blockedBadgeTooltip"), { - placement: "top", - }); + setTooltip(blockedPill, blockedTooltip, { placement: "top" }); } return blockedPill; diff --git a/src/ui/taskCardProperties.ts b/src/ui/taskCardProperties.ts index 9a1c5bedc..fc1e4916a 100644 --- a/src/ui/taskCardProperties.ts +++ b/src/ui/taskCardProperties.ts @@ -34,6 +34,7 @@ import { } from "./renderers/linkRenderer"; import { renderContextsValue, renderTagsValue, type TagServices } from "./renderers/tagRenderer"; import { normalizeDependencyEntry, resolveDependencyEntry } from "../utils/dependencyUtils"; +import { resolveBlockedConstraint } from "./taskCardRelationships"; import { createTaskNotesLogger } from "../utils/tasknotesLogger"; const tasknotesLogger = createTaskNotesLogger({ tag: "Ui/TaskCardProperties" }); @@ -428,13 +429,18 @@ const PROPERTY_RENDERERS: Record = { })}`; } }, - blocked: (element, value, task, plugin, options) => { - if (value === true) { - const blockedCount = task.blockedBy?.length ?? 0; - const label = getTaskCardPropertyLabel("blocked", plugin, options?.propertyLabels); - element.textContent = blockedCount > 0 ? `${label} (${blockedCount})` : label; - element.classList.add("task-card__metadata-pill--blocked"); + blocked: (element, value, task, plugin) => { + if (value !== true) { + return; } + const blocked = resolveBlockedConstraint(task, plugin.app, plugin.dependencyCache); + const isFinish = blocked.state === "finish"; + const label = plugin.i18n.translate( + isFinish ? "ui.taskCard.blockedFinish" : "ui.taskCard.blockedStart" + ); + element.textContent = `${label} (${blocked.count})`; + element.classList.add("task-card__metadata-pill--blocked"); + element.classList.add(isFinish ? "is-finish-blocked" : "is-start-blocked"); }, blocking: (element, value, task, plugin, options) => { if (value === true) { diff --git a/src/ui/taskCardPropertyAccess.ts b/src/ui/taskCardPropertyAccess.ts index c9e8134dd..db4bedfea 100644 --- a/src/ui/taskCardPropertyAccess.ts +++ b/src/ui/taskCardPropertyAccess.ts @@ -49,6 +49,8 @@ const PROPERTY_EXTRACTORS: Record unknown> = { contexts: (task) => task.contexts, tags: (task) => task.tags, blocked: (task) => task.isBlocked, + blockedStart: (task) => task.startBlocked, + blockedFinish: (task) => task.finishBlocked, blocking: (task) => task.isBlocking, blockedBy: (task) => task.blockedBy, blockingTasks: (task) => task.blocking, diff --git a/src/ui/taskCardRelationships.ts b/src/ui/taskCardRelationships.ts index 26b0fc9c7..cd7234d1a 100644 --- a/src/ui/taskCardRelationships.ts +++ b/src/ui/taskCardRelationships.ts @@ -106,3 +106,63 @@ 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[]; +} + +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 }; + } + // 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/ui/taskCardSecondaryBadges.ts b/src/ui/taskCardSecondaryBadges.ts index b84035263..e5e132ddd 100644 --- a/src/ui/taskCardSecondaryBadges.ts +++ b/src/ui/taskCardSecondaryBadges.ts @@ -13,7 +13,10 @@ import { updateBadgeIndicator, } from "./taskCardIndicators"; import { removeRelationshipContainer } from "./taskCardRelationshipExpansion"; -import { getBlockedByTaskPaths } from "./taskCardRelationships"; +import { + type BlockedConstraintState, + resolveBlockedConstraint, +} from "./taskCardRelationships"; import { type TaskCardPresentationOptions } from "./taskCardPresentation"; export interface TaskCardSecondaryBadgeOptions { @@ -83,6 +86,21 @@ function shouldExpandSubtasksByDefault(plugin: TaskNotesPlugin): boolean { return plugin.settings?.expandSubtasksByDefault === true; } +// Break the reverse (blocking) count down by endpoint when this task gates any successor's +// finish; an all-start default (finish = 0, the finish-to-start case) keeps the plain summary. +function blockingToggleTooltip(task: TaskInfo, plugin: TaskNotesPlugin, count: number): string { + const finishCount = plugin.dependencyCache?.getFinishBlockedDependentPaths(task.path).length ?? 0; + if (finishCount === 0) { + return tTaskCard(plugin, "blockingToggle", { count }); + } + const startCount = plugin.dependencyCache?.getStartBlockedDependentPaths(task.path).length ?? 0; + return tTaskCard(plugin, "blockingToggleBreakdown", { + count, + start: startCount, + finish: finishCount, + }); +} + export function isTaskCardSubtasksExpanded(task: TaskInfo, plugin: TaskNotesPlugin): boolean { return ( plugin.expandedProjectsService?.isExpanded( @@ -250,6 +268,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 @@ -258,9 +315,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", @@ -273,18 +328,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; } } } @@ -408,9 +463,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, @@ -447,19 +500,17 @@ function updateBlockingToggle(options: UpdateTaskCardSecondaryBadgesOptions): vo function updateBlockedByToggle(options: UpdateTaskCardSecondaryBadgesOptions): void { const { card, task, plugin, handlers } = options; - const blockedByPaths = getBlockedByTaskPaths(task, plugin.app); - const shouldExist = blockedByPaths.length > 0; - const toggleLabel = `${tTaskCard(plugin, "blockedBadge")} (${blockedByPaths.length})`; + const blocked = describeBlockedToggle(task, plugin); const toggle = updateBadgeIndicator(card, ".task-card__blocked-toggle", { - shouldExist, - className: "task-card__blocked-toggle is-visible", + shouldExist: blocked !== null, + className: blocked?.className ?? "task-card__blocked-toggle is-visible", icon: "git-merge", - tooltip: toggleLabel, + tooltip: blocked?.tooltip ?? "", onClick: createBlockedByToggleClickHandler(task, plugin, card, handlers), }); - if (!shouldExist) { + if (!blocked) { removeRelationshipContainer(card, ".task-card__blocked-by"); return; } @@ -470,14 +521,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 c2958561d..8ef29dde1 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,78 @@ 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); + } + // 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); + } + + // 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); + } + + // 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; + } { + const start = new Set(); + const finish = new Set(); + const edges = this.edgeReltypes.get(taskPath); + if (edges) { + for (const [predecessorPath, reltype] of edges) { + if (this.isEdgeReleased(predecessorPath, reltype)) { + continue; + } + if (reltypeConstrainsStart(reltype)) { + start.add(predecessorPath); + } else { + finish.add(predecessorPath); + } } } - } - - 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 { 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. + 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 +424,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 +458,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 +514,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 +553,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 +570,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 +606,7 @@ export class DependencyCache extends Events { } this.relationshipFingerprints.delete(path); this.completedStatusByPath.delete(path); + this.startedStatusByPath.delete(path); } /** @@ -634,8 +645,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 +657,59 @@ 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; + } + + // 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) { + 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 +788,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/TaskManager.ts b/src/utils/TaskManager.ts index 01c66ddcb..8718186a7 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 { deriveBlockingState } from "./dependencyUtils"; import { isTaskFrontmatter } from "./taskIdentification"; import { createTaskNotesLogger } from "./tasknotesLogger"; @@ -331,24 +332,23 @@ export class TaskManager extends Events { this.storeTitleInFilename ); - // Get dependency information from DependencyCache - let isBlocked = false; - let blockingTasks: string[] = []; - if (this._dependencyCache) { - // Use DependencyCache for status-aware blocking check - isBlocked = this._dependencyCache.isTaskBlocked(path); - blockingTasks = this._dependencyCache.getBlockedTaskPaths(path); - } else { - // Fallback when dependency cache not available: use simple existence check - isBlocked = Array.isArray(mappedTask.blockedBy) && mappedTask.blockedBy.length > 0; - } + const { startBlocked, finishBlocked, blockingTasks, isBlockingStart, isBlockingFinish } = + deriveBlockingState( + this._dependencyCache, + path, + Array.isArray(mappedTask.blockedBy) ? mappedTask.blockedBy : [] + ); 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/dependencyUtils.ts b/src/utils/dependencyUtils.ts index 84bb7d438..f53d20a82 100644 --- a/src/utils/dependencyUtils.ts +++ b/src/utils/dependencyUtils.ts @@ -16,6 +16,101 @@ 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 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 { + 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 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 { + const whole = Math.floor(value); + if (!Number.isFinite(value) || whole <= 0 || whole > 100000) { + return undefined; + } + 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 +// 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/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/styles/settings-view.css b/styles/settings-view.css index 983ac055e..a52a1fe98 100644 --- a/styles/settings-view.css +++ b/styles/settings-view.css @@ -482,6 +482,21 @@ 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-info-badge { padding: 3px 6px; border-radius: 10px; @@ -1398,3 +1413,23 @@ body.is-mobile .tasknotes-plugin .tasknotes-settings__card-action-btn { transition: none; } } + +.setting-item.tn-dependency-readiness--ok { + border-inline-start: 3px solid var(--color-green); + padding-inline-start: var(--size-4-2); +} + +.setting-item.tn-dependency-readiness--warn { + border-inline-start: 3px solid var(--color-orange); + padding-inline-start: var(--size-4-2); +} + +.tn-status-category-advisory { + margin-bottom: var(--size-4-2); + padding: var(--size-4-2) var(--size-4-3); + border-inline-start: 3px solid var(--color-orange); + background: var(--background-modifier-hover); + border-radius: var(--radius-s); + color: var(--text-muted); + font-size: var(--font-ui-small); +} diff --git a/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/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/helpers/mock-factories.ts b/tests/helpers/mock-factories.ts index dbfa18d9b..24888bf72 100644 --- a/tests/helpers/mock-factories.ts +++ b/tests/helpers/mock-factories.ts @@ -526,6 +526,7 @@ export const PluginFactory = { }, statusManager: { isCompletedStatus: jest.fn((status) => status === 'done' || status === 'completed'), + isStarted: jest.fn((status) => status === 'done' || status === 'completed' || status === 'in-progress'), getCompletedStatuses: jest.fn(() => ['done', 'completed']), getNextStatus: jest.fn((currentStatus) => { const statusMap: Record = { diff --git a/tests/performance/dependencyCache.performance.test.ts b/tests/performance/dependencyCache.performance.test.ts index 1ef03f6ab..4033ba1b1 100644 --- a/tests/performance/dependencyCache.performance.test.ts +++ b/tests/performance/dependencyCache.performance.test.ts @@ -70,7 +70,10 @@ function createDependencyCache(app: MockApp): DependencyCache { taskTag: "task", }, new FieldMapper(DEFAULT_FIELD_MAPPING), - { isCompletedStatus: jest.fn((status: string) => status === "done") } as never, + { + isCompletedStatus: jest.fn((status: string) => status === "done"), + isStarted: jest.fn((status: string) => status === "done" || status === "in-progress"), + } as never, (frontmatter) => Array.isArray((frontmatter as { tags?: unknown }).tags) ); } diff --git a/tests/unit/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); + }); +}); diff --git a/tests/unit/components/taskContextMenuDependencies.test.ts b/tests/unit/components/taskContextMenuDependencies.test.ts new file mode 100644 index 000000000..d30ef084d --- /dev/null +++ b/tests/unit/components/taskContextMenuDependencies.test.ts @@ -0,0 +1,194 @@ +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() { + 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; +} + +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", () => { + 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(); + }); + }); + + 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" + ); + }); + }); +}); 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: { 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/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/modals/taskEditChangeState.test.ts b/tests/unit/modals/taskEditChangeState.test.ts index c698c34d2..235372416 100644 --- a/tests/unit/modals/taskEditChangeState.test.ts +++ b/tests/unit/modals/taskEditChangeState.test.ts @@ -168,10 +168,80 @@ 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([]); }); + 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/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/statusManager.lifecycle.test.ts b/tests/unit/services/statusManager.lifecycle.test.ts new file mode 100644 index 000000000..59d5d4f80 --- /dev/null +++ b/tests/unit/services/statusManager.lifecycle.test.ts @@ -0,0 +1,121 @@ +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", () => { + 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("defaults a non-completed status with no category to planned (Not started), isCompleted unchanged", () => { + const [open] = normalizeStatusCategories([createStatus("open", { isCompleted: false })]); + expect(open.category).toBe("planned"); + expect(open.isCompleted).toBe(false); + }); + + it("leaves a status that already carries a valid 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("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); + expect(twice).toEqual(once); + }); + }); + + describe("isStarted / isFinished predicates", () => { + const buildManager = () => + new StatusManager([ + createStatus("planned", { category: "planned", 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 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("nocat")).toBe(false); + }); + + it("isFinished is true only for completed and equals isCompleted for every status", () => { + const manager = buildManager(); + 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", () => { + const manager = buildManager(); + expect(manager.getCategory("doing")).toBe("in-progress"); + expect(manager.getCategory("planned")).toBe("planned"); + }); + + 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); + }); + }); + + 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/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/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 }); + }); +}); diff --git a/tests/unit/settings/settingsPersistence.test.ts b/tests/unit/settings/settingsPersistence.test.ts index 0f3aab2f0..38d8ed65b 100644 --- a/tests/unit/settings/settingsPersistence.test.ts +++ b/tests/unit/settings/settingsPersistence.test.ts @@ -8,6 +8,7 @@ import { pluginDataFileExists, } from "../../../src/settings/settingsPersistence"; import type { TaskNotesSettings } from "../../../src/types/settings"; +import type { StatusConfig } from "../../../src/types"; function createHost(options: { dir?: string; @@ -192,3 +193,33 @@ describe("settings persistence helpers", () => { ); }); }); + +describe("status category migration on load", () => { + const legacyStatus = (overrides: Partial): StatusConfig => ({ + id: "s", + value: "s", + label: "S", + color: "#808080", + isCompleted: false, + order: 0, + autoArchive: false, + autoArchiveDelay: 5, + ...overrides, + }); + + it("backfills category on a legacy completed status through the load path", () => { + const { settings } = buildSettingsFromLoadedData({ + customStatuses: [legacyStatus({ value: "done", isCompleted: true })], + }); + expect(settings.customStatuses[0].category).toBe("completed"); + expect(settings.customStatuses[0].isCompleted).toBe(true); + }); + + it("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).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 new file mode 100644 index 000000000..8d3bba770 --- /dev/null +++ b/tests/unit/settings/statusPropertyCard.lifecycle.test.ts @@ -0,0 +1,128 @@ +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 categorySelect 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", () => { + 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 Not started or Started 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("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", () => { + const { container } = renderCard(createStatus()); + const select = categorySelect(container); + + choose(select, "in-progress"); + expect(badge(container)?.textContent).toBe("Started"); + expect( + badge(container)?.classList.contains( + "tasknotes-settings__card-status-badge--in-progress" + ) + ).toBe(true); + + choose(select, "planned"); + expect(badge(container)?.textContent).toBe("Not started"); + expect( + badge(container)?.classList.contains( + "tasknotes-settings__card-status-badge--planned" + ) + ).toBe(true); + }); + + 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--planned" + ) + ).toBe(true); + }); +}); 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..e903b81ba --- /dev/null +++ b/tests/unit/ui/taskCardSecondaryBadges.constraints.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "@jest/globals"; +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; + +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("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")], + 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, + }); + }); +}); diff --git a/tests/unit/utils/DependencyCache.constraints.test.ts b/tests/unit/utils/DependencyCache.constraints.test.ts new file mode 100644 index 000000000..8c9a5a2d8 --- /dev/null +++ b/tests/unit/utils/DependencyCache.constraints.test.ts @@ -0,0 +1,191 @@ +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[], build = true): 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) + ); + if (build) { + 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("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" }, + { 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); + }); + + 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); + }); +}); 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) ); } diff --git a/tests/unit/utils/dependencyGap.test.ts b/tests/unit/utils/dependencyGap.test.ts new file mode 100644 index 000000000..c75a201be --- /dev/null +++ b/tests/unit/utils/dependencyGap.test.ts @@ -0,0 +1,41 @@ +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(); + expect(composeDependencyGap(Number.POSITIVE_INFINITY, "days")).toBeUndefined(); + }); + + 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"); + }); + + 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(); + }); +}); 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); + }); +});