feat(kanban): add runtime state, event formatter and grouping helpers (#1137)

This commit is contained in:
M57 committed 2026-09-16 18:14:28 +03:00
1 parent be9223eb78
commit dd11f19a1c
6 files changed
+352

No files matched your search

@@ -0,0 +1,14 @@
package com.m57.hermescontrol.ui.kanban
object KanbanColumnCollapseHelper {
fun isColumnCollapsed(
columnName: String,
columnTaskCount: Int,
boardHasWork: Boolean,
overrides: Map<String, Boolean>,
): Boolean {
overrides[columnName]?.let { return it }
if (!boardHasWork) return false
return columnTaskCount == 0
}
}
@@ -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<String, JsonElement> {
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()
}
}
@@ -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<KanbanTask>,
)
object KanbanLaneGrouping {
fun groupRunningTasks(tasks: List<KanbanTask>): List<KanbanRunningGroup> {
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
}
}
}
@@ -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(),
)
}
}
@@ -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"
}
}
}
@@ -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)
}