diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a418fe..103af43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go index e8d7481..8e1765a 100644 --- a/internal/handlers/audit.go +++ b/internal/handlers/audit.go @@ -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)) } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index eb3d748..4cd46c1 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -1,6 +1,7 @@ package handlers_test import ( + "fmt" "io" "net/http" "net/http/cookiejar" @@ -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() diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 8c0dc29..2e84469 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -4,6 +4,7 @@ import ( "context" "html" "net/http" + "strconv" "strings" "time" @@ -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. diff --git a/internal/handlers/myfeedback.go b/internal/handlers/myfeedback.go index dd106d5..84c948a 100644 --- a/internal/handlers/myfeedback.go +++ b/internal/handlers/myfeedback.go @@ -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" @@ -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)) } @@ -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 +} diff --git a/internal/handlers/rounds.go b/internal/handlers/rounds.go index 9826c2c..1632726 100644 --- a/internal/handlers/rounds.go +++ b/internal/handlers/rounds.go @@ -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) } @@ -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)) } diff --git a/internal/handlers/routes.go b/internal/handlers/routes.go index 6004ea7..15536d3 100644 --- a/internal/handlers/routes.go +++ b/internal/handlers/routes.go @@ -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) diff --git a/internal/handlers/users.go b/internal/handlers/users.go index 2e6f518..5b42431 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -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)) } diff --git a/internal/repo/fakes.go b/internal/repo/fakes.go index b87f154..d79b333 100644 --- a/internal/repo/fakes.go +++ b/internal/repo/fakes.go @@ -154,6 +154,23 @@ func (f *FakeUsers) FindAll(_ context.Context) ([]models.User, error) { return out, nil } +func (f *FakeUsers) FindPaged(ctx context.Context, limit, offset int) ([]models.User, error) { + all, _ := f.FindAll(ctx) + return pageSlice(all, limit, offset), nil +} + +// pageSlice returns up to limit items starting at offset (bounds-safe). +func pageSlice[T any](items []T, limit, offset int) []T { + if offset >= len(items) { + return nil + } + end := offset + limit + if end > len(items) { + end = len(items) + } + return items[offset:end] +} + // ---- Teams ---- type FakeTeams struct { @@ -325,6 +342,13 @@ func (f *FakeRounds) FindAll(_ context.Context) ([]models.FeedbackRound, error) return f.filter(func(models.FeedbackRound) bool { return true }), nil } +func (f *FakeRounds) FindPaged(_ context.Context, limit, offset int) ([]models.FeedbackRound, error) { + all := f.filter(func(models.FeedbackRound) bool { return true }) + // FindAll orders by ID; the pg impl orders by created_at DESC. Fakes only + // need stable, bounded output for handler tests, so ID order is fine here. + return pageSlice(all, limit, offset), nil +} + func (f *FakeRounds) Create(_ context.Context, r *models.FeedbackRound) error { f.mu.Lock() defer f.mu.Unlock() @@ -666,6 +690,12 @@ func (f *FakeAudit) FindAll(_ context.Context, limit int) ([]models.AuditLog, er return out, nil } +func (f *FakeAudit) FindPaged(_ context.Context, limit, offset int) ([]models.AuditLog, error) { + f.mu.Lock() + defer f.mu.Unlock() + return pageSlice(reversedAudit(f.logs), limit, offset), nil +} + func (f *FakeAudit) FindByRoundID(_ context.Context, roundID string) ([]models.AuditLog, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/repo/gateway_pg_test.go b/internal/repo/gateway_pg_test.go index 42db014..2cf978a 100644 --- a/internal/repo/gateway_pg_test.go +++ b/internal/repo/gateway_pg_test.go @@ -212,6 +212,52 @@ func TestConsolidations_JSONBRoundtripAndSharedLookup(t *testing.T) { } } +func TestRounds_PaginationCoversEveryRowOnce(t *testing.T) { + r := gateway(t) + ctx := context.Background() + subjectID := makeUser(t, r, "subject") + + // Insert 5 rounds that all share the same created_at, so ordering by + // created_at alone would be ambiguous — the id tiebreaker must make paging + // deterministic (no skips, no duplicates across page boundaries). + for i := 0; i < 5; i++ { + if _, err := testPool.Exec(ctx, ` + INSERT INTO feedback_rounds (subject_id, created_by_id, status, created_at, updated_at) + VALUES ($1, $1, 'draft', '2026-01-01 00:00:00+00', '2026-01-01 00:00:00+00')`, subjectID); err != nil { + t.Fatalf("insert round %d: %v", i, err) + } + } + + // Page through with a small page size and collect every id. + seen := map[string]int{} + total := 0 + for offset := 0; ; offset += 2 { + page, err := r.Rounds.FindPaged(ctx, 2, offset) + if err != nil { + t.Fatalf("find paged (offset %d): %v", offset, err) + } + if len(page) == 0 { + break + } + for _, rd := range page { + seen[rd.ID]++ + total++ + } + } + + if total != 5 { + t.Fatalf("expected to page over exactly 5 rows, got %d", total) + } + if len(seen) != 5 { + t.Fatalf("expected 5 distinct rows, got %d (a duplicate crossed a page boundary)", len(seen)) + } + for id, n := range seen { + if n != 1 { + t.Fatalf("row %s appeared %d times across pages", id, n) + } + } +} + func TestTemplates_UpsertBySlug(t *testing.T) { r := gateway(t) ctx := context.Background() diff --git a/internal/repo/pg_audit.go b/internal/repo/pg_audit.go index ebf0104..b9a1ba9 100644 --- a/internal/repo/pg_audit.go +++ b/internal/repo/pg_audit.go @@ -38,7 +38,12 @@ func (r *pgAudit) FindAll(ctx context.Context, limit int) ([]models.AuditLog, er limit = 200 } return r.queryAudit(ctx, - `SELECT `+auditColumns+` FROM audit_logs ORDER BY created_at DESC LIMIT $1`, limit) + `SELECT `+auditColumns+` FROM audit_logs ORDER BY created_at DESC, id DESC LIMIT $1`, limit) +} + +func (r *pgAudit) FindPaged(ctx context.Context, limit, offset int) ([]models.AuditLog, error) { + return r.queryAudit(ctx, + `SELECT `+auditColumns+` FROM audit_logs ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2`, limit, offset) } func (r *pgAudit) FindByRoundID(ctx context.Context, roundID string) ([]models.AuditLog, error) { diff --git a/internal/repo/pg_rounds.go b/internal/repo/pg_rounds.go index be87b33..ddbc094 100644 --- a/internal/repo/pg_rounds.go +++ b/internal/repo/pg_rounds.go @@ -81,7 +81,12 @@ func (r *pgRounds) FindByReviewerID(ctx context.Context, reviewerID string) ([]m } func (r *pgRounds) FindAll(ctx context.Context) ([]models.FeedbackRound, error) { - return r.queryRounds(ctx, `SELECT `+roundColumns+` FROM feedback_rounds ORDER BY created_at DESC`) + return r.queryRounds(ctx, `SELECT `+roundColumns+` FROM feedback_rounds ORDER BY created_at DESC, id DESC`) +} + +func (r *pgRounds) FindPaged(ctx context.Context, limit, offset int) ([]models.FeedbackRound, error) { + return r.queryRounds(ctx, + `SELECT `+roundColumns+` FROM feedback_rounds ORDER BY created_at DESC, id DESC LIMIT $1 OFFSET $2`, limit, offset) } func (r *pgRounds) Create(ctx context.Context, round *models.FeedbackRound) error { diff --git a/internal/repo/pg_users.go b/internal/repo/pg_users.go index efc3cea..765e94c 100644 --- a/internal/repo/pg_users.go +++ b/internal/repo/pg_users.go @@ -61,7 +61,16 @@ func (r *pgUsers) SetTeam(ctx context.Context, userID string, teamID *string) er } func (r *pgUsers) FindAll(ctx context.Context) ([]models.User, error) { - rows, err := r.q.Query(ctx, `SELECT `+userColumns+` FROM users ORDER BY name, email`) + return r.queryUsers(ctx, `SELECT `+userColumns+` FROM users ORDER BY name, email`) +} + +func (r *pgUsers) FindPaged(ctx context.Context, limit, offset int) ([]models.User, error) { + return r.queryUsers(ctx, + `SELECT `+userColumns+` FROM users ORDER BY name, email LIMIT $1 OFFSET $2`, limit, offset) +} + +func (r *pgUsers) queryUsers(ctx context.Context, sql string, args ...any) ([]models.User, error) { + rows, err := r.q.Query(ctx, sql, args...) if err != nil { return nil, err } diff --git a/internal/repo/repo.go b/internal/repo/repo.go index b17da5f..1024cf9 100644 --- a/internal/repo/repo.go +++ b/internal/repo/repo.go @@ -18,6 +18,7 @@ type UserRepository interface { MarkOnboarded(ctx context.Context, id string) error SetTeam(ctx context.Context, userID string, teamID *string) error FindAll(ctx context.Context) ([]models.User, error) + FindPaged(ctx context.Context, limit, offset int) ([]models.User, error) } type TeamRepository interface { @@ -40,6 +41,7 @@ type RoundRepository interface { FindByCreatedByID(ctx context.Context, creatorID string) ([]models.FeedbackRound, error) FindByReviewerID(ctx context.Context, reviewerID string) ([]models.FeedbackRound, error) FindAll(ctx context.Context) ([]models.FeedbackRound, error) + FindPaged(ctx context.Context, limit, offset int) ([]models.FeedbackRound, error) AddReviewer(ctx context.Context, roundID string, reviewer models.RoundReviewer) error RemoveReviewer(ctx context.Context, roundID, reviewerID string) error GetReviewers(ctx context.Context, roundID string) ([]models.RoundReviewer, error) @@ -74,6 +76,7 @@ type ConsolidationRepository interface { type AuditRepository interface { Create(ctx context.Context, entry *models.AuditLog) error FindAll(ctx context.Context, limit int) ([]models.AuditLog, error) + FindPaged(ctx context.Context, limit, offset int) ([]models.AuditLog, error) FindByRoundID(ctx context.Context, roundID string) ([]models.AuditLog, error) } diff --git a/internal/view/charts.go b/internal/view/charts.go index 3e992a2..f21c6ed 100644 --- a/internal/view/charts.go +++ b/internal/view/charts.go @@ -166,6 +166,92 @@ func DonutSVG(slices []DonutSlice, size, thickness float64, centerLabel string) return template.HTML(b.String()) // #nosec G203 -- markup built from escaped labels + numeric geometry } +// LineSeries is one line on a LineSVG chart. Points aligns with the chart's +// x labels; a nil point is a gap (that round had no value for this series). +type LineSeries struct { + Label string + Color string + Points []*float64 +} + +// LineSVG renders a multi-series line chart (e.g. competency scores over rounds) +// as SVG. yMin/yMax fix the value axis (e.g. 1..5); x positions are evenly +// spread across the labels. It is a pure server-side generator — no JS. +func LineSVG(xLabels []string, series []LineSeries, width, height, yMin, yMax float64) template.HTML { + if len(xLabels) == 0 || yMax <= yMin { + return "" + } + if width <= 0 { + width = 640 + } + if height <= 0 { + height = 260 + } + const padL, padR, padT, padB = 34.0, 14.0, 14.0, 30.0 + plotW := width - padL - padR + plotH := height - padT - padB + n := len(xLabels) + + xAt := func(i int) float64 { + if n == 1 { + return padL + plotW/2 + } + return padL + plotW*float64(i)/float64(n-1) + } + yAt := func(v float64) float64 { + return padT + (1-(v-yMin)/(yMax-yMin))*plotH + } + + var b strings.Builder + fmt.Fprintf(&b, ``, + num(width), num(height), num(width), num(height)) + + // Horizontal gridlines + y labels at each integer step. + b.WriteString(``) + for v := yMin; v <= yMax+0.001; v++ { + y := yAt(v) + fmt.Fprintf(&b, ``, + num(padL), num(y), num(width-padR), num(y)) + fmt.Fprintf(&b, `%s`, + num(padL-5), num(y), num(v)) + } + b.WriteString(``) + + // X labels. + b.WriteString(``) + for i, lbl := range xLabels { + fmt.Fprintf(&b, `%s`, + num(xAt(i)), num(height-padB+16), template.HTMLEscapeString(lbl)) + } + b.WriteString(``) + + // Series: a polyline through present points + a dot per present point. + for _, s := range series { + var pts []string + for i, p := range s.Points { + if p == nil { + continue + } + pts = append(pts, num(xAt(i))+","+num(yAt(*p))) + } + if len(pts) == 0 { + continue + } + color := template.HTMLEscapeString(s.Color) + fmt.Fprintf(&b, ``, + strings.Join(pts, " "), color) + for i, p := range s.Points { + if p == nil { + continue + } + fmt.Fprintf(&b, ``, num(xAt(i)), num(yAt(*p)), color) + } + } + + b.WriteString(``) + return template.HTML(b.String()) // #nosec G203 -- markup built from escaped labels + numeric geometry +} + // num formats a float for SVG coordinates: up to 2 decimals, trailing zeros // trimmed, so "120" stays "120" and "63.64" stays compact. func num(f float64) string { diff --git a/internal/view/charts_test.go b/internal/view/charts_test.go index aa057a0..fff3923 100644 --- a/internal/view/charts_test.go +++ b/internal/view/charts_test.go @@ -69,6 +69,37 @@ func TestDonutSVG_AllZero(t *testing.T) { } } +func TestLineSVG(t *testing.T) { + p := func(v float64) *float64 { return &v } + svg := string(LineSVG( + []string{"Jan", "Mar", "Jun"}, + []LineSeries{ + {Label: "Execution", Color: "#4f46e5", Points: []*float64{p(3), p(3.5), p(4)}}, + {Label: "Collab", Color: "#16a34a", Points: []*float64{p(4), nil, p(4.5)}}, // gap in the middle + }, + 640, 260, 1, 5, + )) + if !strings.HasPrefix(svg, "") { + t.Fatalf("expected complete svg") + } + if strings.Count(svg, "linechart__line") != 2 { + t.Fatalf("expected 2 series lines") + } + // Execution has 3 dots, Collab has 2 (nil skipped) → 5 total. + if got := strings.Count(svg, "Jun") { + t.Fatalf("expected x label Jun") + } +} + +func TestLineSVG_Empty(t *testing.T) { + if LineSVG(nil, nil, 640, 260, 1, 5) != "" { + t.Fatal("expected empty output for no labels") + } +} + func TestNum(t *testing.T) { cases := map[float64]string{120: "120", 63.636: "63.64", 0: "0", 12.5: "12.5"} for in, want := range cases { diff --git a/internal/view/render.go b/internal/view/render.go index 2daee48..6758a32 100644 --- a/internal/view/render.go +++ b/internal/view/render.go @@ -40,6 +40,7 @@ func NewRenderer(fsys fs.FS) (*Renderer, error) { "partial": r.partial, "radarSVG": RadarSVG, "donutSVG": DonutSVG, + "lineSVG": LineSVG, "radarAxis": func(label string, value float64) RadarAxis { return RadarAxis{Label: label, Value: value} }, "donutSlice": func(label string, value int, color string) DonutSlice { return DonutSlice{Label: label, Value: value, Color: color} diff --git a/sonar-project.properties b/sonar-project.properties index 5062bea..2a568a8 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,7 +10,7 @@ # --- Project identity --------------------------------------------------------- sonar.projectKey=mondial7_smart-360 sonar.projectName=Smart 360 Feedback -sonar.projectVersion=1.0.0 +sonar.projectVersion=1.1.0 # If you use SonarCloud, also set: # sonar.organization= diff --git a/web/static/css/app.css b/web/static/css/app.css index 840593e..28c342f 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -172,6 +172,15 @@ th { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; colo .onboarding__dots span.is-active { background: var(--color-primary); transform: scale(1.25); } @keyframes fade-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } +/* Pagination */ +.pagination { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-top: 1rem; } + +/* Line chart (cross-round comparison) */ +.linechart { display: block; max-width: 100%; height: auto; } +.linechart__gridline { stroke: var(--border-color); stroke-width: 1; opacity: 0.5; } +.linechart__ylabel, .linechart__xlabel { font-size: 0.72rem; fill: var(--text-secondary); } +.linechart__line { stroke-linejoin: round; stroke-linecap: round; } + /* Live log viewer */ .log-view { max-height: 60vh; overflow: auto; margin: 0; diff --git a/web/templates/audit.html b/web/templates/audit.html index 5d6a9bb..2026fad 100644 --- a/web/templates/audit.html +++ b/web/templates/audit.html @@ -17,4 +17,5 @@ {{else}}

No audit entries.

{{end}} +{{template "pagination" .Nav}} {{end}} diff --git a/web/templates/compare.html b/web/templates/compare.html new file mode 100644 index 0000000..e1ed269 --- /dev/null +++ b/web/templates/compare.html @@ -0,0 +1,53 @@ +{{define "compare_content"}} +
+

Your growth over time

+

How your competency scores from peers have moved across rounds.

+ Back to my feedback +
+ +{{if not .Enough}} +

+ You need at least two shared feedback rounds to see a comparison. Check back after your next round.

+{{else}} + +
+

Competency scores (peer average, 1–5)

+ {{ lineSVG .XLabels .Series 660.0 280.0 1.0 5.0 }} +
    + {{range .Series}} +
  • + + {{.Label}} +
  • + {{end}} +
+
+ +

Round-by-round

+
+ + + {{range .XLabels}}{{end}} + + + {{range .Rows}} + + + {{range .Scores}}{{end}} + + {{end}} + +
Competency{{.}}
{{.Name}}{{.}}
+
+ +

Summary timeline

+
+ {{range .Timeline}} +
+

{{.Date}}

+

{{.Summary}}

+
+ {{end}} +
+{{end}} +{{end}} diff --git a/web/templates/my_feedback.html b/web/templates/my_feedback.html index bb304b7..7d7bff7 100644 --- a/web/templates/my_feedback.html +++ b/web/templates/my_feedback.html @@ -1,6 +1,8 @@ {{define "my_feedback_content"}} -

My feedback

-

Consolidations shared with you.

+
+

My feedback

Consolidations shared with you.

+ {{if .CanCompare}}See your growth over time{{end}} +
{{if .Consolidations}} {{if .Radar}} diff --git a/web/templates/partials/pagination.html b/web/templates/partials/pagination.html new file mode 100644 index 0000000..2444380 --- /dev/null +++ b/web/templates/partials/pagination.html @@ -0,0 +1,11 @@ +{{define "pagination"}} +{{if .}} +{{if or .HasPrev .HasNext}} + +{{end}} +{{end}} +{{end}} diff --git a/web/templates/rounds.html b/web/templates/rounds.html index a6ad676..7c3f9d9 100644 --- a/web/templates/rounds.html +++ b/web/templates/rounds.html @@ -7,6 +7,7 @@
{{range .Cards}}{{template "round_card" .}}{{end}}
+{{template "pagination" .Nav}} {{else}}

No rounds yet.

{{end}} diff --git a/web/templates/users.html b/web/templates/users.html index 46e71ac..0dda33c 100644 --- a/web/templates/users.html +++ b/web/templates/users.html @@ -26,4 +26,5 @@ +{{template "pagination" .Nav}} {{end}}