feat(kanban): add runtime state, event formatter and grouping helpers (#1137)
This commit is contained in:
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)
|
||||
}
|
||||
Reference in new issue
Block a user