feat(phase 3e dashboard): cross-project /dashboard with tasks, issues, recent docs

- store.RecentDocuments(since, limit) returns dated item_links + parent item
- web/dashboard.go handler aggregates VTODOs + Gitea issues + dated links
  across every linked item, fanout via 4-worker goroutine pool, 60s TTL
  cache keyed by encoded TreeFilter
- Tasks card: bucketed Overdue/Today/Tomorrow/Week/NoDue, sort by bucket
  then due asc; ✓ button completes via existing PutTodo path + busts cache
- Issues card: read-only, reuses GiteaDeps.Cache
- Recent docs card: last-30d event_date links, canonical PER rendered
- Filter chips on top reuse tree_filter URL params (tag/mgmt/has)
- nav adds "dashboard" link; design.md §"Dashboard" documents the surface
- 4 integration tests (empty render, dated-link surfacing, tag filter,
  cache hit)
This commit is contained in:
mAi
2026-05-15 18:59:52 +02:00
parent e6eb165525
commit f3e5adf358
9 changed files with 923 additions and 8 deletions

View File

@@ -450,6 +450,61 @@ func (s *Store) AddLinkDated(ctx context.Context, itemID, refType, refID, rel st
return &l, nil
}
// RecentDocuments returns every dated item_link across the whole schema with
// event_date in [since, now], newest-first. Used by the /dashboard "Recent
// documents" card. Soft-deleted items are excluded — 0013's cascade trigger
// removes their links, so the join against projax.items is technically
// redundant but kept defensively to match the items_unified guarantee.
func (s *Store) RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error) {
if limit <= 0 {
limit = 30
}
rows, err := s.Pool.Query(ctx, `
select l.id, l.item_id, l.ref_type, l.ref_id, l.rel, l.note, l.metadata, l.created_at, l.event_date,
i.slug, i.title, i.paths
from projax.item_links l
join projax.items i on i.id = l.item_id and i.deleted_at is null
where l.event_date is not null
and l.event_date >= $1
order by l.event_date desc, l.created_at desc
limit $2`, since, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []*ItemLinkWithItem{}
for rows.Next() {
var x ItemLinkWithItem
if err := rows.Scan(
&x.Link.ID, &x.Link.ItemID, &x.Link.RefType, &x.Link.RefID, &x.Link.Rel,
&x.Link.Note, &x.Link.Metadata, &x.Link.CreatedAt, &x.Link.EventDate,
&x.ItemSlug, &x.ItemTitle, &x.ItemPaths,
); err != nil {
return nil, err
}
out = append(out, &x)
}
return out, rows.Err()
}
// ItemLinkWithItem bundles an item_link with a thin slice of its parent
// item's fields — enough for the dashboard "Recent documents" row to render
// without a second store hop.
type ItemLinkWithItem struct {
Link ItemLink
ItemSlug string
ItemTitle string
ItemPaths []string
}
// PrimaryPath returns the first path of the bundled item, mirroring Item.PrimaryPath.
func (x *ItemLinkWithItem) PrimaryPath() string {
if len(x.ItemPaths) == 0 {
return ""
}
return x.ItemPaths[0]
}
// DatedLinks returns every item_link with an event_date set, ordered
// newest-first then by insertion order. Used by the detail-page Documents
// section.