Skip to content
This repository was archived by the owner on Apr 16, 2026. It is now read-only.

Commit 8808451

Browse files
authored
Merge pull request #19 from yepzdk/feature/issue-2-observation-search-ui
Issue #2: Add observation search and filtering UI
2 parents dc14d88 + 8d3ab4f commit 8808451

6 files changed

Lines changed: 229 additions & 5 deletions

File tree

assets/viewer/index.html

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@
110110
font-family: inherit;
111111
}
112112
.search-form input[type="text"] { flex: 1; min-width: 200px; }
113+
.filter-row select {
114+
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
115+
padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.875rem;
116+
font-family: inherit;
117+
}
118+
.filter-row select:focus { outline: none; border-color: #58a6ff; }
113119
.search-form input[type="text"]:focus,
114120
.search-form input[type="date"]:focus,
115121
.search-form select:focus {
@@ -267,17 +273,23 @@ <h2 id="section-title">Live Feed</h2>
267273
<form class="search-form" id="search-form">
268274
<input type="text" id="search-query" placeholder="Search observations..." required>
269275
<button type="submit" class="btn">Search</button>
276+
<button type="button" class="btn btn-secondary" id="search-clear" style="display:none">Clear</button>
270277
</form>
271278
<div class="filter-row">
272279
<label>Type:</label>
273-
<input type="text" id="search-type" placeholder="e.g. discovery" style="width:120px">
280+
<select id="search-type">
281+
<option value="">All types</option>
282+
</select>
274283
<label>Project:</label>
275-
<input type="text" id="search-project" placeholder="project" style="width:120px">
284+
<select id="search-project">
285+
<option value="">All projects</option>
286+
</select>
276287
<label>From:</label>
277288
<input type="date" id="search-date-start">
278289
<label>To:</label>
279290
<input type="date" id="search-date-end">
280291
</div>
292+
<div id="search-status" style="margin-bottom:0.75rem;font-size:0.85rem;color:#8b949e;display:none"></div>
281293
<div id="search-results"></div>
282294
</div>
283295
</div>
@@ -370,7 +382,8 @@ <h2 id="section-title">Live Feed</h2>
370382
overlay.classList.remove('visible');
371383

372384
// Load data when switching to a section
373-
if (section === 'sessions') loadSessions();
385+
if (section === 'search') loadFilters();
386+
else if (section === 'sessions') loadSessions();
374387
else if (section === 'summaries') loadSummaries();
375388
else if (section === 'plans') loadPlans();
376389
}
@@ -481,15 +494,61 @@ <h2 id="section-title">Live Feed</h2>
481494
// --- Search ---
482495
const searchForm = document.getElementById('search-form');
483496
const searchResults = document.getElementById('search-results');
497+
const searchClear = document.getElementById('search-clear');
498+
const searchStatus = document.getElementById('search-status');
499+
const searchTypeSelect = document.getElementById('search-type');
500+
const searchProjectSelect = document.getElementById('search-project');
501+
let filtersLoaded = false;
502+
503+
async function loadFilters() {
504+
if (filtersLoaded) return;
505+
try {
506+
const resp = await fetch('/api/observations/filters');
507+
if (!resp.ok) return;
508+
const data = await resp.json();
509+
if (data.types) {
510+
for (const t of data.types) {
511+
const opt = document.createElement('option');
512+
opt.value = t;
513+
opt.textContent = t;
514+
searchTypeSelect.appendChild(opt);
515+
}
516+
}
517+
if (data.projects) {
518+
for (const p of data.projects) {
519+
const opt = document.createElement('option');
520+
opt.value = p;
521+
opt.textContent = p;
522+
searchProjectSelect.appendChild(opt);
523+
}
524+
}
525+
filtersLoaded = true;
526+
} catch (err) {
527+
console.error('Load filters error:', err);
528+
}
529+
}
530+
531+
function clearSearch() {
532+
document.getElementById('search-query').value = '';
533+
searchTypeSelect.value = '';
534+
searchProjectSelect.value = '';
535+
document.getElementById('search-date-start').value = '';
536+
document.getElementById('search-date-end').value = '';
537+
searchResults.innerHTML = '';
538+
searchStatus.style.display = 'none';
539+
searchClear.style.display = 'none';
540+
}
541+
542+
searchClear.addEventListener('click', clearSearch);
484543

485544
searchForm.addEventListener('submit', async (e) => {
486545
e.preventDefault();
487546
const q = document.getElementById('search-query').value.trim();
488547
if (!q) return;
489548

490549
const params = new URLSearchParams({ q });
491-
const type = document.getElementById('search-type').value.trim();
492-
const project = document.getElementById('search-project').value.trim();
550+
const type = searchTypeSelect.value;
551+
const project = searchProjectSelect.value;
493552
const dateStart = document.getElementById('search-date-start').value;
494553
const dateEnd = document.getElementById('search-date-end').value;
495554
if (type) params.set('type', type);
@@ -498,17 +557,24 @@ <h2 id="section-title">Live Feed</h2>
498557
if (dateEnd) params.set('dateEnd', dateEnd);
499558

500559
searchResults.innerHTML = '<div class="placeholder">Searching...</div>';
560+
searchStatus.style.display = 'none';
501561
try {
502562
const resp = await fetch('/api/observations/search?' + params);
503563
const results = await resp.json();
504564
if (!results || results.length === 0) {
505565
searchResults.innerHTML = '<div class="placeholder">No results found.</div>';
566+
searchStatus.textContent = '0 results';
567+
searchStatus.style.display = 'block';
568+
searchClear.style.display = 'inline-block';
506569
return;
507570
}
508571
searchResults.innerHTML = '';
509572
for (const obs of results) {
510573
searchResults.appendChild(createEventCard(obs, fmtTime(obs.CreatedAt)));
511574
}
575+
searchStatus.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '');
576+
searchStatus.style.display = 'block';
577+
searchClear.style.display = 'inline-block';
512578
} catch (err) {
513579
searchResults.innerHTML = '<div class="placeholder">Search failed.</div>';
514580
console.error('Search error:', err);

internal/console/handlers.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,28 @@ func (s *Server) handleRecentObservations(w http.ResponseWriter, r *http.Request
8181
writeJSON(w, http.StatusOK, results)
8282
}
8383

84+
func (s *Server) handleObservationFilters(w http.ResponseWriter, r *http.Request) {
85+
types, err := s.db.DistinctTypes()
86+
if err != nil {
87+
s.logger.Error("distinct types", "error", err)
88+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
89+
return
90+
}
91+
projects, err := s.db.DistinctProjects()
92+
if err != nil {
93+
s.logger.Error("distinct projects", "error", err)
94+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
95+
return
96+
}
97+
if types == nil {
98+
types = []string{}
99+
}
100+
if projects == nil {
101+
projects = []string{}
102+
}
103+
writeJSON(w, http.StatusOK, map[string][]string{"types": types, "projects": projects})
104+
}
105+
84106
func (s *Server) handleSearchObservations(w http.ResponseWriter, r *http.Request) {
85107
query := r.URL.Query().Get("q")
86108
if query == "" {

internal/console/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ func (s *Server) registerRoutes() {
114114
r.Post("/observations", s.handleCreateObservation)
115115
r.Get("/observations/recent", s.handleRecentObservations)
116116
r.Get("/observations/{id}", s.handleGetObservation)
117+
r.Get("/observations/filters", s.handleObservationFilters)
117118
r.Get("/observations/search", s.handleSearchObservations)
118119
r.Get("/observations/hybrid-search", s.handleHybridSearch)
119120
r.Post("/search/reindex", s.handleReindex)

internal/console/server_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,50 @@ func TestPlanMissingPath(t *testing.T) {
485485
}
486486
}
487487

488+
func TestObservationFilters(t *testing.T) {
489+
srv := testServer(t)
490+
491+
// Empty database returns empty arrays
492+
rr := doRequest(t, srv, "GET", "/api/observations/filters", nil)
493+
if rr.Code != http.StatusOK {
494+
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
495+
}
496+
var resp map[string][]string
497+
json.NewDecoder(rr.Body).Decode(&resp)
498+
if len(resp["types"]) != 0 {
499+
t.Errorf("expected empty types, got %v", resp["types"])
500+
}
501+
if len(resp["projects"]) != 0 {
502+
t.Errorf("expected empty projects, got %v", resp["projects"])
503+
}
504+
505+
// Add observations with different types and projects
506+
doRequest(t, srv, "POST", "/api/observations", map[string]string{
507+
"session_id": "s1", "type": "discovery", "title": "a",
508+
"text": "a", "project": "backend",
509+
})
510+
doRequest(t, srv, "POST", "/api/observations", map[string]string{
511+
"session_id": "s1", "type": "bugfix", "title": "b",
512+
"text": "b", "project": "frontend",
513+
})
514+
doRequest(t, srv, "POST", "/api/observations", map[string]string{
515+
"session_id": "s1", "type": "discovery", "title": "c",
516+
"text": "c", "project": "backend",
517+
})
518+
519+
rr = doRequest(t, srv, "GET", "/api/observations/filters", nil)
520+
if rr.Code != http.StatusOK {
521+
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
522+
}
523+
json.NewDecoder(rr.Body).Decode(&resp)
524+
if len(resp["types"]) != 2 {
525+
t.Errorf("got %d types, want 2: %v", len(resp["types"]), resp["types"])
526+
}
527+
if len(resp["projects"]) != 2 {
528+
t.Errorf("got %d projects, want 2: %v", len(resp["projects"]), resp["projects"])
529+
}
530+
}
531+
488532
func TestTimeline(t *testing.T) {
489533
srv := testServer(t)
490534

internal/db/database_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,59 @@ func TestPromptCRUD(t *testing.T) {
462462
}
463463
}
464464

465+
func TestDistinctTypes(t *testing.T) {
466+
db := testDB(t)
467+
468+
db.InsertObservation(&Observation{SessionID: "s1", Type: "discovery", Title: "a", Text: "a"})
469+
db.InsertObservation(&Observation{SessionID: "s1", Type: "bugfix", Title: "b", Text: "b"})
470+
db.InsertObservation(&Observation{SessionID: "s1", Type: "discovery", Title: "c", Text: "c"})
471+
db.InsertObservation(&Observation{SessionID: "s1", Type: "", Title: "d", Text: "d"})
472+
473+
types, err := db.DistinctTypes()
474+
if err != nil {
475+
t.Fatalf("DistinctTypes: %v", err)
476+
}
477+
if len(types) != 2 {
478+
t.Fatalf("got %d types, want 2", len(types))
479+
}
480+
// Should be sorted: bugfix, discovery
481+
if types[0] != "bugfix" || types[1] != "discovery" {
482+
t.Errorf("types = %v, want [bugfix discovery]", types)
483+
}
484+
}
485+
486+
func TestDistinctProjects(t *testing.T) {
487+
db := testDB(t)
488+
489+
db.InsertObservation(&Observation{SessionID: "s1", Title: "a", Text: "a", Project: "backend"})
490+
db.InsertObservation(&Observation{SessionID: "s1", Title: "b", Text: "b", Project: "frontend"})
491+
db.InsertObservation(&Observation{SessionID: "s1", Title: "c", Text: "c", Project: "backend"})
492+
db.InsertObservation(&Observation{SessionID: "s1", Title: "d", Text: "d", Project: ""})
493+
494+
projects, err := db.DistinctProjects()
495+
if err != nil {
496+
t.Fatalf("DistinctProjects: %v", err)
497+
}
498+
if len(projects) != 2 {
499+
t.Fatalf("got %d projects, want 2", len(projects))
500+
}
501+
if projects[0] != "backend" || projects[1] != "frontend" {
502+
t.Errorf("projects = %v, want [backend frontend]", projects)
503+
}
504+
}
505+
506+
func TestDistinctTypesEmpty(t *testing.T) {
507+
db := testDB(t)
508+
509+
types, err := db.DistinctTypes()
510+
if err != nil {
511+
t.Fatalf("DistinctTypes: %v", err)
512+
}
513+
if types != nil {
514+
t.Errorf("expected nil for empty db, got %v", types)
515+
}
516+
}
517+
465518
func TestTimelineAround(t *testing.T) {
466519
db := testDB(t)
467520

internal/db/observations.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,44 @@ func (db *DB) FilteredSearch(f SearchFilter) ([]*Observation, error) {
218218
return results, rows.Err()
219219
}
220220

221+
// DistinctTypes returns all distinct observation types.
222+
func (db *DB) DistinctTypes() ([]string, error) {
223+
rows, err := db.conn.Query(`SELECT DISTINCT type FROM observations WHERE type != '' ORDER BY type`)
224+
if err != nil {
225+
return nil, fmt.Errorf("distinct types: %w", err)
226+
}
227+
defer rows.Close()
228+
229+
var types []string
230+
for rows.Next() {
231+
var t string
232+
if err := rows.Scan(&t); err != nil {
233+
return nil, fmt.Errorf("scan type: %w", err)
234+
}
235+
types = append(types, t)
236+
}
237+
return types, rows.Err()
238+
}
239+
240+
// DistinctProjects returns all distinct project names.
241+
func (db *DB) DistinctProjects() ([]string, error) {
242+
rows, err := db.conn.Query(`SELECT DISTINCT project FROM observations WHERE project != '' ORDER BY project`)
243+
if err != nil {
244+
return nil, fmt.Errorf("distinct projects: %w", err)
245+
}
246+
defer rows.Close()
247+
248+
var projects []string
249+
for rows.Next() {
250+
var p string
251+
if err := rows.Scan(&p); err != nil {
252+
return nil, fmt.Errorf("scan project: %w", err)
253+
}
254+
projects = append(projects, p)
255+
}
256+
return projects, rows.Err()
257+
}
258+
221259
// TimelineAround returns observations around a given observation ID.
222260
func (db *DB) TimelineAround(anchorID int64, before, after int) ([]*Observation, error) {
223261
if before <= 0 {

0 commit comments

Comments
 (0)