Files
multica/server/internal/handler/activity.go
Naiyuan Qing ba147708a6 fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968) (#2128)
* fix(timeline): cursor-paginated timeline to stop long-issue freeze (#1968)

Opening an issue from Inbox with thousands of timeline entries used to
hard-freeze the browser tab on a synchronous render of every comment +
activity. The whole pipeline was unbounded: the API returned every row,
TanStack Query cached the full array, and IssueDetail mounted N
CommentCards (each running a full react-markdown + lowlight pipeline)
in one frame.

This swaps the timeline endpoint to keyset cursor pagination and rewires
the frontend to useInfiniteQuery so a long issue costs the same as a
short one on first paint.

API:
- GET /issues/:id/timeline now accepts ?before / ?after / ?around (mutex)
  + ?limit (default 50, max 100); response wraps entries with next/prev
  cursors and has_more flags. Cursors are opaque base64 (created_at, id).
- ?around=<entry_id> anchors a window on the target so Inbox notifications
  pointing at an old comment never trigger the freeze.
- New composite indexes on (issue_id, created_at DESC, id DESC) replace
  the redundant single-column ones so keyset queries are index-only scans.
- /issues/:id/comments default branch now caps at 50 instead of returning
  every row unbounded; the unbounded ListComments / ListActivities sqlc
  queries are deleted.

Frontend:
- useIssueTimeline switches to useInfiniteQuery, exposes
  fetchOlder/fetchNewer/jumpToLatest + isAtLatest + newEntriesBelowCount.
- WS handlers respect the at-latest invariant: comment/activity:created
  prepends to pages[0] only when the user is reading the live tail;
  otherwise it just bumps a counter so the UI offers a "Jump to latest"
  affordance without yanking scroll.
- Optimistic mutations adapted to the InfiniteData shape via shared
  helpers (mapAllEntries / filterAllEntries / prependToLatestPage in
  core/issues/timeline-cache.ts) and use setQueriesData so all open
  windows of the same issue stay in sync.
- IssueDetail Activity section gets a TimelineSkeleton placeholder
  during the brief load window plus subtle text-link load-more buttons
  matching the existing Subscribe affordance (no Button chrome). Top
  uses a divider for boundary clarity; bottom shows
  "Jump to latest · N new" weighted slightly heavier when there's
  unread state.
- highlightCommentId now flows into the hook's around parameter so
  Inbox jumps fetch the surrounding 50 entries directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(agent): default comment list to 50 + prompt hint about long issues

The CLI's "multica issue comment list" used to default to --limit 0
(meaning "fetch every comment"), which lets an agent on a long issue
fill its context window with thousands of rows. The default is now 50;
agents that need older history can pass --limit or --since explicitly.

The local-coding-agent prompt also gains a single-line note about this
in both the comment-triggered and on-assign flows so the agent knows to
scope its fetches when issue size is unknown. Autopilot run-only mode
is intentionally unchanged — it has no issue context to query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:27:06 +08:00

596 lines
20 KiB
Go

package handler
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TimelineEntry represents a single entry in the issue timeline, which can be
// either an activity log record or a comment.
type TimelineEntry struct {
Type string `json:"type"` // "activity" or "comment"
ID string `json:"id"`
ActorType string `json:"actor_type"`
ActorID string `json:"actor_id"`
CreatedAt string `json:"created_at"`
// Activity-only fields
Action *string `json:"action,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
// Comment-only fields
Content *string `json:"content,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
UpdatedAt *string `json:"updated_at,omitempty"`
CommentType *string `json:"comment_type,omitempty"`
Reactions []ReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
}
// TimelineResponse wraps the cursor-paginated timeline. Entries are sorted
// newest-first (created_at DESC, id DESC). NextCursor / PrevCursor are opaque
// strings; clients pass them back as ?before= / ?after= without inspection.
// HasMoreBefore indicates more entries older than the last in the page;
// HasMoreAfter indicates more entries newer than the first in the page.
type TimelineResponse struct {
Entries []TimelineEntry `json:"entries"`
NextCursor *string `json:"next_cursor"`
PrevCursor *string `json:"prev_cursor"`
HasMoreBefore bool `json:"has_more_before"`
HasMoreAfter bool `json:"has_more_after"`
// TargetIndex is set only in ?around=<id> mode, locating the anchor entry
// within Entries so the client can scroll/highlight without searching.
TargetIndex *int `json:"target_index,omitempty"`
}
const (
timelineDefaultLimit = 50
timelineMaxLimit = 100
)
// timelineCursor encodes a (created_at, id) keyset position as opaque base64
// JSON. The format is intentionally hidden from clients so future schema
// evolution (e.g. switching to a sequence column) can replace the cursor
// payload without breaking API consumers.
type timelineCursor struct {
CreatedAt time.Time `json:"t"`
ID string `json:"i"`
}
func encodeTimelineCursor(t pgtype.Timestamptz, id pgtype.UUID) string {
c := timelineCursor{CreatedAt: t.Time, ID: uuidToString(id)}
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
func decodeTimelineCursor(s string) (pgtype.Timestamptz, pgtype.UUID, error) {
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return pgtype.Timestamptz{}, pgtype.UUID{}, err
}
var c timelineCursor
if err := json.Unmarshal(raw, &c); err != nil {
return pgtype.Timestamptz{}, pgtype.UUID{}, err
}
id, err := parseUUIDStrict(c.ID)
if err != nil {
return pgtype.Timestamptz{}, pgtype.UUID{}, err
}
return pgtype.Timestamptz{Time: c.CreatedAt, Valid: true}, id, nil
}
// parseUUIDStrict mirrors util.ParseUUID but returns a pgtype.UUID directly
// without panicking on bad input. Used for cursor decoding where invalid data
// is a 400, not a 500.
func parseUUIDStrict(s string) (pgtype.UUID, error) {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
return pgtype.UUID{}, err
}
if !u.Valid {
return pgtype.UUID{}, errors.New("invalid uuid")
}
return u, nil
}
// ListTimeline returns a cursor-paginated, newest-first slice of the issue
// timeline (comments + activities merged). The query string accepts at most
// one of: ?before=<cursor>, ?after=<cursor>, ?around=<entry_id>. With none,
// the latest page is returned.
func (h *Handler) ListTimeline(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
if !ok {
return
}
q := r.URL.Query()
limit := timelineDefaultLimit
if raw := q.Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "invalid limit")
return
}
if n > timelineMaxLimit {
writeError(w, http.StatusBadRequest, "limit exceeds maximum of 100")
return
}
limit = n
}
before, after, around := q.Get("before"), q.Get("after"), q.Get("around")
modes := 0
for _, s := range []string{before, after, around} {
if s != "" {
modes++
}
}
if modes > 1 {
writeError(w, http.StatusBadRequest, "before, after, and around are mutually exclusive")
return
}
switch {
case around != "":
h.listTimelineAround(w, r, issue, around, limit)
case before != "":
h.listTimelineBefore(w, r, issue, before, limit)
case after != "":
h.listTimelineAfter(w, r, issue, after, limit)
default:
h.listTimelineLatest(w, r, issue, limit)
}
}
// listTimelineLatest fetches the most recent <limit> entries (no cursor).
// Both tables are queried for <limit> rows each; the merge picks the top
// <limit> overall. Any item the merge didn't include cannot rank higher than
// the worst kept item in either pool, so this is exact, not approximate.
func (h *Handler) listTimelineLatest(w http.ResponseWriter, r *http.Request, issue db.Issue, limit int) {
ctx := r.Context()
comments, err := h.Queries.ListCommentsLatest(ctx, db.ListCommentsLatestParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
activities, err := h.Queries.ListActivitiesLatest(ctx, db.ListActivitiesLatestParams{
IssueID: issue.ID, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
entries := h.mergeTimelineDesc(r, comments, activities, limit)
resp := TimelineResponse{Entries: entries}
// has_more_before: the page is full → there are likely more older. If the
// page is partial it means we hit the bottom of one or both tables.
resp.HasMoreBefore = len(entries) >= limit && (len(comments) >= limit || len(activities) >= limit)
if resp.HasMoreBefore && len(entries) > 0 {
c := encodeTimelineCursor(entryTimestamp(entries[len(entries)-1]), entryID(entries[len(entries)-1]))
resp.NextCursor = &c
}
if len(entries) > 0 {
c := encodeTimelineCursor(entryTimestamp(entries[0]), entryID(entries[0]))
resp.PrevCursor = &c
}
// has_more_after is always false on the latest page by definition.
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) listTimelineBefore(w http.ResponseWriter, r *http.Request, issue db.Issue, cursor string, limit int) {
ctx := r.Context()
t, id, err := decodeTimelineCursor(cursor)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return
}
comments, err := h.Queries.ListCommentsBefore(ctx, db.ListCommentsBeforeParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
Column3: t, Column4: id, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
activities, err := h.Queries.ListActivitiesBefore(ctx, db.ListActivitiesBeforeParams{
IssueID: issue.ID, Column2: t, Column3: id, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
entries := h.mergeTimelineDesc(r, comments, activities, limit)
resp := TimelineResponse{
Entries: entries,
HasMoreAfter: true, // we're paging older from a known position, so newer exists
}
resp.HasMoreBefore = len(entries) >= limit && (len(comments) >= limit || len(activities) >= limit)
if resp.HasMoreBefore && len(entries) > 0 {
c := encodeTimelineCursor(entryTimestamp(entries[len(entries)-1]), entryID(entries[len(entries)-1]))
resp.NextCursor = &c
}
if len(entries) > 0 {
c := encodeTimelineCursor(entryTimestamp(entries[0]), entryID(entries[0]))
resp.PrevCursor = &c
}
writeJSON(w, http.StatusOK, resp)
}
func (h *Handler) listTimelineAfter(w http.ResponseWriter, r *http.Request, issue db.Issue, cursor string, limit int) {
ctx := r.Context()
t, id, err := decodeTimelineCursor(cursor)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return
}
comments, err := h.Queries.ListCommentsAfter(ctx, db.ListCommentsAfterParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
Column3: t, Column4: id, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
activities, err := h.Queries.ListActivitiesAfter(ctx, db.ListActivitiesAfterParams{
IssueID: issue.ID, Column2: t, Column3: id, Limit: int32(limit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
// Both queries returned ASC (older→newer). Merge ASC, take the limit
// closest to the cursor (i.e. the oldest of the "after" set), then
// reverse to DESC for the response.
entries := h.mergeTimelineAscThenReverse(r, comments, activities, limit)
resp := TimelineResponse{Entries: entries, HasMoreBefore: true}
resp.HasMoreAfter = len(entries) >= limit && (len(comments) >= limit || len(activities) >= limit)
if resp.HasMoreAfter && len(entries) > 0 {
c := encodeTimelineCursor(entryTimestamp(entries[0]), entryID(entries[0]))
resp.PrevCursor = &c
}
if len(entries) > 0 {
c := encodeTimelineCursor(entryTimestamp(entries[len(entries)-1]), entryID(entries[len(entries)-1]))
resp.NextCursor = &c
}
writeJSON(w, http.StatusOK, resp)
}
// listTimelineAround anchors a window of size <limit> on a target entry,
// returning roughly half before and half after plus the target itself.
// This is the Inbox-jump / deep-link path: the target entry can be deep in
// the timeline, but the response is bounded so the browser never freezes.
func (h *Handler) listTimelineAround(w http.ResponseWriter, r *http.Request, issue db.Issue, targetID string, limit int) {
ctx := r.Context()
target, err := parseUUIDStrict(targetID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid around id")
return
}
// Resolve the target's (created_at, id). It can be either a comment or
// an activity; we don't ask the client to disambiguate.
var anchorTime pgtype.Timestamptz
var anchorID pgtype.UUID
if c, cErr := h.Queries.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{
ID: target, WorkspaceID: issue.WorkspaceID,
}); cErr == nil && c.IssueID == issue.ID {
anchorTime, anchorID = c.CreatedAt, c.ID
} else if a, aErr := h.Queries.GetActivity(ctx, target); aErr == nil &&
a.IssueID == issue.ID && a.WorkspaceID == issue.WorkspaceID {
anchorTime, anchorID = a.CreatedAt, a.ID
} else {
// Neither comment nor activity matched (or wrong workspace/issue).
// Don't leak existence — return 404 like other resource lookups.
if cErr != nil && !errors.Is(cErr, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "failed to resolve target")
return
}
writeError(w, http.StatusNotFound, "timeline entry not found")
return
}
half := limit / 2
if half < 1 {
half = 1
}
beforeLimit := half
afterLimit := limit - half - 1 // -1 for the anchor itself
if afterLimit < 0 {
afterLimit = 0
}
// Older half: keyset Before (anchor exclusive).
olderComments, err := h.Queries.ListCommentsBefore(ctx, db.ListCommentsBeforeParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
Column3: anchorTime, Column4: anchorID, Limit: int32(beforeLimit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
olderActivities, err := h.Queries.ListActivitiesBefore(ctx, db.ListActivitiesBeforeParams{
IssueID: issue.ID, Column2: anchorTime, Column3: anchorID, Limit: int32(beforeLimit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
olderEntries := h.mergeTimelineDesc(r, olderComments, olderActivities, beforeLimit)
// Newer half: keyset After (anchor exclusive).
newerComments, err := h.Queries.ListCommentsAfter(ctx, db.ListCommentsAfterParams{
IssueID: issue.ID, WorkspaceID: issue.WorkspaceID,
Column3: anchorTime, Column4: anchorID, Limit: int32(afterLimit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list comments")
return
}
newerActivities, err := h.Queries.ListActivitiesAfter(ctx, db.ListActivitiesAfterParams{
IssueID: issue.ID, Column2: anchorTime, Column3: anchorID, Limit: int32(afterLimit),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list activities")
return
}
newerEntries := h.mergeTimelineAscThenReverse(r, newerComments, newerActivities, afterLimit)
// Build the anchor entry inline using the existing single-entry path.
anchorEntry, ok := h.fetchSingleEntry(r, issue, target)
if !ok {
writeError(w, http.StatusInternalServerError, "failed to fetch anchor")
return
}
// Final stitch: newer (DESC) + anchor + older (DESC).
entries := make([]TimelineEntry, 0, len(newerEntries)+1+len(olderEntries))
entries = append(entries, newerEntries...)
entries = append(entries, anchorEntry)
entries = append(entries, olderEntries...)
targetIdx := len(newerEntries)
resp := TimelineResponse{
Entries: entries,
HasMoreBefore: len(olderComments) >= beforeLimit || len(olderActivities) >= beforeLimit,
HasMoreAfter: len(newerComments) >= afterLimit || len(newerActivities) >= afterLimit,
TargetIndex: &targetIdx,
}
if resp.HasMoreBefore {
c := encodeTimelineCursor(entryTimestamp(entries[len(entries)-1]), entryID(entries[len(entries)-1]))
resp.NextCursor = &c
}
if resp.HasMoreAfter {
c := encodeTimelineCursor(entryTimestamp(entries[0]), entryID(entries[0]))
resp.PrevCursor = &c
}
writeJSON(w, http.StatusOK, resp)
}
// fetchSingleEntry materializes a single TimelineEntry (comment or activity)
// for the around-mode anchor. Reactions/attachments come from the same batch
// helpers so the rendering is identical to the merge path.
func (h *Handler) fetchSingleEntry(r *http.Request, issue db.Issue, id pgtype.UUID) (TimelineEntry, bool) {
ctx := r.Context()
if c, err := h.Queries.GetCommentInWorkspace(ctx, db.GetCommentInWorkspaceParams{
ID: id, WorkspaceID: issue.WorkspaceID,
}); err == nil && c.IssueID == issue.ID {
return h.commentsToEntries(r, []db.Comment{c})[0], true
}
if a, err := h.Queries.GetActivity(ctx, id); err == nil &&
a.IssueID == issue.ID && a.WorkspaceID == issue.WorkspaceID {
return activityToEntry(a), true
}
return TimelineEntry{}, false
}
// mergeTimelineDesc takes comments + activities sorted DESC by (created_at, id)
// and returns the top <limit> merged entries, also DESC. Items the merge does
// not include cannot rank higher than the worst kept item in either pool, so
// the result is exact.
func (h *Handler) mergeTimelineDesc(r *http.Request, comments []db.Comment, activities []db.ActivityLog, limit int) []TimelineEntry {
out := make([]TimelineEntry, 0, len(comments)+len(activities))
out = append(out, h.commentsToEntries(r, comments)...)
for _, a := range activities {
out = append(out, activityToEntry(a))
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt != out[j].CreatedAt {
return out[i].CreatedAt > out[j].CreatedAt
}
return out[i].ID > out[j].ID
})
if len(out) > limit {
out = out[:limit]
}
return out
}
// mergeTimelineAscThenReverse takes comments + activities sorted ASC by
// (created_at, id) — the natural shape of an "after" keyset query — picks
// the <limit> closest to the cursor (i.e. earliest of the after-set), and
// returns them DESC for response consistency.
func (h *Handler) mergeTimelineAscThenReverse(r *http.Request, comments []db.Comment, activities []db.ActivityLog, limit int) []TimelineEntry {
out := make([]TimelineEntry, 0, len(comments)+len(activities))
out = append(out, h.commentsToEntries(r, comments)...)
for _, a := range activities {
out = append(out, activityToEntry(a))
}
sort.Slice(out, func(i, j int) bool {
if out[i].CreatedAt != out[j].CreatedAt {
return out[i].CreatedAt < out[j].CreatedAt
}
return out[i].ID < out[j].ID
})
if len(out) > limit {
out = out[:limit]
}
// Reverse to DESC.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// commentsToEntries fetches reactions + attachments for the given comments in
// one batch each and returns enriched TimelineEntry slices preserving order.
func (h *Handler) commentsToEntries(r *http.Request, comments []db.Comment) []TimelineEntry {
if len(comments) == 0 {
return nil
}
ids := make([]pgtype.UUID, len(comments))
for i, c := range comments {
ids[i] = c.ID
}
reactions := h.groupReactions(r, ids)
attachments := h.groupAttachments(r, ids)
out := make([]TimelineEntry, len(comments))
for i, c := range comments {
content := c.Content
commentType := c.Type
updatedAt := timestampToString(c.UpdatedAt)
cid := uuidToString(c.ID)
out[i] = TimelineEntry{
Type: "comment",
ID: cid,
ActorType: c.AuthorType,
ActorID: uuidToString(c.AuthorID),
Content: &content,
CommentType: &commentType,
ParentID: uuidToPtr(c.ParentID),
CreatedAt: timestampToString(c.CreatedAt),
UpdatedAt: &updatedAt,
Reactions: reactions[cid],
Attachments: attachments[cid],
}
}
return out
}
func activityToEntry(a db.ActivityLog) TimelineEntry {
action := a.Action
actorType := ""
if a.ActorType.Valid {
actorType = a.ActorType.String
}
return TimelineEntry{
Type: "activity",
ID: uuidToString(a.ID),
ActorType: actorType,
ActorID: uuidToString(a.ActorID),
Action: &action,
Details: a.Details,
CreatedAt: timestampToString(a.CreatedAt),
}
}
// entryTimestamp / entryID extract the cursor components for an emitted
// TimelineEntry. CreatedAt is already an RFC3339 string at this point;
// re-parse it for cursor encoding.
func entryTimestamp(e TimelineEntry) pgtype.Timestamptz {
t, _ := time.Parse(time.RFC3339Nano, e.CreatedAt)
return pgtype.Timestamptz{Time: t, Valid: true}
}
func entryID(e TimelineEntry) pgtype.UUID {
id, _ := parseUUIDStrict(e.ID)
return id
}
// AssigneeFrequencyEntry represents how often a user assigns to a specific target.
type AssigneeFrequencyEntry struct {
AssigneeType string `json:"assignee_type"`
AssigneeID string `json:"assignee_id"`
Frequency int64 `json:"frequency"`
}
// GetAssigneeFrequency returns assignee usage frequency for the current user,
// combining data from assignee change activities and initial issue assignments.
func (h *Handler) GetAssigneeFrequency(w http.ResponseWriter, r *http.Request) {
userID, ok := requireUserID(w, r)
if !ok {
return
}
workspaceID := h.resolveWorkspaceID(r)
// Aggregate frequency from both data sources.
freq := map[string]int64{} // key: "type:id"
// Source 1: assignee_changed activities by this user.
activityCounts, err := h.Queries.CountAssigneeChangesByActor(r.Context(), db.CountAssigneeChangesByActorParams{
WorkspaceID: parseUUID(workspaceID),
ActorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
return
}
for _, row := range activityCounts {
aType, _ := row.AssigneeType.(string)
aID, _ := row.AssigneeID.(string)
if aType != "" && aID != "" {
freq[aType+":"+aID] += row.Frequency
}
}
// Source 2: issues created by this user with an assignee.
issueCounts, err := h.Queries.CountCreatedIssueAssignees(r.Context(), db.CountCreatedIssueAssigneesParams{
WorkspaceID: parseUUID(workspaceID),
CreatorID: parseUUID(userID),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to get assignee frequency")
return
}
for _, row := range issueCounts {
if !row.AssigneeType.Valid || !row.AssigneeID.Valid {
continue
}
key := row.AssigneeType.String + ":" + uuidToString(row.AssigneeID)
freq[key] += row.Frequency
}
// Build sorted response.
result := make([]AssigneeFrequencyEntry, 0, len(freq))
for key, count := range freq {
// Split "type:id" — type is always "member" or "agent" (no colons).
var aType, aID string
for i := 0; i < len(key); i++ {
if key[i] == ':' {
aType = key[:i]
aID = key[i+1:]
break
}
}
result = append(result, AssigneeFrequencyEntry{
AssigneeType: aType,
AssigneeID: aID,
Frequency: count,
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Frequency > result[j].Frequency
})
writeJSON(w, http.StatusOK, result)
}