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:
parent
003650df24
commit
7cce5e99c7
@ -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 `<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.
|
||||
// Content is rendered from markdown to HTML using goldmark.
|
||||
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 ---
|
||||
|
||||
func TestStripAnsi(t *testing.T) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user