From 8bfa9b15ed58d0271778e9c9ec3c1e706677ec2d Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sat, 16 May 2026 17:51:04 -0400 Subject: [PATCH] fix(ui): tag counts, j/k nav, stream layout, search alignment - Tag rail counts now reflect cards-only when in cards view (ListTags accepts cardsOnly filter, JS passes it per view) - j/k navigation scoped to visible (intent/search filtered) list - scrollSelectedIntoView works in both stream and cards view - Entity items wrap title/desc/preview in .entity-content flex container so tags/pills align right consistently - Title no longer eaten by description/body (flex-shrink + min-width) - Search bar centered in header with margin auto - switchView awaits loadEntities+loadTags to fix stale intent counts --- internal/api/tags.go | 3 ++- internal/db/tags.go | 8 ++++++-- internal/db/tags_test.go | 40 ++++++++++++++++++++++++++++++++++--- web/app.js | 43 +++++++++++++++++++++++++++++----------- web/style.css | 16 +++++++++++++++ 5 files changed, 92 insertions(+), 18 deletions(-) diff --git a/internal/api/tags.go b/internal/api/tags.go index 79607ed..5a51d23 100644 --- a/internal/api/tags.go +++ b/internal/api/tags.go @@ -13,7 +13,8 @@ type TagResponse struct { func listTags(store *db.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - tags, err := store.ListTags() + cardsOnly := r.URL.Query().Get("cards_only") == "true" + tags, err := store.ListTags(cardsOnly) if err != nil { writeInternalError(w, err) return diff --git a/internal/db/tags.go b/internal/db/tags.go index 52b8f56..bcff174 100644 --- a/internal/db/tags.go +++ b/internal/db/tags.go @@ -5,12 +5,16 @@ type TagCount struct { Count int } -func (s *Store) ListTags() ([]TagCount, error) { +func (s *Store) ListTags(cardsOnly bool) ([]TagCount, error) { + where := "WHERE e.deleted_at IS NULL" + if cardsOnly { + where += " AND e.card_type IS NOT NULL" + } rows, err := s.db.Query(` SELECT t.tag, COUNT(*) as cnt FROM entity_tags t JOIN entities e ON t.entity_id = e.id - WHERE e.deleted_at IS NULL + ` + where + ` GROUP BY t.tag ORDER BY t.tag`) if err != nil { diff --git a/internal/db/tags_test.go b/internal/db/tags_test.go index 9cd3dfe..d28d0aa 100644 --- a/internal/db/tags_test.go +++ b/internal/db/tags_test.go @@ -4,7 +4,7 @@ import "testing" func TestListTags_Empty(t *testing.T) { s := testStore(t) - tags, err := s.ListTags() + tags, err := s.ListTags(false) if err != nil { t.Fatal(err) } @@ -19,7 +19,7 @@ func TestListTags_Counts(t *testing.T) { s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}}) s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}}) - tags, err := s.ListTags() + tags, err := s.ListTags(false) if err != nil { t.Fatal(err) } @@ -50,7 +50,7 @@ func TestListTags_ExcludesDeleted(t *testing.T) { s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}}) - tags, err := s.ListTags() + tags, err := s.ListTags(false) if err != nil { t.Fatal(err) } @@ -61,3 +61,37 @@ func TestListTags_ExcludesDeleted(t *testing.T) { t.Errorf("expected 'here', got %q", tags[0].Tag) } } + +func TestListTags_CardsOnly(t *testing.T) { + s := testStore(t) + s.Create(&Entity{Body: "fluid", Glyph: GlyphNote, Tags: []string{"ops", "shared"}}) + + ct := CardSnippet + s.Create(&Entity{Body: "card", Glyph: GlyphNote, Tags: []string{"ops", "code"}, CardType: &ct}) + + all, err := s.ListTags(false) + if err != nil { + t.Fatal(err) + } + if len(all) != 3 { + t.Fatalf("all tags: expected 3, got %d", len(all)) + } + + cards, err := s.ListTags(true) + if err != nil { + t.Fatal(err) + } + if len(cards) != 2 { + t.Fatalf("card tags: expected 2, got %d", len(cards)) + } + counts := map[string]int{} + for _, tc := range cards { + counts[tc.Tag] = tc.Count + } + if counts["ops"] != 1 { + t.Errorf("ops count: expected 1 (card only), got %d", counts["ops"]) + } + if counts["code"] != 1 { + t.Errorf("code count: expected 1, got %d", counts["code"]) + } +} diff --git a/web/app.js b/web/app.js index 89acec0..a20149c 100644 --- a/web/app.js +++ b/web/app.js @@ -110,8 +110,10 @@ }); return resp.json(); }, - async listTags() { - const resp = await fetch('/api/tags'); + async listTags(params = {}) { + const q = new URLSearchParams(); + if (params.cards_only) q.set('cards_only', 'true'); + const resp = await fetch('/api/tags?' + q); return resp.json(); }, }; @@ -653,9 +655,9 @@ const descSnip = e.description ? `${escHtml(e.description)}` : ''; if (e.title) { const preview = e.body ? `${escHtml(e.body)}` : ''; - label = `${escHtml(e.title)}${descSnip}${preview}`; + label = `${escHtml(e.title)}${descSnip}${preview}`; } else { - label = `${escHtml(e.body)}${descSnip}`; + label = `${escHtml(e.body)}${descSnip}`; } return `
@@ -1124,6 +1126,7 @@ state.selectedIndex = -1; renderEntityList(); renderDetailPane(); + renderTagRail(); } async function loadMore() { @@ -1168,7 +1171,9 @@ } async function loadTags() { - state.tags = await api.listTags(); + const params = {}; + if (state.view === 'cards') params.cards_only = true; + state.tags = await api.listTags(params); renderTagRail(); } @@ -1178,10 +1183,10 @@ state.selectedIndex = -1; $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); window.location.hash = view === 'cards' ? '/cards' : '/'; - loadEntities(); renderMonthNav(); - renderTagRail(); renderCaptureBar(); + loadEntities(); + loadTags(); } // ========== Toast ========== @@ -1430,16 +1435,30 @@ const sel = state.entities[state.selectedIndex]; switch (ev.key) { - case 'j': + case 'j': { ev.preventDefault(); - selectEntity(Math.min(state.selectedIndex + 1, state.entities.length - 1)); + const visible = filterBySearch(state.entities); + const sel = state.entities[state.selectedIndex]; + const curPos = sel ? visible.indexOf(sel) : -1; + const nextPos = Math.min(curPos + 1, visible.length - 1); + if (visible.length > 0 && nextPos >= 0) { + selectEntity(state.entities.indexOf(visible[nextPos])); + } scrollSelectedIntoView(); break; - case 'k': + } + case 'k': { ev.preventDefault(); - selectEntity(Math.max(state.selectedIndex - 1, 0)); + const visible = filterBySearch(state.entities); + const sel = state.entities[state.selectedIndex]; + const curPos = sel ? visible.indexOf(sel) : -1; + const prevPos = Math.max(curPos - 1, 0); + if (visible.length > 0) { + selectEntity(state.entities.indexOf(visible[prevPos])); + } scrollSelectedIntoView(); break; + } case 'n': ev.preventDefault(); $('#capture-input').focus(); @@ -1485,7 +1504,7 @@ }); function scrollSelectedIntoView() { - const el = $(`.entity-item[data-index="${state.selectedIndex}"]`); + const el = $(`.entity-item[data-index="${state.selectedIndex}"], .card-row[data-index="${state.selectedIndex}"]`); if (el) el.scrollIntoView({ block: 'nearest' }); } diff --git a/web/style.css b/web/style.css index 829115c..43cf33d 100644 --- a/web/style.css +++ b/web/style.css @@ -117,6 +117,7 @@ nav { display: flex; gap: 2px; } .header-search { flex: 1; max-width: 400px; + margin: 0 auto; } #search-input { @@ -363,6 +364,14 @@ main { .glyph-decision { color: var(--note); } .glyph-link { color: var(--event); } +.entity-content { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + overflow: hidden; +} + .entity-title { font-family: var(--sans); font-size: 12px; @@ -370,6 +379,8 @@ main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex-shrink: 1; + min-width: 60px; } .entity-desc { @@ -381,6 +392,8 @@ main { overflow: hidden; text-overflow: ellipsis; margin-left: 8px; + flex-shrink: 1; + min-width: 0; } .entity-preview { @@ -391,10 +404,13 @@ main { overflow: hidden; text-overflow: ellipsis; margin-left: 8px; + flex: 1; + min-width: 0; } .entity-body { flex: 1; + min-width: 0; font-family: var(--mono); font-size: 12px; white-space: nowrap; -- 2.52.0