fix(tasks): Improve next task logic to be subtask-aware

This commit is contained in:
Eyal Toledano
2025-04-28 00:27:19 -04:00
parent ca7b0457f1
commit 66ac9ab9f6
4 changed files with 170 additions and 9505 deletions

View File

@@ -1,55 +1,120 @@
/**
* Find the next pending task based on dependencies
* @param {Object[]} tasks - The array of tasks
* @returns {Object|null} The next task to work on or null if no eligible tasks
* Return the next work item:
* • Prefer an eligible SUBTASK that belongs to any parent task
* whose own status is `in-progress`.
* • If no such subtask exists, fall back to the best top-level task
* (previous behaviour).
*
* The function still exports the same name (`findNextTask`) so callers
* don't need to change. It now always returns an object with
* ─ id → number (task) or "parentId.subId" (subtask)
* ─ title → string
* ─ status → string
* ─ priority → string ("high" | "medium" | "low")
* ─ dependencies → array (all IDs expressed in the same dotted form)
* ─ parentId → number (present only when it's a subtask)
*
* @param {Object[]} tasks full array of top-level tasks, each may contain .subtasks[]
* @returns {Object|null} next work item or null if nothing is eligible
*/
function findNextTask(tasks) {
// Get all completed task IDs
const completedTaskIds = new Set(
tasks
.filter((t) => t.status === 'done' || t.status === 'completed')
.map((t) => t.id)
);
// Filter for pending tasks whose dependencies are all satisfied
const eligibleTasks = tasks.filter(
(task) =>
(task.status === 'pending' || task.status === 'in-progress') &&
task.dependencies && // Make sure dependencies array exists
task.dependencies.every((depId) => completedTaskIds.has(depId))
);
if (eligibleTasks.length === 0) {
return null;
}
// Sort eligible tasks by:
// 1. Priority (high > medium > low)
// 2. Dependencies count (fewer dependencies first)
// 3. ID (lower ID first)
// ---------- helpers ----------------------------------------------------
const priorityValues = { high: 3, medium: 2, low: 1 };
const toFullSubId = (parentId, maybeDotId) => {
// "12.3" -> "12.3"
// 4 -> "12.4" (numeric / short form)
if (typeof maybeDotId === 'string' && maybeDotId.includes('.')) {
return maybeDotId;
}
return `${parentId}.${maybeDotId}`;
};
// ---------- build completed-ID set (tasks *and* subtasks) --------------
const completedIds = new Set();
tasks.forEach((t) => {
if (t.status === 'done' || t.status === 'completed') {
completedIds.add(String(t.id));
}
if (Array.isArray(t.subtasks)) {
t.subtasks.forEach((st) => {
if (st.status === 'done' || st.status === 'completed') {
completedIds.add(`${t.id}.${st.id}`);
}
});
}
});
// ---------- 1) look for eligible subtasks ------------------------------
const candidateSubtasks = [];
tasks
.filter((t) => t.status === 'in-progress' && Array.isArray(t.subtasks))
.forEach((parent) => {
parent.subtasks.forEach((st) => {
const stStatus = (st.status || 'pending').toLowerCase();
if (stStatus !== 'pending' && stStatus !== 'in-progress') return;
const fullDeps =
st.dependencies?.map((d) => toFullSubId(parent.id, d)) ?? [];
const depsSatisfied =
fullDeps.length === 0 ||
fullDeps.every((depId) => completedIds.has(String(depId)));
if (depsSatisfied) {
candidateSubtasks.push({
id: `${parent.id}.${st.id}`,
title: st.title || `Subtask ${st.id}`,
status: st.status || 'pending',
priority: st.priority || parent.priority || 'medium',
dependencies: fullDeps,
parentId: parent.id
});
}
});
});
if (candidateSubtasks.length > 0) {
// sort by priority → dep-count → parent-id → sub-id
candidateSubtasks.sort((a, b) => {
const pa = priorityValues[a.priority] ?? 2;
const pb = priorityValues[b.priority] ?? 2;
if (pb !== pa) return pb - pa;
if (a.dependencies.length !== b.dependencies.length)
return a.dependencies.length - b.dependencies.length;
// compare parent then sub-id numerically
const [aPar, aSub] = a.id.split('.').map(Number);
const [bPar, bSub] = b.id.split('.').map(Number);
if (aPar !== bPar) return aPar - bPar;
return aSub - bSub;
});
return candidateSubtasks[0];
}
// ---------- 2) fall back to top-level tasks (original logic) ------------
const eligibleTasks = tasks.filter((task) => {
const status = (task.status || 'pending').toLowerCase();
if (status !== 'pending' && status !== 'in-progress') return false;
const deps = task.dependencies ?? [];
return deps.every((depId) => completedIds.has(String(depId)));
});
if (eligibleTasks.length === 0) return null;
const nextTask = eligibleTasks.sort((a, b) => {
// Sort by priority first
const priorityA = priorityValues[a.priority || 'medium'] || 2;
const priorityB = priorityValues[b.priority || 'medium'] || 2;
const pa = priorityValues[a.priority || 'medium'] ?? 2;
const pb = priorityValues[b.priority || 'medium'] ?? 2;
if (pb !== pa) return pb - pa;
if (priorityB !== priorityA) {
return priorityB - priorityA; // Higher priority first
}
const da = (a.dependencies ?? []).length;
const db = (b.dependencies ?? []).length;
if (da !== db) return da - db;
// If priority is the same, sort by dependency count
if (
a.dependencies &&
b.dependencies &&
a.dependencies.length !== b.dependencies.length
) {
return a.dependencies.length - b.dependencies.length; // Fewer dependencies first
}
// If dependency count is the same, sort by ID
return a.id - b.id; // Lower ID first
})[0]; // Return the first (highest priority) task
return a.id - b.id;
})[0];
return nextTask;
}