From 949ccaca59a8909dfcface6f6395f1da96fa8a48 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 14 May 2026 14:03:45 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20browse-at-scale=20=E2=80=94=20dat?= =?UTF-8?q?e=20ranges,=20load=20more,=20month=20navigator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI: --month YYYY-MM, --from/--to date range, --limit override API: from/to query params for date range filtering Web: load more button for pagination, month nav (◂/▸) in stream view --- cmd/ls.go | 48 ++++++++++++++++++++++-- internal/api/entities.go | 14 +++++++ internal/db/entities.go | 10 +++++ web/app.js | 79 ++++++++++++++++++++++++++++++++++++++-- web/index.html | 5 ++- web/style.css | 78 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 226 insertions(+), 8 deletions(-) diff --git a/cmd/ls.go b/cmd/ls.go index 2f7de44..d3d76b2 100644 --- a/cmd/ls.go +++ b/cmd/ls.go @@ -11,9 +11,13 @@ import ( ) var ( - lsTag string - lsDate string - lsAll bool + lsTag string + lsDate string + lsMonth string + lsFrom string + lsTo string + lsLimit int + lsAll bool ) var lsCmd = &cobra.Command{ @@ -25,6 +29,10 @@ var lsCmd = &cobra.Command{ func init() { lsCmd.Flags().StringVar(&lsTag, "tag", "", "filter by tag") lsCmd.Flags().StringVar(&lsDate, "date", "", "filter by date (YYYY-MM-DD)") + lsCmd.Flags().StringVar(&lsMonth, "month", "", "filter by month (YYYY-MM)") + lsCmd.Flags().StringVar(&lsFrom, "from", "", "start date (YYYY-MM-DD)") + lsCmd.Flags().StringVar(&lsTo, "to", "", "end date (YYYY-MM-DD)") + lsCmd.Flags().IntVar(&lsLimit, "limit", 0, "max entities to show (default 50)") lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities") } @@ -41,9 +49,41 @@ func runLs(_ *cobra.Command, _ []string) error { if lsTag != "" { p.Tag = &lsTag } + if lsLimit > 0 { + p.Limit = lsLimit + } + + hasDateFilter := false if lsDate != "" { p.Date = &lsDate - } else { + hasDateFilter = true + } + if lsMonth != "" { + t, err := time.Parse("2006-01", lsMonth) + if err != nil { + return fmt.Errorf("bad --month format, use YYYY-MM") + } + from := t.Format("2006-01-02") + to := t.AddDate(0, 1, -1).Format("2006-01-02") + p.From = &from + p.To = &to + hasDateFilter = true + } + if lsFrom != "" { + if _, err := time.Parse("2006-01-02", lsFrom); err != nil { + return fmt.Errorf("bad --from format, use YYYY-MM-DD") + } + p.From = &lsFrom + hasDateFilter = true + } + if lsTo != "" { + if _, err := time.Parse("2006-01-02", lsTo); err != nil { + return fmt.Errorf("bad --to format, use YYYY-MM-DD") + } + p.To = &lsTo + hasDateFilter = true + } + if !hasDateFilter { since := time.Now().UTC().Add(-48 * time.Hour) p.Since = &since } diff --git a/internal/api/entities.go b/internal/api/entities.go index 4f1a6b8..592bb2e 100644 --- a/internal/api/entities.go +++ b/internal/api/entities.go @@ -47,6 +47,20 @@ func listEntities(store *db.Store) http.HandlerFunc { } p.Date = &date } + if from := r.URL.Query().Get("from"); from != "" { + if _, err := time.Parse("2006-01-02", from); err != nil { + writeError(w, http.StatusBadRequest, "invalid_input", "bad from format, use YYYY-MM-DD") + return + } + p.From = &from + } + if to := r.URL.Query().Get("to"); to != "" { + if _, err := time.Parse("2006-01-02", to); err != nil { + writeError(w, http.StatusBadRequest, "invalid_input", "bad to format, use YYYY-MM-DD") + return + } + p.To = &to + } if r.URL.Query().Get("cards_only") == "true" { p.CardsOnly = true } diff --git a/internal/db/entities.go b/internal/db/entities.go index e802a68..fbc180f 100644 --- a/internal/db/entities.go +++ b/internal/db/entities.go @@ -64,6 +64,8 @@ type Entity struct { type ListParams struct { Tag *string Date *string + From *string + To *string Since *time.Time CardsOnly bool IncludeDeleted bool @@ -192,6 +194,14 @@ func (s *Store) List(params ListParams) ([]*Entity, error) { where = append(where, "date(e.created_at) = ?") args = append(args, *params.Date) } + if params.From != nil { + where = append(where, "date(e.created_at) >= ?") + args = append(args, *params.From) + } + if params.To != nil { + where = append(where, "date(e.created_at) <= ?") + args = append(args, *params.To) + } if params.Since != nil { where = append(where, "e.created_at >= ?") args = append(args, params.Since.Format(time.RFC3339)) diff --git a/web/app.js b/web/app.js index a0bb833..2a163c1 100644 --- a/web/app.js +++ b/web/app.js @@ -14,12 +14,16 @@ link: 'glyph-link', }; + const PAGE_SIZE = 50; + const state = { view: 'stream', entities: [], tags: [], selectedIndex: -1, activeTag: null, + hasMore: false, + activeMonth: null, }; const $ = (sel) => document.querySelector(sel); @@ -32,9 +36,13 @@ const q = new URLSearchParams(); if (params.tag) q.set('tag', params.tag); if (params.date) q.set('date', params.date); + if (params.from) q.set('from', params.from); + if (params.to) q.set('to', params.to); if (params.cards_only) q.set('cards_only', 'true'); if (params.sort) q.set('sort', params.sort); if (params.order) q.set('order', params.order); + if (params.limit) q.set('limit', String(params.limit)); + if (params.offset) q.set('offset', String(params.offset)); const resp = await fetch('/api/entities?' + q); return resp.json(); }, @@ -226,6 +234,10 @@ }); } + if (state.hasMore) { + html += '
'; + } + list.innerHTML = html; list.querySelectorAll('.entity-item').forEach(el => { @@ -233,6 +245,9 @@ selectEntity(parseInt(el.dataset.index)); }); }); + + const loadMoreBtn = list.querySelector('.load-more-btn'); + if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore); } function renderEntityItem(e, idx) { @@ -388,8 +403,8 @@ renderDetailPane(); } - async function loadEntities() { - const params = {}; + function buildListParams(offset) { + const params = { limit: PAGE_SIZE, offset: offset || 0 }; if (state.activeTag) params.tag = state.activeTag; if (state.view === 'cards') { params.cards_only = true; @@ -399,13 +414,68 @@ params.sort = 'created'; params.order = 'desc'; } + if (state.activeMonth) { + const [y, m] = state.activeMonth.split('-').map(Number); + params.from = state.activeMonth + '-01'; + const last = new Date(y, m, 0).getDate(); + params.to = state.activeMonth + '-' + String(last).padStart(2, '0'); + } + return params; + } - state.entities = await api.listEntities(params); + async function loadEntities() { + const params = buildListParams(0); + const results = await api.listEntities(params); + state.entities = results; + state.hasMore = results.length === PAGE_SIZE; state.selectedIndex = -1; renderEntityList(); renderDetailPane(); } + async function loadMore() { + const params = buildListParams(state.entities.length); + const results = await api.listEntities(params); + state.entities = state.entities.concat(results); + state.hasMore = results.length === PAGE_SIZE; + renderEntityList(); + } + + function renderMonthNav() { + const nav = $('#month-nav'); + if (state.view !== 'stream') { nav.innerHTML = ''; return; } + + const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; + const label = state.activeMonth + ? (() => { const [y, m] = state.activeMonth.split('-'); return MONTHS[parseInt(m) - 1] + ' ' + y; })() + : 'all time'; + + nav.innerHTML = ` + + ${label} + + ${state.activeMonth ? '' : ''} + `; + + $('#month-prev').addEventListener('click', () => shiftMonth(-1)); + $('#month-next').addEventListener('click', () => shiftMonth(1)); + const clearBtn = nav.querySelector('.month-nav-clear'); + if (clearBtn) clearBtn.addEventListener('click', () => { state.activeMonth = null; loadEntities(); renderMonthNav(); }); + } + + function shiftMonth(dir) { + if (!state.activeMonth) { + const now = new Date(); + state.activeMonth = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0'); + } else { + const [y, m] = state.activeMonth.split('-').map(Number); + const d = new Date(y, m - 1 + dir, 1); + state.activeMonth = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); + } + loadEntities(); + renderMonthNav(); + } + async function loadTags() { state.tags = await api.listTags(); renderTagRail(); @@ -413,9 +483,11 @@ function switchView(view) { state.view = view; + state.activeMonth = null; $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); window.location.hash = view === 'cards' ? '/cards' : '/'; loadEntities(); + renderMonthNav(); } // ========== Public API (for inline handlers) ========== @@ -687,6 +759,7 @@ async function init() { await Promise.all([loadEntities(), loadTags()]); handleHash(); + renderMonthNav(); } init(); diff --git a/web/index.html b/web/index.html index 5f04747..88398b3 100644 --- a/web/index.html +++ b/web/index.html @@ -22,7 +22,10 @@
-
+
+
+
+
diff --git a/web/style.css b/web/style.css index 3d9e5cb..e74432e 100644 --- a/web/style.css +++ b/web/style.css @@ -153,10 +153,65 @@ main { text-align: right; } +/* Entity panel */ +#entity-panel { + display: flex; + flex-direction: column; + overflow: hidden; +} + +#month-nav { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +#month-nav:empty { + display: none; +} + +.month-nav-btn { + background: none; + border: none; + color: var(--text-dim); + font-family: var(--font-mono); + font-size: 13px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.1s; +} + +.month-nav-btn:hover { color: var(--text); background: var(--bg-hover); } + +.month-nav-label { + font-family: var(--font-mono); + font-size: 13px; + color: var(--text); + min-width: 80px; + text-align: center; +} + +.month-nav-clear { + background: none; + border: none; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; + cursor: pointer; + margin-left: auto; +} + +.month-nav-clear:hover { color: var(--text); } + /* Entity list */ #entity-list { overflow-y: auto; padding: 8px 0; + flex: 1; } .date-header { @@ -483,6 +538,29 @@ main { font-family: var(--font-mono); } +/* Load more */ +.load-more-wrap { + padding: 12px 20px; + text-align: center; +} + +.load-more-btn { + background: var(--bg-hover); + border: 1px solid var(--border); + color: var(--text-dim); + padding: 6px 24px; + border-radius: var(--radius); + cursor: pointer; + font-family: var(--font-mono); + font-size: 12px; + transition: all 0.15s; +} + +.load-more-btn:hover { + border-color: var(--accent); + color: var(--accent); +} + /* Absorb modal */ .absorb-list { max-height: 300px; -- 2.52.0