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), } }