Merge pull request 'feat: add browse-at-scale — date ranges, load more, month navigator' (#3) from feat/browse-at-scale into main

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-05-14 18:09:18 +00:00
6 changed files with 226 additions and 8 deletions
+44 -4
View File
@@ -11,9 +11,13 @@ import (
) )
var ( var (
lsTag string lsTag string
lsDate string lsDate string
lsAll bool lsMonth string
lsFrom string
lsTo string
lsLimit int
lsAll bool
) )
var lsCmd = &cobra.Command{ var lsCmd = &cobra.Command{
@@ -25,6 +29,10 @@ var lsCmd = &cobra.Command{
func init() { func init() {
lsCmd.Flags().StringVar(&lsTag, "tag", "", "filter by tag") lsCmd.Flags().StringVar(&lsTag, "tag", "", "filter by tag")
lsCmd.Flags().StringVar(&lsDate, "date", "", "filter by date (YYYY-MM-DD)") 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") lsCmd.Flags().BoolVar(&lsAll, "all", false, "include deleted entities")
} }
@@ -41,9 +49,41 @@ func runLs(_ *cobra.Command, _ []string) error {
if lsTag != "" { if lsTag != "" {
p.Tag = &lsTag p.Tag = &lsTag
} }
if lsLimit > 0 {
p.Limit = lsLimit
}
hasDateFilter := false
if lsDate != "" { if lsDate != "" {
p.Date = &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) since := time.Now().UTC().Add(-48 * time.Hour)
p.Since = &since p.Since = &since
} }
+14
View File
@@ -47,6 +47,20 @@ func listEntities(store *db.Store) http.HandlerFunc {
} }
p.Date = &date 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" { if r.URL.Query().Get("cards_only") == "true" {
p.CardsOnly = true p.CardsOnly = true
} }
+10
View File
@@ -64,6 +64,8 @@ type Entity struct {
type ListParams struct { type ListParams struct {
Tag *string Tag *string
Date *string Date *string
From *string
To *string
Since *time.Time Since *time.Time
CardsOnly bool CardsOnly bool
IncludeDeleted bool IncludeDeleted bool
@@ -192,6 +194,14 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
where = append(where, "date(e.created_at) = ?") where = append(where, "date(e.created_at) = ?")
args = append(args, *params.Date) 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 { if params.Since != nil {
where = append(where, "e.created_at >= ?") where = append(where, "e.created_at >= ?")
args = append(args, params.Since.Format(time.RFC3339)) args = append(args, params.Since.Format(time.RFC3339))
+76 -3
View File
@@ -14,12 +14,16 @@
link: 'glyph-link', link: 'glyph-link',
}; };
const PAGE_SIZE = 50;
const state = { const state = {
view: 'stream', view: 'stream',
entities: [], entities: [],
tags: [], tags: [],
selectedIndex: -1, selectedIndex: -1,
activeTag: null, activeTag: null,
hasMore: false,
activeMonth: null,
}; };
const $ = (sel) => document.querySelector(sel); const $ = (sel) => document.querySelector(sel);
@@ -32,9 +36,13 @@
const q = new URLSearchParams(); const q = new URLSearchParams();
if (params.tag) q.set('tag', params.tag); if (params.tag) q.set('tag', params.tag);
if (params.date) q.set('date', params.date); 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.cards_only) q.set('cards_only', 'true');
if (params.sort) q.set('sort', params.sort); if (params.sort) q.set('sort', params.sort);
if (params.order) q.set('order', params.order); 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); const resp = await fetch('/api/entities?' + q);
return resp.json(); return resp.json();
}, },
@@ -226,6 +234,10 @@
}); });
} }
if (state.hasMore) {
html += '<div class="load-more-wrap"><button class="load-more-btn">load more</button></div>';
}
list.innerHTML = html; list.innerHTML = html;
list.querySelectorAll('.entity-item').forEach(el => { list.querySelectorAll('.entity-item').forEach(el => {
@@ -233,6 +245,9 @@
selectEntity(parseInt(el.dataset.index)); selectEntity(parseInt(el.dataset.index));
}); });
}); });
const loadMoreBtn = list.querySelector('.load-more-btn');
if (loadMoreBtn) loadMoreBtn.addEventListener('click', loadMore);
} }
function renderEntityItem(e, idx) { function renderEntityItem(e, idx) {
@@ -388,8 +403,8 @@
renderDetailPane(); renderDetailPane();
} }
async function loadEntities() { function buildListParams(offset) {
const params = {}; const params = { limit: PAGE_SIZE, offset: offset || 0 };
if (state.activeTag) params.tag = state.activeTag; if (state.activeTag) params.tag = state.activeTag;
if (state.view === 'cards') { if (state.view === 'cards') {
params.cards_only = true; params.cards_only = true;
@@ -399,13 +414,68 @@
params.sort = 'created'; params.sort = 'created';
params.order = 'desc'; 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; state.selectedIndex = -1;
renderEntityList(); renderEntityList();
renderDetailPane(); 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 = `
<button class="month-nav-btn" id="month-prev">◂</button>
<span class="month-nav-label">${label}</span>
<button class="month-nav-btn" id="month-next">▸</button>
${state.activeMonth ? '<button class="month-nav-clear">clear</button>' : ''}
`;
$('#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() { async function loadTags() {
state.tags = await api.listTags(); state.tags = await api.listTags();
renderTagRail(); renderTagRail();
@@ -413,9 +483,11 @@
function switchView(view) { function switchView(view) {
state.view = view; state.view = view;
state.activeMonth = null;
$$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); $$('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
window.location.hash = view === 'cards' ? '/cards' : '/'; window.location.hash = view === 'cards' ? '/cards' : '/';
loadEntities(); loadEntities();
renderMonthNav();
} }
// ========== Public API (for inline handlers) ========== // ========== Public API (for inline handlers) ==========
@@ -687,6 +759,7 @@
async function init() { async function init() {
await Promise.all([loadEntities(), loadTags()]); await Promise.all([loadEntities(), loadTags()]);
handleHash(); handleHash();
renderMonthNav();
} }
init(); init();
+4 -1
View File
@@ -22,7 +22,10 @@
</header> </header>
<main> <main>
<aside id="tag-rail"></aside> <aside id="tag-rail"></aside>
<section id="entity-list"></section> <section id="entity-panel">
<div id="month-nav"></div>
<div id="entity-list"></div>
</section>
<aside id="detail-pane"> <aside id="detail-pane">
<div class="detail-empty">select an entity</div> <div class="detail-empty">select an entity</div>
</aside> </aside>
+78
View File
@@ -153,10 +153,65 @@ main {
text-align: right; 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 */
#entity-list { #entity-list {
overflow-y: auto; overflow-y: auto;
padding: 8px 0; padding: 8px 0;
flex: 1;
} }
.date-header { .date-header {
@@ -483,6 +538,29 @@ main {
font-family: var(--font-mono); 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 modal */
.absorb-list { .absorb-list {
max-height: 300px; max-height: 300px;