feat(tasks): markdown export projection

A disposable Markdown view of the board, grouped by status with checkboxes
and claimants — rendered from the event log, never a source of truth.
TaskMarkdown.render (pure) backs GET /tasks/export[?project=].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 07:17:06 +00:00
parent 8d84ccf92f
commit 8a6678f171
4 changed files with 111 additions and 0 deletions
@@ -0,0 +1,54 @@
package com.correx.core.tasks
/**
* Renders the task board to Markdown — a *disposable projection* of the event log, grouped by
* status. It is never a source of truth: regenerate it, don't hand-edit it. Pure and store-free
* so it can render either [TaskService.list] or [TaskService.listAll] and be unit tested directly.
*/
object TaskMarkdown {
// Active work first, terminal states last.
private val ORDER = listOf(
TaskStatus.IN_PROGRESS,
TaskStatus.IN_REVIEW,
TaskStatus.BLOCKED,
TaskStatus.TODO,
TaskStatus.DONE,
TaskStatus.CANCELLED,
)
fun render(tasks: List<Task>): String = buildString {
appendLine("# Tasks")
appendLine()
appendLine("_Generated from the correx event log — disposable; regenerate, don't edit._")
appendLine()
if (tasks.isEmpty()) {
appendLine("_No tasks._")
return@buildString
}
val byStatus = tasks.groupBy { it.state.status }
for (status in ORDER) {
val group = byStatus[status]?.sortedBy { it.taskId.value }.orEmpty()
if (group.isEmpty()) continue
appendLine("## ${heading(status)} (${group.size})")
appendLine()
group.forEach { appendLine(item(it)) }
appendLine()
}
}.trimEnd() + "\n"
private fun item(task: Task): String {
val check = if (task.state.status == TaskStatus.DONE) "x" else " "
val claim = task.state.claimant?.let { " _(@$it)_" }.orEmpty()
return "- [$check] **${task.taskId.value}** ${task.state.title.orEmpty()}$claim".trimEnd()
}
private fun heading(status: TaskStatus): String = when (status) {
TaskStatus.TODO -> "To do"
TaskStatus.IN_PROGRESS -> "In progress"
TaskStatus.IN_REVIEW -> "In review"
TaskStatus.BLOCKED -> "Blocked"
TaskStatus.DONE -> "Done"
TaskStatus.CANCELLED -> "Cancelled"
}
}
@@ -0,0 +1,33 @@
package com.correx.core.tasks
import com.correx.core.events.types.TaskId
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class TaskMarkdownTest {
private fun task(id: String, status: TaskStatus, title: String, claimant: String? = null) =
Task(TaskId(id), TaskState(key = id, status = status, title = title, claimant = claimant))
@Test
fun `renders grouped sections with checkboxes and claimants`() {
val md = TaskMarkdown.render(
listOf(
task("auth-1", TaskStatus.IN_PROGRESS, "JWT refresh", claimant = "claude-opus"),
task("auth-2", TaskStatus.DONE, "Login form"),
),
)
assertTrue(md.startsWith("# Tasks"))
assertTrue(md.contains("disposable"))
assertTrue(md.contains("## In progress (1)"))
assertTrue(md.contains("- [ ] **auth-1** JWT refresh _(@claude-opus)_"))
assertTrue(md.contains("## Done (1)"))
assertTrue(md.contains("- [x] **auth-2** Login form"))
}
@Test
fun `empty board renders a placeholder`() {
assertTrue(TaskMarkdown.render(emptyList()).contains("_No tasks._"))
}
}