Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

## [1.1.0] - 2026-07-11

### Added

- **Cross-round comparison** (#29) — a subject can now see their competency
scores from peers trending across their shared rounds: a multi-series line
chart (server-rendered SVG), a round-by-round table, and a summary timeline.
Linked from **My Feedback** once two or more rounds have been shared.

### Changed

- **Pagination** (#33) — the Rounds (admin), Audit log, and Users lists are now
paginated (25/page) with Prev/Next controls. Paged queries carry a unique
`id` tiebreaker so page boundaries are deterministic even when timestamps
collide.

## [1.0.0] - 2026-07-11

First tagged release. Delivers the app as a single server-rendered Go binary
Expand Down
31 changes: 21 additions & 10 deletions internal/handlers/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,38 @@ package handlers

import "net/http"

// AuditLogs renders the admin audit trail, optionally filtered by action prefix.
// AuditLogs renders the admin audit trail. The unfiltered view is paginated;
// when an action filter is set, it filters over the most recent 200 entries
// (filtered views are narrow, so a cap is fine there).
func (h *Handlers) AuditLogs(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
logs, err := h.Repos.Audit.FindAll(ctx, 200)
if err != nil {
serverError(w, err)
return
}

action := r.URL.Query().Get("action")

data := map[string]any{"Filter": action}
if action != "" {
logs, err := h.Repos.Audit.FindAll(ctx, 200)
if err != nil {
serverError(w, err)
return
}
filtered := logs[:0]
for _, l := range logs {
if hasPrefix(string(l.Action), action) {
filtered = append(filtered, l)
}
}
logs = filtered
data["Logs"] = filtered
} else {
page := pageParam(r)
rows, err := h.Repos.Audit.FindPaged(ctx, pageSize+1, (page-1)*pageSize)
if err != nil {
serverError(w, err)
return
}
logs, hasNext := paginate(rows)
data["Logs"] = logs
data["Nav"] = buildPageNav(r, page, hasNext)
}

data := map[string]any{"Logs": logs, "Filter": action}
h.View.Page(w, http.StatusOK, h.page(r, "Audit log", "audit", "audit_content", data))
}

Expand Down
27 changes: 27 additions & 0 deletions internal/handlers/handlers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package handlers_test

import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
Expand Down Expand Up @@ -160,6 +161,32 @@ func TestRoundOwnerSeesRawSubmissionsButReviewerDoesNot(t *testing.T) {
}
}

func TestUsersListPaginates(t *testing.T) {
srv, client, repos := newTestServer(t)
ctx := t.Context()
_, _ = get(t, client, srv.URL+"/auth/dev-login?email=admin@example.com")

// Create enough users to span two pages (pageSize is 25).
for i := 0; i < 30; i++ {
_ = repos.Users.Create(ctx, &models.User{Email: fmt.Sprintf("u%02d@example.com", i), Name: fmt.Sprintf("User %02d", i)})
}

// Page 1 offers a Next link; no Prev.
_, body := get(t, client, srv.URL+"/users")
if !strings.Contains(body, "page=2") {
t.Fatal("page 1 should link to page 2 (Next)")
}
if strings.Contains(body, "page=0") {
t.Fatal("page 1 should not offer a Prev link")
}

// Page 2 offers a Prev link back to page 1.
_, body = get(t, client, srv.URL+"/users?page=2")
if !strings.Contains(body, "page=1") {
t.Fatal("page 2 should link back to page 1 (Prev)")
}
}

func TestOnboardingShownUntilCompleted(t *testing.T) {
srv, client, repos := newTestServer(t)
ctx := t.Context()
Expand Down
51 changes: 51 additions & 0 deletions internal/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"html"
"net/http"
"strconv"
"strings"
"time"

Expand All @@ -14,6 +15,56 @@ import (
// (e.g. SSE event payloads that don't go through html/template).
func htmlEscape(s string) string { return html.EscapeString(s) }

// pageSize is the number of rows per page on paginated lists.
const pageSize = 25

// pageNav is the view-model for the pagination controls.
type pageNav struct {
Page int
HasPrev bool
HasNext bool
PrevURL string
NextURL string
}

// pageParam reads the 1-based ?page query parameter (min 1).
func pageParam(r *http.Request) int {
p, _ := strconv.Atoi(r.URL.Query().Get("page"))
if p < 1 {
return 1
}
return p
}

// buildPageNav builds Prev/Next controls, preserving any other query params
// (e.g. the audit action filter). hasNext is determined by fetching pageSize+1
// rows and checking for the extra one.
func buildPageNav(r *http.Request, page int, hasNext bool) pageNav {
nav := pageNav{Page: page, HasPrev: page > 1, HasNext: hasNext}
if nav.HasPrev {
nav.PrevURL = pageURL(r, page-1)
}
if nav.HasNext {
nav.NextURL = pageURL(r, page+1)
}
return nav
}

func pageURL(r *http.Request, page int) string {
q := r.URL.Query()
q.Set("page", strconv.Itoa(page))
return r.URL.Path + "?" + q.Encode()
}

// paginate trims a slice fetched with one extra row (pageSize+1) to the page
// size and reports whether a next page exists.
func paginate[T any](rows []T) (page []T, hasNext bool) {
if len(rows) > pageSize {
return rows[:pageSize], true
}
return rows, false
}

// redirect navigates the browser: htmx requests get an HX-Redirect header (so
// the client does a full navigation), everyone else a 303. The target is forced
// to a same-origin absolute path so it can never become an open redirect.
Expand Down
117 changes: 116 additions & 1 deletion internal/handlers/myfeedback.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package handlers

import (
"fmt"
"net/http"
"sort"
"time"

"github.com/mondial7/smart-360/internal/models"
"github.com/mondial7/smart-360/internal/view"
Expand Down Expand Up @@ -34,7 +37,11 @@ func (h *Handlers) MyFeedback(w http.ResponseWriter, r *http.Request) {
}
}

data := map[string]any{"Consolidations": cons, "Radar": radar}
data := map[string]any{
"Consolidations": cons,
"Radar": radar,
"CanCompare": len(cons) >= 2,
}
h.View.Page(w, http.StatusOK, h.page(r, "My feedback", "my-feedback", "my_feedback_content", data))
}

Expand All @@ -44,3 +51,111 @@ func deltaLen(d *models.SelfVsOthersDelta) int {
}
return len(d.Aligned)
}

// chartPalette cycles line/series colours for the comparison charts.
var chartPalette = []string{"#4f46e5", "#16a34a", "#d97706", "#dc2626", "#2563eb", "#7c3aed", "#0891b2", "#db2777"}

// CompareRounds shows the subject's competency scores trending across their
// shared rounds — the growth-over-time view. Read-only over shared
// consolidations the subject already has access to; no manager-only data.
func (h *Handlers) CompareRounds(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := h.user(r)

cons, err := h.Repos.Consolidations.FindSharedBySubjectID(ctx, u.ID)
if err != nil {
serverError(w, err)
return
}
// Oldest → newest so the trend reads left to right.
sort.Slice(cons, func(i, j int) bool {
return sharedTime(cons[i]).Before(sharedTime(cons[j]))
})

if len(cons) < 2 {
h.View.Page(w, http.StatusOK, h.page(r, "Compare rounds", "my-feedback",
"compare_content", map[string]any{"Enough": false}))
return
}

xLabels := make([]string, len(cons))
for i := range cons {
xLabels[i] = sharedTime(cons[i]).Format("Jan 2006")
}

// Union of competencies in first-seen order; per-round "others average".
type comp struct {
name string
scores []*float64 // one per round
}
order := []string{}
byKey := map[string]*comp{}
for roundIdx := range cons {
for _, cr := range cons[roundIdx].CompetencyRatings {
c, ok := byKey[cr.Key]
if !ok {
c = &comp{name: cr.Name, scores: make([]*float64, len(cons))}
byKey[cr.Key] = c
order = append(order, cr.Key)
}
if cr.OthersAverage != nil {
v := *cr.OthersAverage
c.scores[roundIdx] = &v
}
}
}

var series []view.LineSeries
type tableRow struct {
Name string
Scores []string
}
var rows []tableRow
for i, key := range order {
c := byKey[key]
series = append(series, view.LineSeries{
Label: c.name,
Color: chartPalette[i%len(chartPalette)],
Points: c.scores,
})
cells := make([]string, len(c.scores))
for j, s := range c.scores {
if s == nil {
cells[j] = "—"
} else {
cells[j] = fmt.Sprintf("%.1f", *s)
}
}
rows = append(rows, tableRow{Name: c.name, Scores: cells})
}

type timelineEntry struct {
Date string
Summary string
}
var timeline []timelineEntry
for i := len(cons) - 1; i >= 0; i-- { // newest first for the timeline
timeline = append(timeline, timelineEntry{
Date: sharedTime(cons[i]).Format("Jan 2, 2006"),
Summary: cons[i].ExecutiveSummary,
})
}

data := map[string]any{
"Enough": true,
"XLabels": xLabels,
"Series": series,
"Rows": rows,
"Timeline": timeline,
"Rounds": len(cons),
}
h.View.Page(w, http.StatusOK, h.page(r, "Compare rounds", "my-feedback", "compare_content", data))
}

// sharedTime returns the consolidation's shared timestamp (falls back to created).
func sharedTime(c models.Consolidation) time.Time {
if c.SharedAt != nil {
return *c.SharedAt
}
return c.CreatedAt
}
13 changes: 11 additions & 2 deletions internal/handlers/rounds.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,17 @@ func (h *Handlers) RoundsList(w http.ResponseWriter, r *http.Request) {
}

var rounds []models.FeedbackRound
var nav pageNav // zero value renders no controls (both HasPrev/HasNext false)
if u.Role == models.RoleAdmin {
rounds, err = h.Repos.Rounds.FindAll(ctx)
page := pageParam(r)
paged, perr := h.Repos.Rounds.FindPaged(ctx, pageSize+1, (page-1)*pageSize)
if perr != nil {
serverError(w, perr)
return
}
var hasNext bool
rounds, hasNext = paginate(paged)
nav = buildPageNav(r, page, hasNext)
} else {
rounds, err = h.roundsForMe(ctx, u.ID)
}
Expand All @@ -88,7 +97,7 @@ func (h *Handlers) RoundsList(w http.ResponseWriter, r *http.Request) {
cards = append(cards, h.toCard(ctx, rd, u.ID, users))
}

data := map[string]any{"Cards": cards, "CanCreate": u.Role == models.RoleAdmin || u.Role == models.RoleTeamAdmin}
data := map[string]any{"Cards": cards, "Nav": nav, "CanCreate": u.Role == models.RoleAdmin || u.Role == models.RoleTeamAdmin}
h.View.Page(w, http.StatusOK, h.page(r, "Rounds", "rounds", "rounds_content", data))
}

Expand Down
1 change: 1 addition & 0 deletions internal/handlers/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func (h *Handlers) MountAppRoutes(r chi.Router, submitLimit func(http.Handler) h

// My feedback
r.Get("/my-feedback", h.MyFeedback)
r.Get("/my-feedback/compare", h.CompareRounds)

// Team directory
r.Get("/team", h.TeamDirectory)
Expand Down
6 changes: 4 additions & 2 deletions internal/handlers/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ import (
func (h *Handlers) UsersList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u := h.user(r)
users, err := h.Repos.Users.FindAll(ctx)
page := pageParam(r)
rows, err := h.Repos.Users.FindPaged(ctx, pageSize+1, (page-1)*pageSize)
if err != nil {
serverError(w, err)
return
}
data := map[string]any{"Users": users, "MeID": u.ID}
users, hasNext := paginate(rows)
data := map[string]any{"Users": users, "MeID": u.ID, "Nav": buildPageNav(r, page, hasNext)}
h.View.Page(w, http.StatusOK, h.page(r, "Users", "users", "users_content", data))
}

Expand Down
Loading
Loading