|
|
@@ -13,6 +13,7 @@ namespace ClankerSaddle.Services {
|
|
|
public const string STATUS_WORKING = "working";
|
|
|
public const string STATUS_BLOCKED = "blocked";
|
|
|
public const string STATUS_REVIEW_REQUESTED = "review_requested";
|
|
|
+ public const string STATUS_SUSPENDED = "suspended";
|
|
|
public const string STATUS_DONE = "done";
|
|
|
public const string STATUS_FAILED = "failed";
|
|
|
|
|
|
@@ -21,6 +22,44 @@ namespace ClankerSaddle.Services {
|
|
|
public const string BLOCK_RFC = "rfc";
|
|
|
public const string BLOCK_REVIEW = "review";
|
|
|
|
|
|
+ /**
|
|
|
+ * A task's computed presentation state (stored status + open
|
|
|
+ * subtasks + unanswered blocks): `review requested` while a review
|
|
|
+ * block is unanswered (even when the stored status is terminal —
|
|
|
+ * the post-mortem's `done`-with-open-review case), `delegating x/y`
|
|
|
+ * while non-terminal subtasks exist, `waiting on subtasks` for the
|
|
|
+ * parked suspended state, otherwise the stored status. Pure
|
|
|
+ * presentation — the stored statuses and the dispatcher are
|
|
|
+ * untouched.
|
|
|
+ */
|
|
|
+ public class TaskDisplay : Object {
|
|
|
+ public string status { get; set; default = ""; }
|
|
|
+ public bool terminal { get; set; }
|
|
|
+ public bool failed { get; set; }
|
|
|
+ public int64 subtask_total { get; set; }
|
|
|
+ public int64 subtask_open { get; set; }
|
|
|
+
|
|
|
+ public static TaskDisplay compute(TaskEntity task, bool review_pending,
|
|
|
+ int64 subtask_total, int64 subtask_open) {
|
|
|
+ var view = new TaskDisplay() {
|
|
|
+ terminal = task.status == STATUS_DONE || task.status == STATUS_FAILED,
|
|
|
+ failed = task.status == STATUS_FAILED,
|
|
|
+ subtask_total = subtask_total,
|
|
|
+ subtask_open = subtask_open
|
|
|
+ };
|
|
|
+ if (review_pending) {
|
|
|
+ view.status = "review requested";
|
|
|
+ } else if (subtask_open > 0) {
|
|
|
+ view.status = @"delegating $(subtask_total - subtask_open)/$(subtask_total)";
|
|
|
+ } else if (task.status == STATUS_SUSPENDED) {
|
|
|
+ view.status = "waiting on subtasks";
|
|
|
+ } else {
|
|
|
+ view.status = task.status;
|
|
|
+ }
|
|
|
+ return view;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* The full task aggregate loaded by {@link TaskService.get_task}: the
|
|
|
* task itself plus its comments, blocks, progress history, child
|
|
|
@@ -128,6 +167,79 @@ namespace ClankerSaddle.Services {
|
|
|
* Lists tasks, optionally filtered by status and/or project, ordered
|
|
|
* by priority (1 first) then creation time.
|
|
|
*/
|
|
|
+ /**
|
|
|
+ * Computed display states for a batch of tasks (all lists share
|
|
|
+ * this), keyed by task id string. Unanswered review blocks and
|
|
|
+ * child-task states each load once, not per task.
|
|
|
+ */
|
|
|
+ public async Dictionary<string, TaskDisplay> display_map_for(
|
|
|
+ Lot<TaskEntity> tasks) throws Error {
|
|
|
+ var wanted = new Invercargill.DataStructures.HashSet<string>();
|
|
|
+ foreach (var task in tasks) {
|
|
|
+ wanted.add(task.id.to_string());
|
|
|
+ }
|
|
|
+
|
|
|
+ var review_pending = new Invercargill.DataStructures.HashSet<string>();
|
|
|
+ foreach (var block in yield db.query<TaskBlock>()
|
|
|
+ .where(expr("type == $0", elem(BLOCK_REVIEW)))
|
|
|
+ .materialise_async()) {
|
|
|
+ if (block.answered_at == null && wanted.has(block.task_id.to_string())) {
|
|
|
+ review_pending.add(block.task_id.to_string());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Subtask counters per parent (stringly keyed: the Invercargill
|
|
|
+ // collections cannot key on value types).
|
|
|
+ var open_by_parent = new Dictionary<string, string>();
|
|
|
+ var total_by_parent = new Dictionary<string, string>();
|
|
|
+ foreach (var child in yield db.query<TaskEntity>()
|
|
|
+ .where(expr("parent_task_id > 0"))
|
|
|
+ .materialise_async()) {
|
|
|
+ string parent = child.parent_task_id.to_string();
|
|
|
+ if (!wanted.has(parent)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ total_by_parent.set(parent,
|
|
|
+ (int64.parse(total_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
|
|
|
+ if (child.status != STATUS_DONE && child.status != STATUS_FAILED) {
|
|
|
+ open_by_parent.set(parent,
|
|
|
+ (int64.parse(open_by_parent.get_or_default(parent) ?? "0") + 1).to_string());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var map = new Dictionary<string, TaskDisplay>();
|
|
|
+ foreach (var task in tasks) {
|
|
|
+ string key = task.id.to_string();
|
|
|
+ map.set(key, TaskDisplay.compute(task,
|
|
|
+ review_pending.has(key),
|
|
|
+ int64.parse(total_by_parent.get_or_default(key) ?? "0"),
|
|
|
+ int64.parse(open_by_parent.get_or_default(key) ?? "0")));
|
|
|
+ }
|
|
|
+ return map;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The display state of a single task — the detail page's path,
|
|
|
+ * computed from its already-loaded aggregate (no extra queries).
|
|
|
+ */
|
|
|
+ public static TaskDisplay display_of_detail(TaskDetail detail) {
|
|
|
+ bool review_pending = false;
|
|
|
+ int64 subtask_total = 0;
|
|
|
+ int64 subtask_open = 0;
|
|
|
+ foreach (var block in detail.blocks) {
|
|
|
+ if (block.block_type == BLOCK_REVIEW && block.answered_at == null) {
|
|
|
+ review_pending = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ foreach (var child in detail.subtasks) {
|
|
|
+ subtask_total++;
|
|
|
+ if (child.status != STATUS_DONE && child.status != STATUS_FAILED) {
|
|
|
+ subtask_open++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return TaskDisplay.compute(detail.task, review_pending, subtask_total, subtask_open);
|
|
|
+ }
|
|
|
+
|
|
|
public async ImmutableLot<TaskEntity> list_tasks(string? status, int64? project_id) throws Error {
|
|
|
var tasks = yield db.query<TaskEntity>().materialise_async();
|
|
|
var rows = new Vector<TaskEntity>();
|