T25: Timestamp tracking + izveštaj prikaz u overlay modalu

1. Timestamp: svaki potez taska (move/run) dodaje red u tabelu
   "## Vremena" u task fajlu sa događajem i vremenom.
2. Izveštaj: klik "Izveštaj" na done tasku otvara overlay modal
   sa goldmark-renderovanim reportom. Ako nema reporta, prikazuje
   sam task sadržaj.

10 novih testova, 172 ukupno — svi prolaze.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-20 15:17:51 +00:00
parent 003650df24
commit 7cce5e99c7
3 changed files with 290 additions and 7 deletions

View File

@ -203,6 +203,16 @@ func renderSearchResults(data searchResultsData) string {
return buf.String() return buf.String()
} }
// renderReportModal generates HTML fragment for the report overlay modal.
func renderReportModal(taskID, title, content string) string {
rendered := renderMarkdown([]byte(content), taskID+"-report.md")
return `<div class="detail-inner">
<span class="detail-close" onclick="closeDetail()"></span>
<h2>` + template.HTMLEscapeString(title) + `</h2>
<div class="detail-content docs-content">` + rendered + `</div>
</div>`
}
// renderTaskDetail generates HTML fragment for task detail panel. // renderTaskDetail generates HTML fragment for task detail panel.
// Content is rendered from markdown to HTML using goldmark. // Content is rendered from markdown to HTML using goldmark.
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string { func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {

View File

@ -2,12 +2,14 @@
package server package server
import ( import (
"fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -207,6 +209,12 @@ func (s *Server) apiMoveTask(c *gin.Context) {
return return
} }
// Append timestamp to moved file
newPath := filepath.Join(s.Config.TasksDir, toFolder, id+".md")
if label, ok := moveEventLabel[toFolder]; ok {
appendTimestamp(newPath, label)
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "moved": id, "to": toFolder}) c.JSON(http.StatusOK, gin.H{"status": "ok", "moved": id, "to": toFolder})
} }
@ -277,6 +285,12 @@ func (s *Server) handleMoveTask(c *gin.Context) {
return return
} }
// Append timestamp to moved file
newPath := filepath.Join(s.Config.TasksDir, toFolder, id+".md")
if label, ok := moveEventLabel[toFolder]; ok {
appendTimestamp(newPath, label)
}
// Re-scan and return updated dashboard // Re-scan and return updated dashboard
s.handleDashboard(c) s.handleDashboard(c)
} }
@ -326,6 +340,8 @@ func (s *Server) handleRunTask(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
readyPath := filepath.Join(s.Config.TasksDir, "ready", id+".md")
appendTimestamp(readyPath, "Odobren (→ready)")
task.Status = "ready" task.Status = "ready"
} }
@ -367,6 +383,10 @@ func (s *Server) handleRunTask(c *gin.Context) {
}) })
session.mu.Unlock() session.mu.Unlock()
// Append "Pokrenut" timestamp
taskPath := filepath.Join(s.Config.TasksDir, "ready", id+".md")
appendTimestamp(taskPath, "Pokrenut")
go s.runCommand(session, prompt, execID) go s.runCommand(session, prompt, execID)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@ -376,19 +396,41 @@ func (s *Server) handleRunTask(c *gin.Context) {
}) })
} }
// handleReport serves a task report file. // handleReport serves a task report in the overlay modal.
// If a report file exists, render it. Otherwise, show the task content.
func (s *Server) handleReport(c *gin.Context) { func (s *Server) handleReport(c *gin.Context) {
id := strings.ToUpper(c.Param("id")) id := strings.ToUpper(c.Param("id"))
reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md") reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md")
content, err := os.ReadFile(reportPath) var content []byte
var title string
if data, err := os.ReadFile(reportPath); err == nil {
content = data
title = id + " — Izveštaj"
} else {
// No report — show the task file itself
tasks, err := supervisor.ScanTasks(s.Config.TasksDir)
if err != nil { if err != nil {
c.String(http.StatusNotFound, "Izveštaj za %s nije pronađen", id) c.String(http.StatusInternalServerError, "Greška: %v", err)
return return
} }
task := supervisor.FindTask(tasks, id)
if task == nil {
c.String(http.StatusNotFound, "Task %s nije pronađen", id)
return
}
data, err := os.ReadFile(task.FilePath)
if err != nil {
c.String(http.StatusNotFound, "Fajl nije pronađen")
return
}
content = data
title = id + " — " + task.Title
}
c.Header("Content-Type", "text/plain; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, string(content)) c.String(http.StatusOK, renderReportModal(id, title, string(content)))
} }
// Run starts the HTTP server. // Run starts the HTTP server.
@ -439,6 +481,40 @@ func isMoveAllowed(from, to string) bool {
return targets[to] return targets[to]
} }
// moveEventLabel returns a human-readable label for a folder transition.
var moveEventLabel = map[string]string{
"ready": "Odobren (→ready)",
"active": "Pokrenut (→active)",
"review": "Završen (→review)",
"done": "Odobren (→done)",
}
// appendTimestamp adds a timestamp row to the task file's "## Vremena" table.
// If the table doesn't exist yet, it creates it.
func appendTimestamp(filePath, event string) error {
content, err := os.ReadFile(filePath)
if err != nil {
return err
}
now := time.Now().Format("2006-01-02 15:04")
row := fmt.Sprintf("| %s | %s |", event, now)
text := string(content)
marker := "## Vremena"
if strings.Contains(text, marker) {
// Table exists — append row before the last line break at end
text = strings.TrimRight(text, "\n") + "\n" + row + "\n"
} else {
// Create the table section at the end
section := fmt.Sprintf("\n%s\n\n| Događaj | Vreme |\n|---------|-------|\n%s\n", marker, row)
text = strings.TrimRight(text, "\n") + "\n" + section
}
return os.WriteFile(filePath, []byte(text), 0644)
}
func toTaskResponse(t supervisor.Task) taskResponse { func toTaskResponse(t supervisor.Task) taskResponse {
deps := t.DependsOn deps := t.DependsOn
if deps == nil { if deps == nil {

View File

@ -1675,6 +1675,203 @@ func TestDocsPage_HasFullHeightLayout(t *testing.T) {
} }
} }
// --- T25: Timestamp + report tests ---
func TestMoveTask_AddsTimestamp(t *testing.T) {
srv := setupTestServer(t)
// Move T08 from backlog to ready
req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Read the moved file and check for timestamp
content, err := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md"))
if err != nil {
t.Fatalf("failed to read moved file: %v", err)
}
text := string(content)
if !containsStr(text, "## Vremena") {
t.Error("expected '## Vremena' section in task file")
}
if !containsStr(text, "Odobren (→ready)") {
t.Error("expected 'Odobren (→ready)' timestamp")
}
}
func TestMoveTask_AppendsMultipleTimestamps(t *testing.T) {
srv := setupTestServer(t)
// Move backlog → ready
req := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
// Move ready → backlog (return)
req2 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=backlog", nil)
w2 := httptest.NewRecorder()
// Need to re-fetch since task is now in ready/
srv.Router.ServeHTTP(w2, req2)
// Move backlog → ready again
req3 := httptest.NewRequest(http.MethodPost, "/task/T08/move?to=ready", nil)
w3 := httptest.NewRecorder()
srv.Router.ServeHTTP(w3, req3)
content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md"))
text := string(content)
// Should have two "Odobren (→ready)" entries
count := strings.Count(text, "Odobren (→ready)")
if count != 2 {
t.Errorf("expected 2 'Odobren (→ready)' timestamps, got %d", count)
}
}
func TestAPIMoveTask_AddsTimestamp(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/task/T08/move?to=ready", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md"))
text := string(content)
if !containsStr(text, "Odobren (→ready)") {
t.Error("expected timestamp in API-moved task file")
}
}
func TestAppendTimestamp_CreatesTable(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.md")
os.WriteFile(path, []byte("# T01: Test\n\nSome content.\n"), 0644)
err := appendTimestamp(path, "Odobren (→ready)")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
content, _ := os.ReadFile(path)
text := string(content)
if !containsStr(text, "## Vremena") {
t.Error("expected Vremena section")
}
if !containsStr(text, "| Događaj | Vreme |") {
t.Error("expected table header")
}
if !containsStr(text, "Odobren (→ready)") {
t.Error("expected timestamp row")
}
}
func TestAppendTimestamp_AppendsToExisting(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.md")
os.WriteFile(path, []byte("# T01: Test\n\n## Vremena\n\n| Događaj | Vreme |\n|---------|-------|\n| Kreiran | 2026-02-20 14:00 |\n"), 0644)
err := appendTimestamp(path, "Odobren (→ready)")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
content, _ := os.ReadFile(path)
text := string(content)
if !containsStr(text, "Kreiran") {
t.Error("expected existing row preserved")
}
if !containsStr(text, "Odobren (→ready)") {
t.Error("expected new timestamp row")
}
}
func TestReport_RendersMarkdownInModal(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/report/T01", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
if !containsStr(body, "detail-inner") {
t.Error("expected detail-inner wrapper for modal")
}
if !containsStr(body, "docs-content") {
t.Error("expected docs-content class for markdown rendering")
}
if !containsStr(body, "<h1>") {
t.Error("expected rendered markdown heading")
}
if !containsStr(body, "Izveštaj") {
t.Error("expected 'Izveštaj' in title")
}
}
func TestReport_NoReport_ShowsTask(t *testing.T) {
srv := setupTestServer(t)
// T08 has no report — should show task content
req := httptest.NewRequest(http.MethodGet, "/report/T08", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
body := w.Body.String()
if !containsStr(body, "detail-inner") {
t.Error("expected detail-inner wrapper")
}
if !containsStr(body, "HTTP server") {
t.Error("expected task title in fallback content")
}
}
func TestReport_NotFoundTask(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/report/T99", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestRunTask_AddsTimestamp(t *testing.T) {
srv := setupTestServer(t)
// Move T08 to ready
os.Rename(
filepath.Join(srv.Config.TasksDir, "backlog", "T08.md"),
filepath.Join(srv.Config.TasksDir, "ready", "T08.md"),
)
req := httptest.NewRequest(http.MethodPost, "/task/T08/run", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
content, _ := os.ReadFile(filepath.Join(srv.Config.TasksDir, "ready", "T08.md"))
text := string(content)
if !containsStr(text, "Pokrenut") {
t.Error("expected 'Pokrenut' timestamp after run")
}
}
// --- T24: PTY tests --- // --- T24: PTY tests ---
func TestStripAnsi(t *testing.T) { func TestStripAnsi(t *testing.T) {