From 7cce5e99c7a700fdcf3fb67a70546dfd13a318a0 Mon Sep 17 00:00:00 2001 From: djuka Date: Fri, 20 Feb 2026 15:17:51 +0000 Subject: [PATCH] =?UTF-8?q?T25:=20Timestamp=20tracking=20+=20izve=C5=A1taj?= =?UTF-8?q?=20prikaz=20u=20overlay=20modalu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- code/internal/server/render.go | 10 ++ code/internal/server/server.go | 90 ++++++++++++- code/internal/server/server_test.go | 197 ++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 7 deletions(-) diff --git a/code/internal/server/render.go b/code/internal/server/render.go index 7be4610..c00dc39 100644 --- a/code/internal/server/render.go +++ b/code/internal/server/render.go @@ -203,6 +203,16 @@ func renderSearchResults(data searchResultsData) 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 `
+ +

` + template.HTMLEscapeString(title) + `

+
` + rendered + `
+
` +} + // renderTaskDetail generates HTML fragment for task detail panel. // Content is rendered from markdown to HTML using goldmark. func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string { diff --git a/code/internal/server/server.go b/code/internal/server/server.go index dda484c..6c0efcd 100644 --- a/code/internal/server/server.go +++ b/code/internal/server/server.go @@ -2,12 +2,14 @@ package server import ( + "fmt" "io/fs" "net/http" "os" "path/filepath" "strings" "sync" + "time" "github.com/gin-gonic/gin" @@ -207,6 +209,12 @@ func (s *Server) apiMoveTask(c *gin.Context) { 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}) } @@ -277,6 +285,12 @@ func (s *Server) handleMoveTask(c *gin.Context) { 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 s.handleDashboard(c) } @@ -326,6 +340,8 @@ func (s *Server) handleRunTask(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + readyPath := filepath.Join(s.Config.TasksDir, "ready", id+".md") + appendTimestamp(readyPath, "Odobren (→ready)") task.Status = "ready" } @@ -367,6 +383,10 @@ func (s *Server) handleRunTask(c *gin.Context) { }) session.mu.Unlock() + // Append "Pokrenut" timestamp + taskPath := filepath.Join(s.Config.TasksDir, "ready", id+".md") + appendTimestamp(taskPath, "Pokrenut") + go s.runCommand(session, prompt, execID) 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) { id := strings.ToUpper(c.Param("id")) reportPath := filepath.Join(s.Config.TasksDir, "reports", id+"-report.md") - content, err := os.ReadFile(reportPath) - if err != nil { - c.String(http.StatusNotFound, "Izveštaj za %s nije pronađen", id) - return + 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 { + c.String(http.StatusInternalServerError, "Greška: %v", err) + 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.String(http.StatusOK, string(content)) + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, renderReportModal(id, title, string(content))) } // Run starts the HTTP server. @@ -439,6 +481,40 @@ func isMoveAllowed(from, to string) bool { 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 { deps := t.DependsOn if deps == nil { diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go index d980130..2c93a89 100644 --- a/code/internal/server/server_test.go +++ b/code/internal/server/server_test.go @@ -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, "

") { + 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 --- func TestStripAnsi(t *testing.T) {