Commit 09b21346c2

09b21346c213327be0428c8cb90322866378a975

parent: 9807f90f60

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-19 16:07 UTC

web: the dashboard is three columns

Pinned repositories with counts, a tile per queue, the feed as an aside.

Ref #226
e2e/dashboard_test.go +13 −14
@@ -93,8 +93,8 @@ func TestDashboard(t *testing.T) {
9393 t.Fatalf("dashboard: %d", status)
9494 }
9595 for _, want := range []string{
96 `aria-label="Pinned repositories"`, `</span>app</a>`, // the dashboard lists the pinned repo
97 "Waiting on your review", "Assigned to you", "Recent activity",
96 `aria-label="Pinned repositories"`, `class="pins"`, `</span>app</a>`, // the dashboard lists the pinned repo
97 "Waiting on your review", "assigned to you", "Recent activity",
9898 "from bob", "alice/app!1", "todo one", "alice/app#1",
9999 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
100100 "Yours anywhere, and every one in a repository you can write to.",
@@ -104,17 +104,16 @@ func TestDashboard(t *testing.T) {
104104 }
105105 }
106106
107 // D01: alice has nothing assigned to her, so that section condenses to
108 // an empty heading, and populated sections (she has a review waiting)
109 // sort before it.
110 emptyHeading := `<h2 class="empty">Assigned to you <span class="count">0</span></h2>`
111 if !strings.Contains(body, emptyHeading) {
112 t.Fatalf("dashboard missing condensed empty heading %q:\n%s", emptyHeading, body)
113 }
114 reviewIdx := strings.Index(body, "Waiting on your review")
115 assignedIdx := strings.Index(body, emptyHeading)
116 if reviewIdx < 0 || assignedIdx < 0 || reviewIdx > assignedIdx {
117 t.Fatalf("populated heading should come before the empty one: review=%d assigned=%d", reviewIdx, assignedIdx)
107 // The empty queue is a tile with a zero; a populated one is a tile
108 // that links to its rows, which render in the middle column.
109 if !strings.Contains(body, `<div class="tile"><b>0</b><span>assigned to you</span></div>`) {
110 t.Fatalf("dashboard missing the zero tile for assigned:\n%s", body)
111 }
112 if !strings.Contains(body, `<a class="tile wants" href="#reviews"><b>1</b><span>waiting on your review</span></a>`) {
113 t.Fatalf("dashboard missing the review tile:\n%s", body)
114 }
115 if !strings.Contains(body, `<h2 id="reviews">Waiting on your review`) || strings.Contains(body, `<h2 class="empty">`) {
116 t.Fatalf("queues do not render as tiles plus rows:\n%s", body)
118117 }
119118
120119 // The diff has its own view rather than a fold at the foot of the
@@ -352,7 +351,7 @@ func TestDashboardQueues(t *testing.T) {
352351 t.Fatalf("review: %s", errOut)
353352 }
354353 _, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
355 if !strings.Contains(after, `<h2 class="empty">Waiting on your review <span class="count">0</span></h2>`) {
354 if !strings.Contains(after, `<div class="tile"><b>0</b><span>waiting on your review</span></div>`) {
356355 t.Fatalf("reviewed MR still waiting:\n%s", after)
357356 }
358357}
internal/httpd/dashpins.go added +38
@@ -0,0 +1,38 @@
1package httpd
2
3import (
4 "gitbay.org/gitbay/internal/policy"
5 "gitbay.org/gitbay/internal/store"
6)
7
8// pinnedRow is one pinned repository on the dashboard with the counts
9// that say whether it wants attention: open issues, open merge requests
10// and the newest build's status ("" when it has none).
11type pinnedRow struct {
12 Owner string
13 Name string
14 Issues int
15 MRs int
16 Build string
17}
18
19// pinnedRows reads the viewer's pinned repositories the way railFor does,
20// then adds the counts. Three reads per pinned repository, on the
21// dashboard only.
22func (s *Server) pinnedRows(viewer store.User) []pinnedRow {
23 pinned, _ := s.st.PinnedRepos(viewer.ID)
24 var rows []pinnedRow
25 for _, rp := range pinned {
26 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
27 if !policy.CanRead(viewer, rp, grant) {
28 continue
29 }
30 row := pinnedRow{Owner: rp.OwnerName, Name: rp.Name}
31 row.Issues, row.MRs = s.st.OpenCounts(rp.ID)
32 if builds, err := s.st.ListBuilds(rp.ID, store.BuildFilter{}, 1); err == nil && len(builds) > 0 {
33 row.Build = builds[0].Status
34 }
35 rows = append(rows, row)
36 }
37 return rows
38}
internal/httpd/dashpins_test.go added +52
@@ -0,0 +1,52 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/store"
8 "gitbay.org/gitbay/internal/web"
9)
10
11// The dashboard is three columns: pinned repositories with counts, the
12// tile strip and queue rows, the activity feed. Tiles carry every queue's
13// count; only a non-empty queue lists rows (desktop layout spec).
14func TestDashboardTilesAndPins(t *testing.T) {
15 var sb strings.Builder
16 var base basePage
17 base.Viewer = "alice"
18 err := web.Render(&sb, "dashboard.html", struct {
19 basePage
20 Tab string
21 Pins []pinnedRow
22 Reviews []store.DashboardItem
23 Assigned []store.DashboardItem
24 MRs []store.DashboardItem
25 Issues []store.DashboardItem
26 Feed []feedLine
27 }{base, "dashboard", []pinnedRow{{Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}}, nil, nil, nil,
28 []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil})
29 if err != nil {
30 t.Fatal(err)
31 }
32 out := sb.String()
33 for _, want := range []string{
34 `<div class="dashgrid">`,
35 `<aside class="dashpins" aria-label="Pinned repositories">`,
36 `<span class="owner">krz/</span>gitbay</a>`,
37 `<b class="wants">3</b>`, `<span class="dot ok"></span>`,
38 `<a class="tile wants" href="#issues"><b>1</b><span>open issues</span></a>`,
39 `<div class="tile"><b>0</b><span>waiting on your review</span></div>`,
40 `<div class="tile"><b>0</b><span>assigned to you</span></div>`,
41 `<div class="tile"><b>0</b><span>open merge requests</span></div>`,
42 `<h2 id="issues">Open issues <span class="count">1</span></h2>`,
43 `<aside class="feedcol" aria-label="Recent activity">`,
44 } {
45 if !strings.Contains(out, want) {
46 t.Errorf("dashboard lacks %q", want)
47 }
48 }
49 if strings.Contains(out, `<h2 class="empty">`) {
50 t.Error("an empty queue still renders as a heading; the tile carries it")
51 }
52}
internal/httpd/web.go +2 −1
@@ -191,12 +191,13 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
191191 s.render(w, "dashboard.html", struct {
192192 basePage
193193 Tab string
194 Pins []pinnedRow
194195 Reviews []store.DashboardItem
195196 Assigned []store.DashboardItem
196197 MRs []store.DashboardItem
197198 Issues []store.DashboardItem
198199 Feed []feedLine
199 }{s.baseFor(viewer), "dashboard", reviews, assigned, mrs, issues, feedLines(events)})
200 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)})
200201}
201202
202203func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +54 −8
@@ -1041,9 +1041,47 @@ pre.message {
10411041.blobimage { text-align: center; }
10421042.blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); }
10431043
1044/* pinned repositories, on the dashboard now that the rail is icons only */
1045.pinned { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-4); }
1046.pinned .owner { color: var(--muted); }
1044/* ---- dashboard: pinned column, tiles and queues, feed ---- */
1045.dashgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; }
1046.dashgrid h1 { margin-top: 0; }
1047.dashgrid > aside { position: sticky; top: var(--sp-5); }
1048.dashgrid .dashmain h2 { margin-top: var(--sp-5); }
1049.dashgrid .dashmain .tiles + h2 { margin-top: 0; }
1050.colhead {
1051 margin: 0 0 var(--sp-2);
1052 font-size: var(--fs-0);
1053 font-weight: 500;
1054 letter-spacing: 0.08em;
1055 text-transform: uppercase;
1056 color: var(--muted);
1057}
1058.pins { list-style: none; margin: 0; padding: 0; font-size: var(--fs-2); }
1059.pins li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; border-bottom: 1px solid var(--faint); }
1060.pins li:last-child { border-bottom: 0; }
1061.pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1062.pins a:hover { color: var(--link); }
1063.pins .owner { color: var(--muted); }
1064.pins .n { margin-left: auto; flex: none; display: flex; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; }
1065.pins .n b { font-weight: 600; color: var(--fg); }
1066.pins .n b.wants { color: var(--warn); }
1067.pins .dot { margin: 0; }
1068/* count tiles: the queues' sizes in one strip; a non-zero count is orange,
1069 what wants you */
1070.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin: var(--sp-4) 0 var(--sp-5); }
1071.tile {
1072 display: block;
1073 background: var(--surface);
1074 border: 1px solid var(--line);
1075 border-radius: var(--r-card);
1076 padding: var(--sp-3) var(--sp-4);
1077 color: var(--fg);
1078}
1079a.tile:hover { background: var(--hover); text-decoration: none; }
1080.tile b { display: block; font-size: var(--fs-5); font-weight: 600; line-height: 1.2; }
1081.tile.wants b { color: var(--warn); }
1082.tile span { font-size: var(--fs-1); color: var(--muted); }
1083.feedcol .feedline { font-size: var(--fs-2); margin-bottom: var(--sp-2); }
1084.feedcol .feedline .none { font-size: var(--fs-1); }
10471085
10481086/* ---- two-column: conversation and aside ---- */
10491087.withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; }
@@ -1075,11 +1113,6 @@ form.actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var
10751113form.actions button { flex: 1 1 auto; }
10761114form.actions select, form.actions textarea { width: 100%; }
10771115.feedline { margin-bottom: var(--sp-2); }
1078/* the dashboard's activity feed is a section after the queues, one column */
1079.feed { margin-top: var(--sp-6); max-width: 78ch; }
1080.feed h2 { margin-bottom: var(--sp-3); }
1081.feed .feedline { font-size: var(--fs-2); }
1082.feed .none { color: var(--muted); font-size: var(--fs-1); }
10831116
10841117/* ---- comments and threads ---- */
10851118article.comment {
@@ -1524,6 +1557,11 @@ svg.icon { vertical-align: -0.125em; }
15241557.review { font-size: var(--fs-2); }
15251558
15261559/* ---- breakpoints ---- */
1560@media (max-width: 80rem) {
1561 .dashgrid { grid-template-columns: minmax(0, 1fr) 20rem; }
1562 .dashgrid > .dashpins { grid-column: 1 / -1; position: static; }
1563}
1564
15271565@media (max-width: 62rem) {
15281566 /* one column, the aside after the thread: a phone shows the
15291567 description first, checks and reviewers after it (#232) */
@@ -1532,6 +1570,14 @@ svg.icon { vertical-align: -0.125em; }
15321570 /* rows go back to two lines where one does not fit */
15331571 ul.issuelist.rows .issuemain, ul.repolist.rows li, ul.loglist.rows .commitmain { display: block; }
15341572 ul.issuelist.rows .title, ul.repolist.rows .desc { white-space: normal; }
1573
1574 .dashgrid { grid-template-columns: 1fr; }
1575 .dashgrid > aside { position: static; }
1576 /* the pinned column goes back to a chip row on a phone */
1577 .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
1578 .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
1579 .pins .n, .dashpins .meta { display: none; }
1580 .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
15351581}
15361582
15371583@media (max-width: 52rem) {
internal/web/templates/dashboard.html +30 −14
@@ -12,27 +12,43 @@
1212</ul>
1313{{end}}
1414{{define "queue"}}
15<h2>{{$.Title}} <span class="count">{{len $.Items}}</span></h2>
15<h2 id="{{$.ID}}">{{$.Title}} <span class="count">{{len $.Items}}</span></h2>
1616{{if $.Hint}}<p class="hint">{{$.Hint}}</p>{{end}}
1717{{template "itemlist" dict "Items" $.Items "Kind" $.Kind "Empty" $.Empty}}
1818{{end}}
19{{define "tile"}}{{if $.N}}<a class="tile wants" href="#{{$.ID}}"><b>{{$.N}}</b><span>{{$.Label}}</span></a>{{else}}<div class="tile"><b>0</b><span>{{$.Label}}</span></div>{{end}}{{end}}
1920{{define "content"}}
20<h1>Dashboard</h1>
21{{with .Rail.Pinned}}<p class="pinned" aria-label="Pinned repositories">{{range .}}<a class="chip" href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a> {{end}}</p>{{end}}
21<div class="dashgrid">
2222
23{{if .Reviews}}{{template "queue" dict "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}}
24{{if .Assigned}}{{template "queue" dict "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}}
25{{if .MRs}}{{template "queue" dict "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
26{{if .Issues}}{{template "queue" dict "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
23<aside class="dashpins" aria-label="Pinned repositories">
24 <h2 class="colhead">Pinned</h2>
25 {{if .Pins}}<ul class="pins">
26 {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}">{{if .Issues}}<b class="wants">{{.Issues}}</b>{{else}}<b>0</b>{{end}} {{if .MRs}}<b class="wants">{{.MRs}}</b>{{else}}<b>0</b>{{end}} <span class="dot{{if eq .Build "success"}} ok{{else if eq .Build "failure"}} bad{{else if .Build}} pend{{end}}"></span></span></li>
27 {{end}}</ul>
28 <p class="meta">issues · merge requests · last build</p>
29 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
30</aside>
2731
28{{if not .Reviews}}<h2 class="empty">Waiting on your review <span class="count">0</span></h2>{{end}}
29{{if not .Assigned}}<h2 class="empty">Assigned to you <span class="count">0</span></h2>{{end}}
30{{if not .MRs}}<h2 class="empty">Open merge requests <span class="count">0</span></h2>{{end}}
31{{if not .Issues}}<h2 class="empty">Open issues <span class="count">0</span></h2>{{end}}
32<section class="dashmain">
33<h1>Dashboard</h1>
34<div class="tiles">
35 {{template "tile" dict "N" (len .Issues) "ID" "issues" "Label" "open issues"}}
36 {{template "tile" dict "N" (len .Reviews) "ID" "reviews" "Label" "waiting on your review"}}
37 {{template "tile" dict "N" (len .Assigned) "ID" "assigned" "Label" "assigned to you"}}
38 {{template "tile" dict "N" (len .MRs) "ID" "mrs" "Label" "open merge requests"}}
39</div>
40{{if .Reviews}}{{template "queue" dict "ID" "reviews" "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}}
41{{if .Assigned}}{{template "queue" dict "ID" "assigned" "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}}
42{{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
43{{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
44{{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open anywhere you can write to.</p>{{end}}
45</section>
3246
33<section class="feed">
34 <h2>Recent activity</h2>
47<aside class="feedcol" aria-label="Recent activity">
48 <h2 class="colhead">Recent activity</h2>
3549 {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
3650 {{else}}<p class="none">No activity yet</p>{{end}}
37</section>
51</aside>
52
53</div>
3854{{end}}