From dd11f19a1c8bffb009fbf90b118204e277c6ccb2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:14:28 +0300 Subject: [PATCH] feat(kanban): add runtime state, event formatter and grouping helpers (#1137) --- .../ui/kanban/KanbanColumnCollapseHelper.kt | 14 ++ .../ui/kanban/KanbanEventFormatter.kt | 168 ++++++++++++++++++ .../ui/kanban/KanbanLaneGrouping.kt | 26 +++ .../ui/kanban/KanbanModelOverride.kt | 79 ++++++++ .../ui/kanban/KanbanRuntimeState.kt | 50 ++++++ .../ui/kanban/KanbanStatusConstraints.kt | 15 ++ 6 files changed, 352 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelper.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGrouping.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanStatusConstraints.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelper.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelper.kt new file mode 100644 index 00000000..d81af538 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelper.kt @@ -0,0 +1,14 @@ +package com.m57.hermescontrol.ui.kanban + +object KanbanColumnCollapseHelper { + fun isColumnCollapsed( + columnName: String, + columnTaskCount: Int, + boardHasWork: Boolean, + overrides: Map, + ): Boolean { + overrides[columnName]?.let { return it } + if (!boardHasWork) return false + return columnTaskCount == 0 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt new file mode 100644 index 00000000..6aaf5164 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt @@ -0,0 +1,168 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanDetailEvent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +data class FormattedKanbanEvent( + val label: String, + val detail: String? = null, +) + +object KanbanEventFormatter { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun format(event: KanbanDetailEvent): FormattedKanbanEvent { + val payloadMap = extractPayloadMap(event.payload) + + fun str(key: String): String? { + val element = payloadMap[key] ?: return null + return when { + element is JsonPrimitive && element.isString -> element.content.takeIf { it.isNotBlank() } + element is JsonPrimitive -> element.content.takeIf { it.isNotBlank() } + else -> null + } + } + + fun col(key: String): String? { + val raw = str(key) ?: return null + return when (raw.lowercase()) { + "triage" -> "Triage" + "todo" -> "Todo" + "scheduled" -> "Scheduled" + "ready" -> "Ready" + "running" -> "Running" + "blocked" -> "Blocked" + "review" -> "Review" + "done" -> "Done" + "archived" -> "Archived" + else -> raw.replaceFirstChar { it.uppercase() } + } + } + + return when (event.kind.lowercase()) { + "created" -> { + val status = col("status") ?: "Todo" + val assignee = str("assignee") + val label = if (assignee != null) "Created in $status by $assignee" else "Created in $status" + FormattedKanbanEvent(label = label) + } + + "status" -> { + val targetStatus = col("status") ?: "?" + val reason = str("reason") + val parent = str("parent") + val detail = + when (reason) { + "parent_reopened" -> if (parent != null) "Parent $parent reopened" else "Parent reopened" + else -> reason + } + FormattedKanbanEvent( + label = "Moved to $targetStatus", + detail = detail, + ) + } + + "assigned" -> { + val assignee = str("assignee") + FormattedKanbanEvent( + label = if (assignee != null) "Assigned to $assignee" else "Unassigned", + ) + } + + "commented" -> { + val author = str("author") ?: "someone" + FormattedKanbanEvent(label = "Comment by $author") + } + + "claimed" -> { + val source = str("source_status") + FormattedKanbanEvent( + label = if (source == "review") "Claimed for review" else "Claimed by worker", + ) + } + + "spawned" -> { + val pid = str("pid") ?: payloadMap["pid"]?.toString() + FormattedKanbanEvent( + label = "Worker started", + detail = if (pid != null && pid != "null") "PID $pid" else null, + ) + } + + "completed" -> { + FormattedKanbanEvent(label = "Completed") + } + + "blocked" -> { + val reason = str("reason") + FormattedKanbanEvent(label = "Blocked", detail = reason) + } + + "unblocked" -> { + val status = col("status") + val label = if (status != null) "Unblocked ($status)" else "Unblocked" + FormattedKanbanEvent(label = label) + } + + "reclaimed" -> { + val reason = str("reason") + FormattedKanbanEvent(label = "Reclaimed", detail = reason) + } + + "specified" -> { + FormattedKanbanEvent(label = "Specified") + } + + "promoted" -> { + FormattedKanbanEvent(label = "Promoted to ready") + } + + "scheduled" -> { + val reason = str("reason") + FormattedKanbanEvent(label = "Scheduled", detail = reason) + } + + "archived" -> { + FormattedKanbanEvent(label = "Archived") + } + + "reprioritized" -> { + val priority = str("priority") ?: payloadMap["priority"]?.toString() ?: "?" + FormattedKanbanEvent(label = "Priority set to $priority") + } + + else -> { + val label = event.kind.replace('_', ' ').replaceFirstChar { it.uppercase() } + val detail = + payloadMap.entries + .filter { (_, v) -> v is JsonPrimitive } + .joinToString(" ") { (k, v) -> "$k=${(v as JsonPrimitive).content}" } + .ifBlank { null } + FormattedKanbanEvent(label = label, detail = detail) + } + } + } + + private fun extractPayloadMap(element: JsonElement?): Map { + if (element == null) return emptyMap() + if (element is JsonObject) return element + if (element is JsonPrimitive && element.isString) { + val content = element.content.trim() + if (content.startsWith("{") && content.endsWith("}")) { + runCatching { + val parsed = json.parseToJsonElement(content) + if (parsed is JsonObject) return parsed + } + } + return if (content.isNotEmpty()) mapOf("raw" to element) else emptyMap() + } + return emptyMap() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGrouping.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGrouping.kt new file mode 100644 index 00000000..be18abfc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGrouping.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanTask + +data class KanbanRunningGroup( + val assignee: String?, + val tasks: List, +) + +object KanbanLaneGrouping { + fun groupRunningTasks(tasks: List): List { + val grouped = tasks.groupBy { it.assignee?.takeIf { a -> a.isNotBlank() } } + val namedGroups = + grouped + .filterKeys { it != null } + .entries + .sortedBy { it.key!!.lowercase() } + .map { KanbanRunningGroup(assignee = it.key, tasks = it.value) } + val unassignedTasks = grouped[null] + return if (!unassignedTasks.isNullOrEmpty()) { + namedGroups + KanbanRunningGroup(assignee = null, tasks = unassignedTasks) + } else { + namedGroups + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt new file mode 100644 index 00000000..326d23a6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt @@ -0,0 +1,79 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.UpdateTaskBody + +/** + * Task model override matching desktop's TaskModelOverride contract: + * - model: model name or ID + * - provider: provider name or ID + * - effort: "" (inherit), "none" (explicitly disabled), or reasoning effort string ("low", "medium", "high") + */ +data class KanbanModelOverride( + val model: String = "", + val provider: String = "", + val effort: String = "", +) { + val isInherited: Boolean + get() = model.isBlank() && provider.isBlank() && effort.isBlank() + + fun displayLabel(inheritLabel: String = "Inherit profile"): String { + if (isInherited) return inheritLabel + val trimmedModel = model.trim() + val trimmedProvider = provider.trim() + val trimmedEffort = effort.trim() + + val base = + when { + trimmedModel.isNotEmpty() && trimmedProvider.isNotEmpty() -> "$trimmedProvider: $trimmedModel" + trimmedModel.isNotEmpty() -> trimmedModel + else -> inheritLabel + } + + val effortPart = + when (trimmedEffort.lowercase()) { + "none" -> "None" + "low" -> "Low" + "medium" -> "Medium" + "high" -> "High" + "" -> "" + else -> trimmedEffort.replaceFirstChar { it.uppercase() } + } + + return if (effortPart.isNotEmpty() && base != inheritLabel) { + "$base ยท $effortPart" + } else if (effortPart.isNotEmpty()) { + "Effort: $effortPart" + } else { + base + } + } + + fun toUpdateTaskBody(): UpdateTaskBody { + val trimmedModel = model.trim() + val trimmedProvider = provider.trim() + val trimmedEffort = effort.trim() + + return UpdateTaskBody( + modelOverride = trimmedModel.ifEmpty { null }, + providerOverride = if (trimmedModel.isNotEmpty() && trimmedProvider.isNotEmpty()) trimmedProvider else null, + clearModelOverride = trimmedModel.isEmpty(), + reasoningEffort = trimmedEffort.ifEmpty { null }, + clearReasoningEffort = trimmedEffort.isEmpty(), + ) + } + + companion object { + val EMPTY = KanbanModelOverride() + + fun fromTask( + model: String?, + provider: String?, + effort: String?, + ): KanbanModelOverride = + KanbanModelOverride( + model = model?.trim().orEmpty(), + provider = provider?.trim().orEmpty(), + effort = effort?.trim().orEmpty(), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt new file mode 100644 index 00000000..50b6ba27 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanTask + +enum class KanbanArcState { + QUEUED, + RUNNING, + STALE, +} + +object KanbanRuntimeHelper { + fun arcState( + task: KanbanTask, + fallbackAssignee: String? = null, + nowSeconds: Long = System.currentTimeMillis() / 1000L, + ): KanbanArcState? { + val normalized = task.status.lowercase() + if (normalized == "running") { + val isStale = task.lastHeartbeatAt?.let { (nowSeconds - it) > 120L } ?: false + return if (isStale) KanbanArcState.STALE else KanbanArcState.RUNNING + } + val isQueued = + normalized == "triage" || + normalized == "review" || + (normalized == "ready" && (!task.assignee.isNullOrBlank() || !fallbackAssignee.isNullOrBlank())) + return if (isQueued) KanbanArcState.QUEUED else null + } + + fun isWontRun( + task: KanbanTask, + fallbackAssignee: String? = null, + ): Boolean = + task.status.equals("ready", ignoreCase = true) && + task.assignee.isNullOrBlank() && + fallbackAssignee.isNullOrBlank() + + fun formatElapsed( + startSeconds: Long?, + nowSeconds: Long = System.currentTimeMillis() / 1000L, + ): String? { + if (startSeconds == null || startSeconds <= 0L || nowSeconds < startSeconds) return null + val diff = nowSeconds - startSeconds + return when { + diff < 60L -> "${diff}s" + diff < 3600L -> "${diff / 60L}m" + diff < 86400L -> "${diff / 3600L}h" + else -> "${diff / 86400L}d" + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanStatusConstraints.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanStatusConstraints.kt new file mode 100644 index 00000000..018f6aea --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanStatusConstraints.kt @@ -0,0 +1,15 @@ +package com.m57.hermescontrol.ui.kanban + +/** + * Shared constraints for Kanban target columns. + * Mirrors desktop's LOCKED_COLUMNS: system-owned columns that users can drag/move + * cards OUT of, but never manually target for moves or creation. + */ +object KanbanStatusConstraints { + val LOCKED_COLUMNS = setOf("running", "review", "scheduled") + + fun isLockedTarget(status: String): Boolean = status.lowercase() in LOCKED_COLUMNS + + fun isUserWritableTarget(status: String): Boolean = + !isLockedTarget(status) && !status.equals("archived", ignoreCase = true) +}