feat(kanban): reporte diario al click en dia del calendario (issue 0093)

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>
This commit is contained in:
2026-05-14 17:43:29 +02:00
parent 9d3ab5f0f3
commit fc7e6a34a7
10 changed files with 2497 additions and 1187 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-B70qRZGH.js"></script>
<script type="module" crossorigin src="/assets/index-zy-U5pO-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
+21
View File
@@ -437,6 +437,26 @@ func handleRestoreCard(db *DB) http.HandlerFunc {
}
}
// GET /api/reports/daily?date=YYYY-MM-DD&tz=Europe/Madrid
func handleDailyReport(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
date := r.URL.Query().Get("date")
if date == "" {
date = time.Now().UTC().Format("2006-01-02")
}
tz := r.URL.Query().Get("tz")
if tz == "" {
tz = "Europe/Madrid"
}
rep, err := db.DailyReportFor(date, tz)
if err != nil {
badRequest(w, err.Error())
return
}
infra.HTTPJSONResponse(w, http.StatusOK, rep)
}
}
// GET /api/archive
func handleListArchive(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -511,6 +531,7 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
{Method: "GET", Path: "/api/reports/daily", Handler: handleDailyReport(db)},
{Method: "GET", Path: "/api/archive", Handler: handleListArchive(db)},
{Method: "POST", Path: "/api/cards/{id}/archive", Handler: handleArchiveCard(db)},
{Method: "POST", Path: "/api/cards/{id}/unarchive", Handler: handleUnarchiveCard(db)},
+588
View File
@@ -0,0 +1,588 @@
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),
}
}