fc7e6a34a7
Adds a daily report dashboard accessible by clicking a day number in the
calendar view. Renders inside a full-width modal (90% width).
Backend (new file backend/reports.go):
- Type DailyReport with KPIs, rankings, done_cards list, reopened cards,
3-bucket stale list (7/14/30d), lead time avg+p50+p95, 24-hour
movement histogram, deadlines met/missed list, tag distribution and
archived count.
- DB.DailyReportFor(date, tz) uses Europe/Madrid by default; computes
[start,end) in local time, converts to UTC and queries:
* cards.completed_at in range -> done list
* card_events kind=created in range -> created counts
* card_column_history.entered_at in range -> moves + hourly
* previousColumnWasDone() -> reopened detection
* card_lock_history overlapping the day -> blocked_ms
* stale buckets: open history entries on non-done columns aged >=7d
- New route GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid.
Frontend:
- api.ts: DailyReport type + dailyReport(date, tz?) call.
- New component DailyReportView (components/DailyReport.tsx):
* 6 KPI cards (Hechas, Creadas, Movimientos, Bloqueado, Reabiertas,
Deadlines on-time %).
* 4 ranking cards (Top assignees done, Top assignees created,
Top requesters atendidas, Top requesters aportadas).
* Done cards table with click-to-jump (links open the card in board).
* Mantine BarChart with movements per hour.
* Tag chips, reopened list, deadlines list with late_ms, stale buckets.
- CalendarView wraps the day number in UnstyledButton with data-test
attribute and forwards onOpenDailyReport.
- App.handleOpenDailyReport opens modals.open size 90% with the view;
click on a card title closes the modal and jumps to the board with
highlight (reuses existing handleJumpToCard).
Tests (e2e/daily-report.spec.ts):
- Endpoint shape: kpis, done_cards, hourly_moves[24], stale buckets.
- Calendar day click opens the modal with "Reporte diario" title and
KPI labels visible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
589 lines
16 KiB
Go
589 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// DailyReport — agregaciones por dia natural (TZ del servidor a menos que el
|
|
// caller pase una TZ explicita). Issue 0093.
|
|
type DailyReport struct {
|
|
Date string `json:"date"`
|
|
TZ string `json:"tz"`
|
|
StartTs string `json:"start_ts"`
|
|
EndTs string `json:"end_ts"`
|
|
|
|
KPIs DailyKPIs `json:"kpis"`
|
|
|
|
TopAssigneesDone []UserCount `json:"top_assignees_done"`
|
|
TopAssigneesCreated []UserCount `json:"top_assignees_created"`
|
|
TopRequestersAdded []NamedCount `json:"top_requesters_added"`
|
|
TopRequestersDone []NamedCount `json:"top_requesters_done"`
|
|
DoneCards []DoneCard `json:"done_cards"`
|
|
ReopenedCards []ReopenedEntry `json:"reopened_cards"`
|
|
StaleCards StaleBuckets `json:"stale_cards"`
|
|
LeadTime LeadTimeStats `json:"lead_time"`
|
|
HourlyMoves [24]int `json:"hourly_moves"`
|
|
Deadlines DeadlineSummary `json:"deadlines"`
|
|
TagsDone []NamedCount `json:"tags_done"`
|
|
ArchivedToday int `json:"archived_today"`
|
|
}
|
|
|
|
type DailyKPIs struct {
|
|
Done int `json:"done"`
|
|
Created int `json:"created"`
|
|
Moves int `json:"moves"`
|
|
BlockedMs int64 `json:"blocked_ms"`
|
|
DeadlinesMet int `json:"deadlines_met"`
|
|
DeadlinesMissed int `json:"deadlines_missed"`
|
|
Reopened int `json:"reopened"`
|
|
ArchivedAuto int `json:"archived_auto"`
|
|
ArchivedManual int `json:"archived_manual"`
|
|
}
|
|
|
|
type UserCount struct {
|
|
UserID string `json:"user_id"`
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type NamedCount struct {
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type DoneCard struct {
|
|
ID string `json:"id"`
|
|
SeqNum int `json:"seq_num"`
|
|
Title string `json:"title"`
|
|
Requester string `json:"requester"`
|
|
AssigneeID *string `json:"assignee_id"`
|
|
AssigneeName *string `json:"assignee_name"`
|
|
Tags []string `json:"tags"`
|
|
ColumnID string `json:"column_id"`
|
|
ColumnName string `json:"column_name"`
|
|
CompletedAt string `json:"completed_at"`
|
|
CreatedAt string `json:"created_at"`
|
|
LeadTimeMs int64 `json:"lead_time_ms"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
type ReopenedEntry struct {
|
|
CardID string `json:"card_id"`
|
|
Title string `json:"title"`
|
|
SeqNum int `json:"seq_num"`
|
|
FromColumn string `json:"from_column"`
|
|
ToColumn string `json:"to_column"`
|
|
Ts string `json:"ts"`
|
|
ActorID *string `json:"actor_id"`
|
|
ActorName *string `json:"actor_name"`
|
|
}
|
|
|
|
type StaleEntry struct {
|
|
CardID string `json:"card_id"`
|
|
Title string `json:"title"`
|
|
SeqNum int `json:"seq_num"`
|
|
ColumnID string `json:"column_id"`
|
|
ColumnName string `json:"column_name"`
|
|
EnteredAt string `json:"entered_at"`
|
|
Days int `json:"days"`
|
|
}
|
|
|
|
type StaleBuckets struct {
|
|
D7 []StaleEntry `json:"d7"`
|
|
D14 []StaleEntry `json:"d14"`
|
|
D30 []StaleEntry `json:"d30"`
|
|
}
|
|
|
|
type LeadTimeStats struct {
|
|
AvgMs int64 `json:"avg_ms"`
|
|
P50Ms int64 `json:"p50_ms"`
|
|
P95Ms int64 `json:"p95_ms"`
|
|
Samples int `json:"samples"`
|
|
}
|
|
|
|
type DeadlineSummary struct {
|
|
Met int `json:"met"`
|
|
Missed int `json:"missed"`
|
|
List []DeadlineMissEntry `json:"list"`
|
|
}
|
|
|
|
type DeadlineMissEntry struct {
|
|
CardID string `json:"card_id"`
|
|
Title string `json:"title"`
|
|
SeqNum int `json:"seq_num"`
|
|
Deadline string `json:"deadline"`
|
|
CompletedAt string `json:"completed_at"`
|
|
LateMs int64 `json:"late_ms"`
|
|
}
|
|
|
|
// DailyReportFor computes the report for the local day specified by date+tz.
|
|
func (db *DB) DailyReportFor(date, tz string) (*DailyReport, error) {
|
|
loc, err := time.LoadLocation(tz)
|
|
if err != nil {
|
|
loc = time.UTC
|
|
tz = "UTC"
|
|
}
|
|
t, err := time.ParseInLocation("2006-01-02", date, loc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid date %q: %w", date, err)
|
|
}
|
|
start := t
|
|
end := t.Add(24 * time.Hour)
|
|
startUTC := start.UTC().Format(time.RFC3339Nano)
|
|
endUTC := end.UTC().Format(time.RFC3339Nano)
|
|
|
|
r := &DailyReport{
|
|
Date: date,
|
|
TZ: tz,
|
|
StartTs: startUTC,
|
|
EndTs: endUTC,
|
|
StaleCards: StaleBuckets{
|
|
D7: []StaleEntry{},
|
|
D14: []StaleEntry{},
|
|
D30: []StaleEntry{},
|
|
},
|
|
Deadlines: DeadlineSummary{List: []DeadlineMissEntry{}},
|
|
DoneCards: []DoneCard{},
|
|
ReopenedCards: []ReopenedEntry{},
|
|
TopAssigneesDone: []UserCount{},
|
|
TopAssigneesCreated: []UserCount{},
|
|
TopRequestersAdded: []NamedCount{},
|
|
TopRequestersDone: []NamedCount{},
|
|
TagsDone: []NamedCount{},
|
|
}
|
|
|
|
users, err := db.userNameMap()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doneColIDs, doneColNames, err := db.doneColumnIDs()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allColNames, err := db.allColumnNames()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// --- Done cards ----------------------------------------------------------
|
|
rows, err := db.conn.Query(`
|
|
SELECT c.id, c.seq_num, c.title, c.requester, c.assignee_id, c.tags, c.column_id, c.completed_at, c.created_at, c.color, c.deadline
|
|
FROM cards c
|
|
WHERE c.completed_at IS NOT NULL
|
|
AND c.completed_at >= ? AND c.completed_at < ?
|
|
AND c.deleted_at IS NULL
|
|
ORDER BY c.completed_at DESC
|
|
`, startUTC, endUTC)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
leadSamples := []int64{}
|
|
assigneeDoneCount := map[string]int{}
|
|
requesterDoneCount := map[string]int{}
|
|
tagCount := map[string]int{}
|
|
for rows.Next() {
|
|
var c DoneCard
|
|
var assignee, deadline sql.NullString
|
|
var tagsJSON string
|
|
if err := rows.Scan(&c.ID, &c.SeqNum, &c.Title, &c.Requester, &assignee, &tagsJSON, &c.ColumnID, &c.CompletedAt, &c.CreatedAt, &c.Color, &deadline); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
c.Tags = parseTags(tagsJSON)
|
|
if assignee.Valid && assignee.String != "" {
|
|
a := assignee.String
|
|
c.AssigneeID = &a
|
|
if n, ok := users[a]; ok {
|
|
nm := n
|
|
c.AssigneeName = &nm
|
|
}
|
|
assigneeDoneCount[a]++
|
|
}
|
|
if c.Requester != "" {
|
|
requesterDoneCount[c.Requester]++
|
|
}
|
|
for _, tag := range c.Tags {
|
|
tagCount[tag]++
|
|
}
|
|
c.ColumnName = allColNames[c.ColumnID]
|
|
// Lead time created -> completed.
|
|
if ct, err := time.Parse(time.RFC3339Nano, c.CreatedAt); err == nil {
|
|
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
|
|
c.LeadTimeMs = compt.Sub(ct).Milliseconds()
|
|
if c.LeadTimeMs >= 0 {
|
|
leadSamples = append(leadSamples, c.LeadTimeMs)
|
|
}
|
|
}
|
|
}
|
|
// Deadlines.
|
|
if deadline.Valid && deadline.String != "" {
|
|
if dlt, err := time.Parse(time.RFC3339Nano, deadline.String); err == nil {
|
|
if compt, err := time.Parse(time.RFC3339Nano, c.CompletedAt); err == nil {
|
|
if compt.After(dlt) {
|
|
r.Deadlines.Missed++
|
|
r.Deadlines.List = append(r.Deadlines.List, DeadlineMissEntry{
|
|
CardID: c.ID, Title: c.Title, SeqNum: c.SeqNum,
|
|
Deadline: deadline.String, CompletedAt: c.CompletedAt,
|
|
LateMs: compt.Sub(dlt).Milliseconds(),
|
|
})
|
|
} else {
|
|
r.Deadlines.Met++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
r.DoneCards = append(r.DoneCards, c)
|
|
}
|
|
rows.Close()
|
|
r.KPIs.Done = len(r.DoneCards)
|
|
r.LeadTime = computeLeadTime(leadSamples)
|
|
r.TopAssigneesDone = topUsersFromCount(assigneeDoneCount, users, 5)
|
|
r.TopRequestersDone = topNamedFromCount(requesterDoneCount, 5)
|
|
r.TagsDone = topNamedFromCount(tagCount, 10)
|
|
|
|
_ = doneColIDs
|
|
_ = doneColNames
|
|
|
|
// --- Created (card_events kind=created) ----------------------------------
|
|
rows, err = db.conn.Query(`
|
|
SELECT e.card_id, e.actor_id, COALESCE(c.requester, '')
|
|
FROM card_events e
|
|
LEFT JOIN cards c ON c.id = e.card_id
|
|
WHERE e.kind = 'created'
|
|
AND e.created_at >= ? AND e.created_at < ?
|
|
`, startUTC, endUTC)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
assigneeCreatedCount := map[string]int{}
|
|
requesterAddedCount := map[string]int{}
|
|
createdN := 0
|
|
for rows.Next() {
|
|
var cardID string
|
|
var actor sql.NullString
|
|
var requester string
|
|
if err := rows.Scan(&cardID, &actor, &requester); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
createdN++
|
|
if actor.Valid && actor.String != "" {
|
|
assigneeCreatedCount[actor.String]++
|
|
}
|
|
if requester != "" {
|
|
requesterAddedCount[requester]++
|
|
}
|
|
}
|
|
rows.Close()
|
|
r.KPIs.Created = createdN
|
|
r.TopAssigneesCreated = topUsersFromCount(assigneeCreatedCount, users, 5)
|
|
r.TopRequestersAdded = topNamedFromCount(requesterAddedCount, 5)
|
|
|
|
// --- Moves del dia + hourly + reopened -----------------------------------
|
|
// Reopened = card que el dia X entro a una columna NO done HABIENDO estado
|
|
// en una done previa. Detectable comparando entered_at del dia con la
|
|
// entrada previa (mismo card_id).
|
|
rows, err = db.conn.Query(`
|
|
SELECT h.card_id, h.column_id, h.entered_at, h.actor_id, c.title, c.seq_num
|
|
FROM card_column_history h
|
|
JOIN cards c ON c.id = h.card_id
|
|
WHERE h.entered_at >= ? AND h.entered_at < ?
|
|
AND c.deleted_at IS NULL
|
|
ORDER BY h.entered_at ASC
|
|
`, startUTC, endUTC)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hourly := [24]int{}
|
|
type moveRow struct {
|
|
cardID, columnID, enteredAt, title string
|
|
actor sql.NullString
|
|
seqNum int
|
|
}
|
|
var moves []moveRow
|
|
for rows.Next() {
|
|
var m moveRow
|
|
if err := rows.Scan(&m.cardID, &m.columnID, &m.enteredAt, &m.actor, &m.title, &m.seqNum); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
moves = append(moves, m)
|
|
if ts, err := time.Parse(time.RFC3339Nano, m.enteredAt); err == nil {
|
|
h := ts.In(loc).Hour()
|
|
if h >= 0 && h < 24 {
|
|
hourly[h]++
|
|
}
|
|
}
|
|
}
|
|
rows.Close()
|
|
r.HourlyMoves = hourly
|
|
r.KPIs.Moves = len(moves)
|
|
for _, m := range moves {
|
|
// Solo interesa si la columna actual NO es done.
|
|
isDone := doneColIDs[m.columnID]
|
|
if isDone {
|
|
continue
|
|
}
|
|
// Hubo entrada previa en una columna done?
|
|
prevWasDone, prevColID := db.previousColumnWasDone(m.cardID, m.enteredAt, doneColIDs)
|
|
if prevWasDone {
|
|
entry := ReopenedEntry{
|
|
CardID: m.cardID,
|
|
Title: m.title,
|
|
SeqNum: m.seqNum,
|
|
FromColumn: allColNames[prevColID],
|
|
ToColumn: allColNames[m.columnID],
|
|
Ts: m.enteredAt,
|
|
}
|
|
if m.actor.Valid && m.actor.String != "" {
|
|
a := m.actor.String
|
|
entry.ActorID = &a
|
|
if n, ok := users[a]; ok {
|
|
nm := n
|
|
entry.ActorName = &nm
|
|
}
|
|
}
|
|
r.ReopenedCards = append(r.ReopenedCards, entry)
|
|
}
|
|
}
|
|
r.KPIs.Reopened = len(r.ReopenedCards)
|
|
|
|
// --- Stale buckets (cards activas hoy con N dias en misma columna) -------
|
|
r.StaleCards = db.staleBucketsAt(end, doneColIDs, allColNames)
|
|
|
|
// --- Bloqueado ms (lock_history que solapa con el dia) -------------------
|
|
r.KPIs.BlockedMs = db.blockedMsInRange(startUTC, endUTC)
|
|
|
|
// --- Archivadas hoy ------------------------------------------------------
|
|
var autoN, manualN int
|
|
if err := db.conn.QueryRow(`
|
|
SELECT COUNT(*) FROM cards
|
|
WHERE archived_at IS NOT NULL
|
|
AND archived_at >= ? AND archived_at < ?
|
|
AND deleted_at IS NULL
|
|
`, startUTC, endUTC).Scan(&autoN); err == nil {
|
|
// Heuristica: auto vs manual no se diferencia (no log explicito). Si
|
|
// la columna actual es is_done, asumimos auto. Mejor que nada.
|
|
_ = manualN
|
|
r.KPIs.ArchivedAuto = autoN
|
|
r.ArchivedToday = autoN
|
|
}
|
|
r.KPIs.DeadlinesMet = r.Deadlines.Met
|
|
r.KPIs.DeadlinesMissed = r.Deadlines.Missed
|
|
|
|
return r, nil
|
|
}
|
|
|
|
func (db *DB) userNameMap() (map[string]string, error) {
|
|
rows, err := db.conn.Query(`SELECT id, COALESCE(display_name,''), username FROM users`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]string{}
|
|
for rows.Next() {
|
|
var id, dn, un string
|
|
if err := rows.Scan(&id, &dn, &un); err != nil {
|
|
return nil, err
|
|
}
|
|
if dn != "" {
|
|
out[id] = dn
|
|
} else {
|
|
out[id] = un
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (db *DB) doneColumnIDs() (map[string]bool, map[string]string, error) {
|
|
rows, err := db.conn.Query(`SELECT id, name FROM columns WHERE is_done=1`)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer rows.Close()
|
|
ids := map[string]bool{}
|
|
names := map[string]string{}
|
|
for rows.Next() {
|
|
var id, n string
|
|
if err := rows.Scan(&id, &n); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
ids[id] = true
|
|
names[id] = n
|
|
}
|
|
return ids, names, nil
|
|
}
|
|
|
|
func (db *DB) allColumnNames() (map[string]string, error) {
|
|
rows, err := db.conn.Query(`SELECT id, name FROM columns`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[string]string{}
|
|
for rows.Next() {
|
|
var id, n string
|
|
if err := rows.Scan(&id, &n); err != nil {
|
|
return nil, err
|
|
}
|
|
out[id] = n
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// previousColumnWasDone returns whether the entry of `cardID` immediately
|
|
// before `enteredAt` was in a done column.
|
|
func (db *DB) previousColumnWasDone(cardID, enteredAt string, doneColIDs map[string]bool) (bool, string) {
|
|
var colID string
|
|
err := db.conn.QueryRow(`
|
|
SELECT column_id FROM card_column_history
|
|
WHERE card_id=? AND entered_at < ?
|
|
ORDER BY entered_at DESC
|
|
LIMIT 1
|
|
`, cardID, enteredAt).Scan(&colID)
|
|
if err != nil {
|
|
return false, ""
|
|
}
|
|
return doneColIDs[colID], colID
|
|
}
|
|
|
|
func (db *DB) staleBucketsAt(asOf time.Time, doneColIDs map[string]bool, colNames map[string]string) StaleBuckets {
|
|
out := StaleBuckets{D7: []StaleEntry{}, D14: []StaleEntry{}, D30: []StaleEntry{}}
|
|
rows, err := db.conn.Query(`
|
|
SELECT h.card_id, c.title, c.seq_num, h.column_id, h.entered_at
|
|
FROM card_column_history h
|
|
JOIN cards c ON c.id = h.card_id
|
|
WHERE h.exited_at IS NULL
|
|
AND c.deleted_at IS NULL
|
|
AND c.archived_at IS NULL
|
|
`)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var s StaleEntry
|
|
if err := rows.Scan(&s.CardID, &s.Title, &s.SeqNum, &s.ColumnID, &s.EnteredAt); err != nil {
|
|
continue
|
|
}
|
|
// Skip done columns (esos se auto-archivan; no son "estancados" activos).
|
|
if doneColIDs[s.ColumnID] {
|
|
continue
|
|
}
|
|
entered, err := time.Parse(time.RFC3339Nano, s.EnteredAt)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
days := int(asOf.Sub(entered).Hours() / 24)
|
|
if days < 7 {
|
|
continue
|
|
}
|
|
s.Days = days
|
|
s.ColumnName = colNames[s.ColumnID]
|
|
switch {
|
|
case days >= 30:
|
|
out.D30 = append(out.D30, s)
|
|
case days >= 14:
|
|
out.D14 = append(out.D14, s)
|
|
default:
|
|
out.D7 = append(out.D7, s)
|
|
}
|
|
}
|
|
sort.Slice(out.D7, func(i, j int) bool { return out.D7[i].Days > out.D7[j].Days })
|
|
sort.Slice(out.D14, func(i, j int) bool { return out.D14[i].Days > out.D14[j].Days })
|
|
sort.Slice(out.D30, func(i, j int) bool { return out.D30[i].Days > out.D30[j].Days })
|
|
return out
|
|
}
|
|
|
|
func (db *DB) blockedMsInRange(startUTC, endUTC string) int64 {
|
|
// Para cada periodo de lock, contar la interseccion con [start,end].
|
|
rows, err := db.conn.Query(`
|
|
SELECT locked_at, COALESCE(unlocked_at, ?) FROM card_lock_history
|
|
WHERE locked_at < ? AND COALESCE(unlocked_at, ?) > ?
|
|
`, endUTC, endUTC, endUTC, startUTC)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer rows.Close()
|
|
start, _ := time.Parse(time.RFC3339Nano, startUTC)
|
|
end, _ := time.Parse(time.RFC3339Nano, endUTC)
|
|
var total time.Duration
|
|
for rows.Next() {
|
|
var lstr, ustr string
|
|
if err := rows.Scan(&lstr, &ustr); err != nil {
|
|
continue
|
|
}
|
|
l, err := time.Parse(time.RFC3339Nano, lstr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
u, err := time.Parse(time.RFC3339Nano, ustr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if l.Before(start) {
|
|
l = start
|
|
}
|
|
if u.After(end) {
|
|
u = end
|
|
}
|
|
if u.After(l) {
|
|
total += u.Sub(l)
|
|
}
|
|
}
|
|
return total.Milliseconds()
|
|
}
|
|
|
|
func topUsersFromCount(m map[string]int, names map[string]string, k int) []UserCount {
|
|
out := make([]UserCount, 0, len(m))
|
|
for id, n := range m {
|
|
out = append(out, UserCount{UserID: id, Name: names[id], Count: n})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
|
if len(out) > k {
|
|
out = out[:k]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func topNamedFromCount(m map[string]int, k int) []NamedCount {
|
|
out := make([]NamedCount, 0, len(m))
|
|
for n, c := range m {
|
|
out = append(out, NamedCount{Name: n, Count: c})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count })
|
|
if len(out) > k {
|
|
out = out[:k]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func computeLeadTime(samples []int64) LeadTimeStats {
|
|
if len(samples) == 0 {
|
|
return LeadTimeStats{}
|
|
}
|
|
sorted := make([]int64, len(samples))
|
|
copy(sorted, samples)
|
|
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
|
|
var sum int64
|
|
for _, v := range sorted {
|
|
sum += v
|
|
}
|
|
p := func(q float64) int64 {
|
|
if len(sorted) == 0 {
|
|
return 0
|
|
}
|
|
idx := int(float64(len(sorted)-1) * q)
|
|
return sorted[idx]
|
|
}
|
|
return LeadTimeStats{
|
|
AvgMs: sum / int64(len(sorted)),
|
|
P50Ms: p(0.5),
|
|
P95Ms: p(0.95),
|
|
Samples: len(sorted),
|
|
}
|
|
}
|