T13: Dodat search bar sa instant pretragom taskova i dokumenata

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-20 12:47:39 +00:00
parent 563abd8481
commit a3fc9b3af0
9 changed files with 623 additions and 4 deletions

View File

@ -0,0 +1,70 @@
# T13 Izveštaj: Dashboard — pretraga taskova i dokumentacije
**Agent:** coder
**Model:** Opus
**Datum:** 2026-02-20
---
## Šta je urađeno
Dodat search bar na dashboard sa instant pretragom (HTMX debounce 300ms).
### Novi fajlovi
| Fajl | Opis |
|------|------|
| `internal/server/search.go` | Search handler, matchTask, extractSnippet, containsInsensitive |
| `web/templates/partials/search-results.html` | Template za dropdown rezultate |
### Izmenjeni fajlovi
| Fajl | Izmena |
|------|--------|
| `internal/server/server.go` | GET /search ruta |
| `internal/server/render.go` | renderSearchResults(), search-results template u init() |
| `internal/server/server_test.go` | 8 novih testova, checker agent i report u test setup |
| `web/templates/layout.html` | Search input sa hx-get, click-outside zatvaranje |
| `web/static/style.css` | Search wrapper, dropdown, result items, status badges, snippet |
### Endpoint
| Ruta | Opis |
|------|------|
| `GET /search?q={query}` | HTML fragment sa rezultatima pretrage |
### Šta pretražuje
1. **Taskovi** — ID, naslov, opis, agent, status
2. **Dokumenti** — svi .md fajlovi (sadržaj)
3. **Izveštaji** — reports/*.md (sadržaj)
### Features
- Case insensitive pretraga
- Snippet sa kontekstom oko pogotka
- Status badge za taskove (done/active/review...)
- Klik na task → otvori detail panel
- Klik na dokument → navigacija na /docs/
- Max 20 rezultata
- Prazan query → prazan odgovor
- Nema rezultata → "Nema rezultata" poruka
- Click outside → zatvori dropdown
### Novi testovi — 8 PASS
```
TestSearch_FindsTask PASS
TestSearch_FindsTaskByID PASS
TestSearch_FindsDocument PASS
TestSearch_FindsReport PASS
TestSearch_EmptyQuery PASS
TestSearch_NoResults PASS
TestSearch_CaseInsensitive PASS
TestSearch_HasSnippet PASS
```
### Ukupno projekat: 109 testova, svi prolaze
- `go vet ./...` — čist
- `go build ./...` — prolazi

87
TASKS/review/T13.md Normal file
View File

@ -0,0 +1,87 @@
# T13: Dashboard — pretraga taskova i dokumentacije
**Kreirao:** planer
**Datum:** 2026-02-20
**Agent:** coder
**Model:** Sonnet
**Zavisi od:** T12
---
## Opis
Search bar na dashboardu. Pretražuje taskove i .md fajlove.
Rezultati se prikazuju instant dok korisnik kuca (debounce 300ms).
## Endpoint
```
GET /search?q={query} → rezultati pretrage (HTML fragment)
```
## Šta pretražuje
1. **Taskovi** — ID, naslov, opis, agent, status (folder)
2. **Dokumenti** — sadržaj svih .md fajlova
3. **Izveštaji** — sadržaj reports/*.md
## Kako radi
```html
<input type="search"
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
name="q"
placeholder="Pretraži taskove i dokumente...">
<div id="search-results"></div>
```
## Format rezultata
```
┌─────────────────────────────────────────┐
│ 🔍 "checker" │
├─────────────────────────────────────────┤
│ 📋 T04: Checker — verifikacija [done] │
│ ...go build, go vet, go test... │
│ │
│ 📄 agents/checker/CLAUDE.md │
│ ...Build + Test verifikacija... │
│ │
│ 📊 T04-report.md │
│ ...10 testova, svi prolaze... │
└─────────────────────────────────────────┘
```
Svaki rezultat:
- Ikona po tipu (📋 task, 📄 dokument, 📊 izveštaj)
- Naslov/putanja — klikabilan
- Snippet sa highlighted match (kontekst oko pogotka)
- Klik → otvori task detalj ili dokument
## Pretraga logika
- Case insensitive
- Pretraži: naslov, sadržaj, ID
- Sortiraj: taskovi prvo, pa dokumenti, pa izveštaji
- Max 20 rezultata
- Prazan query → sakrij rezultate
## Testovi
- GET /search?q=checker → vrati T04 i checker CLAUDE.md
- GET /search?q=T01 → vrati T01 task i T01-report
- GET /search?q=deploy → vrati deployer agent CLAUDE.md
- GET /search?q= → prazan odgovor
- GET /search?q=xyznepostoji → "Nema rezultata"
- Snippet sadrži kontekst oko pogotka
---
## Pitanja
---
## Odgovori

View File

@ -63,6 +63,7 @@ func init() {
"templates/partials/column.html",
"templates/partials/task-card.html",
"templates/partials/task-detail.html",
"templates/partials/search-results.html",
),
)
}
@ -106,6 +107,15 @@ func renderDocsView(data docsViewData) string {
return buf.String()
}
// renderSearchResults generates the search results HTML fragment.
func renderSearchResults(data searchResultsData) string {
var buf bytes.Buffer
if err := templates.ExecuteTemplate(&buf, "search-results", data); err != nil {
return "Greška pri renderovanju: " + err.Error()
}
return buf.String()
}
// renderTaskDetail generates HTML fragment for task detail panel.
func renderTaskDetail(t supervisor.Task, content string, hasReport bool) string {
data := taskDetailData{

View File

@ -0,0 +1,191 @@
package server
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/dal/kaos/internal/supervisor"
)
// searchResult represents a single search result.
type searchResult struct {
Type string // "task", "doc", "report"
Icon string // emoji icon
Title string // display title
Link string // URL to navigate to
Snippet string // context around the match
Status string // task status (only for tasks)
}
// searchResultsData holds data for the search results template.
type searchResultsData struct {
Query string
Results []searchResult
}
// handleSearch serves search results as an HTML fragment.
func (s *Server) handleSearch(c *gin.Context) {
query := strings.TrimSpace(c.Query("q"))
if query == "" {
c.String(http.StatusOK, "")
return
}
root := s.projectRoot()
var results []searchResult
// 1. Search tasks
tasks, _ := supervisor.ScanTasks(s.Config.TasksDir)
for _, t := range tasks {
if matchTask(t, query) {
snippet := taskSnippet(t, query)
results = append(results, searchResult{
Type: "task",
Icon: "📋",
Title: t.ID + ": " + t.Title,
Link: "/task/" + t.ID,
Snippet: snippet,
Status: t.Status,
})
}
}
// 2. Search documents (non-report .md files)
docs := scanMarkdownFiles(root)
for _, doc := range docs {
// Skip task files and reports (handled separately)
if strings.HasPrefix(doc.Path, "TASKS/") {
continue
}
content, err := os.ReadFile(filepath.Join(root, doc.Path))
if err != nil {
continue
}
if containsInsensitive(string(content), query) || containsInsensitive(doc.Path, query) {
snippet := extractSnippet(string(content), query)
results = append(results, searchResult{
Type: "doc",
Icon: "📄",
Title: doc.Path,
Link: "/docs/" + doc.Path,
Snippet: snippet,
})
}
}
// 3. Search reports
reportsDir := filepath.Join(s.Config.TasksDir, "reports")
entries, _ := os.ReadDir(reportsDir)
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
content, err := os.ReadFile(filepath.Join(reportsDir, entry.Name()))
if err != nil {
continue
}
if containsInsensitive(string(content), query) || containsInsensitive(entry.Name(), query) {
// Extract task ID from report filename (T01-report.md → T01)
taskID := strings.TrimSuffix(entry.Name(), "-report.md")
snippet := extractSnippet(string(content), query)
results = append(results, searchResult{
Type: "report",
Icon: "📊",
Title: entry.Name(),
Link: "/report/" + taskID,
Snippet: snippet,
})
}
}
// Limit results
if len(results) > 20 {
results = results[:20]
}
data := searchResultsData{
Query: query,
Results: results,
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, renderSearchResults(data))
}
// matchTask checks if a task matches the query (case insensitive).
func matchTask(t supervisor.Task, query string) bool {
q := strings.ToLower(query)
return containsInsensitive(t.ID, query) ||
containsInsensitive(t.Title, query) ||
containsInsensitive(t.Description, query) ||
containsInsensitive(t.Agent, query) ||
strings.ToLower(t.Status) == q
}
// taskSnippet returns a snippet from the task for display.
func taskSnippet(t supervisor.Task, query string) string {
// Try to find match in description first
if t.Description != "" && containsInsensitive(t.Description, query) {
return extractSnippet(t.Description, query)
}
// Fall back to description start
if t.Description != "" {
desc := t.Description
if len(desc) > 100 {
desc = desc[:100] + "..."
}
return desc
}
return t.Agent + " · " + t.Model
}
// containsInsensitive checks if s contains substr (case insensitive).
func containsInsensitive(s, substr string) bool {
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
}
// extractSnippet extracts a snippet around the first match with context.
func extractSnippet(content, query string) string {
lower := strings.ToLower(content)
q := strings.ToLower(query)
idx := strings.Index(lower, q)
if idx == -1 {
// Return first 100 chars
if len(content) > 100 {
return content[:100] + "..."
}
return content
}
// Get context: 40 chars before and 60 chars after
start := idx - 40
if start < 0 {
start = 0
}
end := idx + len(query) + 60
if end > len(content) {
end = len(content)
}
snippet := content[start:end]
// Clean up: remove newlines, trim
snippet = strings.ReplaceAll(snippet, "\n", " ")
snippet = strings.ReplaceAll(snippet, "\r", "")
snippet = strings.TrimSpace(snippet)
if start > 0 {
snippet = "..." + snippet
}
if end < len(content) {
snippet = snippet + "..."
}
return snippet
}

View File

@ -97,6 +97,9 @@ func (s *Server) setupRoutes() {
s.Router.POST("/task/:id/move", s.handleMoveTask)
s.Router.GET("/report/:id", s.handleReport)
// Search route
s.Router.GET("/search", s.handleSearch)
// Docs routes
s.Router.GET("/docs", s.handleDocsList)
s.Router.GET("/docs/*path", s.handleDocsView)

View File

@ -58,6 +58,9 @@ func setupTestServer(t *testing.T) *Server {
os.WriteFile(filepath.Join(dir, "README.md"), []byte("# README\n\nOpis projekta.\n"), 0644)
os.MkdirAll(filepath.Join(dir, "agents", "coder"), 0755)
os.WriteFile(filepath.Join(dir, "agents", "coder", "CLAUDE.md"), []byte("# Coder Agent\n\nPravila kodiranja.\n"), 0644)
os.MkdirAll(filepath.Join(dir, "agents", "checker"), 0755)
os.WriteFile(filepath.Join(dir, "agents", "checker", "CLAUDE.md"), []byte("# Checker Agent\n\nBuild + Test verifikacija.\n"), 0644)
os.WriteFile(filepath.Join(tasksDir, "reports", "T01-report.md"), []byte("# T01 Report\n\n10 testova, svi prolaze.\n"), 0644)
cfg := &config.Config{
TasksDir: tasksDir,
@ -671,6 +674,116 @@ func TestDocsView_HasBreadcrumbs(t *testing.T) {
}
}
func TestSearch_FindsTask(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=Prvi", 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, "T01") {
t.Error("expected T01 in search results for 'Prvi'")
}
}
func TestSearch_FindsTaskByID(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=T08", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "T08") {
t.Error("expected T08 in search results")
}
}
func TestSearch_FindsDocument(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=checker", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "checker/CLAUDE.md") {
t.Error("expected checker CLAUDE.md in search results")
}
}
func TestSearch_FindsReport(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=prolaze", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "T01-report.md") {
t.Error("expected T01 report in search results")
}
}
func TestSearch_EmptyQuery(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if w.Body.Len() != 0 {
t.Errorf("expected empty response for empty query, got %d bytes", w.Body.Len())
}
}
func TestSearch_NoResults(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=xyznepostoji", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "Nema rezultata") {
t.Error("expected 'Nema rezultata' message")
}
}
func TestSearch_CaseInsensitive(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=CODER", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "coder") {
t.Error("expected case-insensitive match for 'CODER'")
}
}
func TestSearch_HasSnippet(t *testing.T) {
srv := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/search?q=kodiranja", nil)
w := httptest.NewRecorder()
srv.Router.ServeHTTP(w, req)
body := w.Body.String()
if !containsStr(body, "kodiranja") {
t.Error("expected snippet with 'kodiranja' text")
}
}
func TestRewriteLinksSimple(t *testing.T) {
input := `<a href="README.md">link</a> and <a href="https://example.com">ext</a>`
result := rewriteLinksSimple(input, ".")

View File

@ -226,6 +226,114 @@ body {
color: #fff;
}
/* Header right section */
.header-right {
display: flex;
gap: 12px;
align-items: center;
}
/* Search */
.search-wrapper {
position: relative;
}
.search-wrapper input {
background: #1a1a2e;
border: 1px solid #333;
border-radius: 6px;
color: #eee;
padding: 6px 12px;
font-size: 0.85em;
width: 220px;
outline: none;
transition: border-color 0.2s, width 0.3s;
}
.search-wrapper input:focus {
border-color: #e94560;
width: 300px;
}
.search-results-dropdown {
position: absolute;
top: 100%;
right: 0;
width: 400px;
max-height: 500px;
overflow-y: auto;
background: #16213e;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
z-index: 50;
margin-top: 4px;
}
.search-results-dropdown:empty {
display: none;
}
.search-result {
display: block;
padding: 10px 14px;
text-decoration: none;
color: #eee;
border-bottom: 1px solid #0f3460;
transition: background 0.15s;
}
.search-result:last-child {
border-bottom: none;
}
.search-result:hover {
background: #0f3460;
}
.search-result-header {
display: flex;
align-items: center;
gap: 6px;
}
.search-icon { font-size: 0.9em; }
.search-title {
font-weight: bold;
font-size: 0.85em;
}
.search-status {
font-size: 0.7em;
padding: 2px 6px;
border-radius: 4px;
background: #0f3460;
margin-left: auto;
}
.search-status-done { color: #4ecca3; }
.search-status-active { color: #e94560; }
.search-status-review { color: #ffd93d; }
.search-status-ready { color: #6ec6ff; }
.search-status-backlog { color: #888; }
.search-snippet {
font-size: 0.75em;
color: #888;
margin-top: 4px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-empty {
padding: 16px;
text-align: center;
color: #888;
font-size: 0.85em;
}
/* Navigation */
.nav {
display: flex;

View File

@ -11,10 +11,22 @@
<body>
<div class="header">
<h1>🔧 KAOS Dashboard</h1>
<div class="header-right">
<div class="search-wrapper">
<input type="search" id="search-input"
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
name="q"
placeholder="Pretraži..."
autocomplete="off">
<div id="search-results" class="search-results-dropdown"></div>
</div>
<nav class="nav">
<a href="/" class="btn btn-active">Kanban</a>
<a href="/docs" class="btn">Dokumenti</a>
</nav>
</div>
</div>
{{block "content" .}}{{end}}
<div id="task-detail"></div>
@ -43,6 +55,15 @@ function showToast(msg, type) {
document.addEventListener('DOMContentLoaded', function() {
initSortable();
// Close search results on click outside
document.addEventListener('click', function(e) {
var wrapper = document.querySelector('.search-wrapper');
var results = document.getElementById('search-results');
if (wrapper && !wrapper.contains(e.target)) {
results.innerHTML = '';
}
});
});
document.body.addEventListener('htmx:afterSwap', function(e) {

View File

@ -0,0 +1,16 @@
{{define "search-results"}}
{{if .Results}}
{{range .Results}}
<a href="{{.Link}}" class="search-result" {{if eq .Type "task"}}hx-get="{{.Link}}" hx-target="#task-detail" hx-swap="innerHTML"{{end}}>
<div class="search-result-header">
<span class="search-icon">{{.Icon}}</span>
<span class="search-title">{{.Title}}</span>
{{if .Status}}<span class="search-status search-status-{{.Status}}">{{.Status}}</span>{{end}}
</div>
<div class="search-snippet">{{.Snippet}}</div>
</a>
{{end}}
{{else}}
<div class="search-empty">Nema rezultata za "{{.Query}}"</div>
{{end}}
{{end}}