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),
}
}
+57
View File
@@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";
import { pw_kanban_login } from "../../../../frontend/functions/browser/pw_kanban_login";
const USER = process.env.KANBAN_USER || "e2e_user";
const PWD = process.env.KANBAN_PWD || "e2e_test_pw_2026";
/**
* Issue 0093: reporte diario al pulsar numero del dia en el calendario.
* Verifica: endpoint responde, calendario abre modal con titulo "Reporte diario",
* KPIs visibles, tabla de hechas presente.
*/
test.describe("daily report (issue 0093)", () => {
test("endpoint /api/reports/daily devuelve estructura esperada", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
const today = new Date().toISOString().slice(0, 10);
const res = await page.request.get(`/api/reports/daily?date=${today}`);
expect(res.status()).toBe(200);
const data = await res.json();
expect(data).toHaveProperty("kpis");
expect(data).toHaveProperty("done_cards");
expect(data).toHaveProperty("hourly_moves");
expect(Array.isArray(data.hourly_moves)).toBe(true);
expect(data.hourly_moves.length).toBe(24);
expect(data).toHaveProperty("stale_cards");
expect(data.stale_cards).toHaveProperty("d7");
expect(data.stale_cards).toHaveProperty("d14");
expect(data.stale_cards).toHaveProperty("d30");
});
test("click en numero del dia del calendario abre modal del reporte", async ({ page }) => {
await page.goto("/");
await pw_kanban_login(page, { username: USER, password: PWD });
// Switch to Calendario tab.
await page.getByRole("tab", { name: /Calendario/i }).click();
// Wait until the calendar cells render.
await page.waitForSelector('[data-test^="calendar-day-"]', { timeout: 5000 });
// Use yesterday — the seeded DB has activity there.
const yesterday = new Date(Date.now() - 24 * 3600 * 1000).toISOString().slice(0, 10);
const cellBtn = page.locator(`[data-test="calendar-day-${yesterday}"]`);
if ((await cellBtn.count()) === 0) {
// Fallback: click any visible day.
await page.locator('[data-test^="calendar-day-"]').first().dispatchEvent("click");
} else {
await cellBtn.dispatchEvent("click");
}
// Modal opens.
const modal = page.locator('[role="dialog"]').filter({ hasText: /Reporte diario/i });
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal.getByText("Hechas", { exact: false }).first()).toBeVisible();
await expect(modal.getByText("Movimientos", { exact: false }).first()).toBeVisible();
});
});
+18 -1
View File
@@ -72,6 +72,7 @@ import { CardForm } from "./components/CardForm";
import { CardEditPanel } from "./components/CardEditPanel";
import { ChatPanel } from "./components/ChatPanel";
import { CalendarView } from "./components/CalendarView";
import { DailyReportView } from "./components/DailyReport";
import { Dashboard } from "./components/Dashboard";
import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard";
@@ -720,6 +721,22 @@ export function App() {
window.setTimeout(() => setHighlightCardId(null), 3000);
}, []);
const handleOpenDailyReport = useCallback((date: string) => {
const id = modals.open({
title: "Reporte diario",
size: "90%",
children: (
<DailyReportView
date={date}
onJumpToCard={(cardId) => {
modals.close(id);
handleJumpToCard(cardId);
}}
/>
),
});
}, [handleJumpToCard]);
const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
setBoard((prev) => {
if (!prev) return prev;
@@ -1324,7 +1341,7 @@ export function App() {
</Box>
) : activeTab === "calendar" ? (
<Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} />
<CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} onOpenDailyReport={handleOpenDailyReport} />
</Box>
) : (
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
+84
View File
@@ -119,6 +119,90 @@ export function unarchiveCard(id: string): Promise<void> {
return fetchJSON(`/cards/${id}/unarchive`, { method: "POST" });
}
export interface DailyReport {
date: string;
tz: string;
start_ts: string;
end_ts: string;
kpis: {
done: number;
created: number;
moves: number;
blocked_ms: number;
deadlines_met: number;
deadlines_missed: number;
reopened: number;
archived_auto: number;
archived_manual: number;
};
top_assignees_done: { user_id: string; name: string; count: number }[];
top_assignees_created: { user_id: string; name: string; count: number }[];
top_requesters_added: { name: string; count: number }[];
top_requesters_done: { name: string; count: number }[];
done_cards: {
id: string;
seq_num: number;
title: string;
requester: string;
assignee_id: string | null;
assignee_name: string | null;
tags: string[];
column_id: string;
column_name: string;
completed_at: string;
created_at: string;
lead_time_ms: number;
color: string;
}[];
reopened_cards: {
card_id: string;
title: string;
seq_num: number;
from_column: string;
to_column: string;
ts: string;
actor_id: string | null;
actor_name: string | null;
}[];
stale_cards: {
d7: StaleEntry[];
d14: StaleEntry[];
d30: StaleEntry[];
};
lead_time: { avg_ms: number; p50_ms: number; p95_ms: number; samples: number };
hourly_moves: number[];
deadlines: {
met: number;
missed: number;
list: {
card_id: string;
title: string;
seq_num: number;
deadline: string;
completed_at: string;
late_ms: number;
}[];
};
tags_done: { name: string; count: number }[];
archived_today: number;
}
export interface StaleEntry {
card_id: string;
title: string;
seq_num: number;
column_id: string;
column_name: string;
entered_at: string;
days: number;
}
export function dailyReport(date: string, tz?: string): Promise<DailyReport> {
const params = new URLSearchParams({ date });
if (tz) params.set("tz", tz);
return fetchJSON(`/reports/daily?${params.toString()}`);
}
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
return fetchJSON(`/cards/${id}/move`, {
method: "POST",
+19 -4
View File
@@ -19,15 +19,17 @@ import { useEffect, useMemo, useState } from "react";
import * as api from "../api";
import type { Card, Metrics, User } from "../types";
// Hace clickable el numero del dia para abrir el reporte diario (issue 0093).
interface Props {
users: User[];
cards: Card[];
onJumpToCard?: (cardId: string) => void;
onOpenDailyReport?: (date: string) => void;
}
const DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
export function CalendarView({ users, cards, onJumpToCard }: Props) {
export function CalendarView({ users, cards, onJumpToCard, onOpenDailyReport }: Props) {
const [openDate, setOpenDate] = useState<string | null>(null);
const [month, setMonth] = useState<Date>(new Date());
const [assigneeId, setAssigneeId] = useState<string | null>(null);
@@ -199,9 +201,22 @@ export function CalendarView({ users, cards, onJumpToCard }: Props) {
}}
>
<Stack gap={2}>
<Text size="xs" fw={isToday ? 700 : 500} c={isToday ? "blue" : undefined}>
{dayNum}
</Text>
<UnstyledButton
onClick={() => cell.date && onOpenDailyReport?.(cell.date as string)}
title="Ver reporte diario"
style={{ alignSelf: "flex-start" }}
data-test={`calendar-day-${cell.date}`}
>
<Text
size="xs"
fw={isToday ? 700 : 500}
c={isToday ? "blue" : undefined}
td={onOpenDailyReport ? "underline" : undefined}
style={{ cursor: onOpenDailyReport ? "pointer" : "default" }}
>
{dayNum}
</Text>
</UnstyledButton>
{stats.created > 0 && (
<Group gap={3} wrap="nowrap">
<IconPlus size={10} color="var(--mantine-color-blue-5)" />
+523
View File
@@ -0,0 +1,523 @@
import {
Alert,
Avatar,
Badge,
Box,
Card as MCard,
Divider,
Group,
Loader,
Paper,
ScrollArea,
SimpleGrid,
Stack,
Table,
Text,
Title,
UnstyledButton,
} from "@mantine/core";
import { BarChart } from "@mantine/charts";
import {
IconAlertTriangle,
IconArrowBackUp,
IconCalendarStats,
IconCheck,
IconClock,
IconHourglass,
IconLock,
IconPlus,
IconRefresh,
IconTrendingUp,
} from "@tabler/icons-react";
import { useEffect, useMemo, useState } from "react";
import { dailyReport, type DailyReport as Report } from "../api";
import { formatDuration } from "./format";
import { tagColor } from "./colors";
interface Props {
date: string; // YYYY-MM-DD
onJumpToCard?: (cardId: string) => void;
}
function fmtDate(s: string): string {
try {
const d = new Date(s + "T00:00:00");
return d.toLocaleDateString("es-ES", {
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
} catch {
return s;
}
}
function KPI({
label,
value,
color,
icon,
sub,
}: {
label: string;
value: string | number;
color?: string;
icon?: React.ReactNode;
sub?: string;
}) {
return (
<Paper p="sm" withBorder radius="md">
<Group gap={6} mb={2} align="center">
{icon}
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
</Group>
<Text fz={28} fw={700} c={color}>
{value}
</Text>
{sub && (
<Text size="xs" c="dimmed">
{sub}
</Text>
)}
</Paper>
);
}
function RankingList<T extends { name: string; count: number; user_id?: string }>({
title,
rows,
emptyText,
withAvatar = false,
}: {
title: string;
rows: T[];
emptyText: string;
withAvatar?: boolean;
}) {
return (
<MCard withBorder radius="md" p="sm">
<Text fw={600} size="sm" mb={6}>
{title}
</Text>
{rows.length === 0 ? (
<Text size="xs" c="dimmed">
{emptyText}
</Text>
) : (
<Stack gap={4}>
{rows.map((r, i) => (
<Group key={(r.user_id || r.name) + i} gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
{withAvatar && (
<Avatar size={22} radius="xl" color={tagColor(r.name || String(i))}>
{(r.name || "?").slice(0, 2).toUpperCase()}
</Avatar>
)}
<Text size="sm" truncate>
{r.name || "(sin nombre)"}
</Text>
</Group>
<Badge size="sm" variant="light" color={i === 0 ? "teal" : "gray"}>
{r.count}
</Badge>
</Group>
))}
</Stack>
)}
</MCard>
);
}
export function DailyReportView({ date, onJumpToCard }: Props) {
const [data, setData] = useState<Report | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
setData(null);
setErr(null);
dailyReport(date)
.then(setData)
.catch((e) => setErr((e as Error).message));
}, [date]);
const hourlyChartData = useMemo(() => {
if (!data) return [];
return data.hourly_moves.map((n, h) => ({
hora: String(h).padStart(2, "0") + ":00",
movimientos: n,
}));
}, [data]);
if (err) {
return (
<Alert color="red" icon={<IconAlertTriangle size={14} />}>
{err}
</Alert>
);
}
if (!data) {
return (
<Group justify="center" p="xl">
<Loader size="sm" />
</Group>
);
}
const k = data.kpis;
const onTimePct =
k.deadlines_met + k.deadlines_missed > 0
? Math.round((k.deadlines_met / (k.deadlines_met + k.deadlines_missed)) * 100)
: null;
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap={6}>
<IconCalendarStats size={20} />
<Title order={4}>Reporte diario</Title>
</Group>
<Text size="sm" c="dimmed" tt="capitalize">
{fmtDate(data.date)}
</Text>
</Group>
<SimpleGrid cols={{ base: 2, sm: 4, md: 6 }} spacing="xs">
<KPI label="Hechas" value={k.done} color="teal" icon={<IconCheck size={14} color="var(--mantine-color-teal-6)" />} />
<KPI label="Creadas" value={k.created} icon={<IconPlus size={14} />} />
<KPI label="Movimientos" value={k.moves} icon={<IconRefresh size={14} />} />
<KPI
label="Bloqueado"
value={formatDuration(k.blocked_ms)}
color="yellow"
icon={<IconLock size={14} color="var(--mantine-color-yellow-6)" />}
/>
<KPI
label="Reabiertas"
value={k.reopened}
color={k.reopened > 0 ? "orange" : undefined}
icon={<IconArrowBackUp size={14} />}
/>
<KPI
label="Deadlines"
value={onTimePct != null ? `${onTimePct}%` : "—"}
color={onTimePct == null ? "dimmed" : onTimePct >= 80 ? "teal" : "red"}
sub={`${k.deadlines_met} on-time / ${k.deadlines_missed} vencidos`}
icon={<IconHourglass size={14} />}
/>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="xs">
<RankingList
title="Asignado: mas hechas"
rows={data.top_assignees_done}
emptyText="Sin hechas con asignado."
withAvatar
/>
<RankingList
title="Asignado: mas creadas"
rows={data.top_assignees_created}
emptyText="Sin actor en creadas."
withAvatar
/>
<RankingList
title="Solicitante: mas atendidas"
rows={data.top_requesters_done}
emptyText="Sin solicitantes con hechas."
/>
<RankingList
title="Solicitante: mas aportadas"
rows={data.top_requesters_added}
emptyText="Sin nuevas con solicitante."
/>
</SimpleGrid>
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb="xs">
<Text fw={600} size="sm">
Tareas hechas
</Text>
<Group gap={6}>
<Badge size="xs" variant="light">
N {k.done}
</Badge>
<Text size="xs" c="dimmed">
Lead time avg {data.lead_time.samples > 0 ? formatDuration(data.lead_time.avg_ms) : "—"} · p50{" "}
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p50_ms) : "—"} · p95{" "}
{data.lead_time.samples > 0 ? formatDuration(data.lead_time.p95_ms) : "—"}
</Text>
</Group>
</Group>
{data.done_cards.length === 0 ? (
<Text size="xs" c="dimmed">
Sin hechas en este dia.
</Text>
) : (
<ScrollArea style={{ maxHeight: 280 }} type="auto">
<Table verticalSpacing={4} fz="xs" highlightOnHover striped="even">
<Table.Thead>
<Table.Tr>
<Table.Th style={{ width: 70 }}>#</Table.Th>
<Table.Th>Titulo</Table.Th>
<Table.Th>Solicitante</Table.Th>
<Table.Th>Asignado</Table.Th>
<Table.Th>Tags</Table.Th>
<Table.Th style={{ width: 110 }}>Lead time</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.done_cards.map((c) => (
<Table.Tr key={c.id}>
<Table.Td>
<Text size="xs" c="dimmed">
{String(c.seq_num).padStart(5, "0")}
</Text>
</Table.Td>
<Table.Td>
<UnstyledButton onClick={() => onJumpToCard?.(c.id)} style={{ textAlign: "left" }}>
<Text size="xs" fw={500} td="underline">
{c.title}
</Text>
</UnstyledButton>
</Table.Td>
<Table.Td>
<Text size="xs">{c.requester || "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{c.assignee_name || "—"}</Text>
</Table.Td>
<Table.Td>
<Group gap={2} wrap="wrap">
{(c.tags || []).slice(0, 3).map((t) => (
<Badge key={t} size="xs" variant="light" color={tagColor(t)} radius="sm">
{t}
</Badge>
))}
</Group>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{formatDuration(c.lead_time_ms)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)}
</MCard>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="xs">
<MCard withBorder radius="md" p="sm">
<Group justify="space-between" mb={6}>
<Text fw={600} size="sm">
Movimientos por hora
</Text>
<Badge size="xs" variant="light">
{k.moves}
</Badge>
</Group>
{k.moves === 0 ? (
<Text size="xs" c="dimmed">
Sin movimientos.
</Text>
) : (
<BarChart
h={160}
data={hourlyChartData}
dataKey="hora"
series={[{ name: "movimientos", color: "blue.6" }]}
tickLine="y"
withTooltip
valueFormatter={(v: number) => String(v)}
/>
)}
</MCard>
<MCard withBorder radius="md" p="sm">
<Text fw={600} size="sm" mb={6}>
Tags trabajadas
</Text>
{data.tags_done.length === 0 ? (
<Text size="xs" c="dimmed">
Sin tags.
</Text>
) : (
<Group gap={4} wrap="wrap">
{data.tags_done.map((t) => (
<Badge key={t.name} variant="light" color={tagColor(t.name)} size="sm">
{t.name} · {t.count}
</Badge>
))}
</Group>
)}
</MCard>
</SimpleGrid>
{data.reopened_cards.length > 0 && (
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconArrowBackUp size={14} color="var(--mantine-color-orange-6)" />
<Text fw={600} size="sm">
Reabiertas (Done otra)
</Text>
<Badge size="xs" variant="light" color="orange">
{data.reopened_cards.length}
</Badge>
</Group>
<Stack gap={4}>
{data.reopened_cards.map((r) => (
<Group key={r.card_id + r.ts} gap={6} wrap="nowrap" justify="space-between">
<UnstyledButton onClick={() => onJumpToCard?.(r.card_id)} style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" truncate td="underline">
{r.title}
</Text>
</UnstyledButton>
<Text size="xs" c="dimmed">
{r.from_column} {r.to_column}
</Text>
{r.actor_name && (
<Badge size="xs" variant="light" color="cyan">
{r.actor_name}
</Badge>
)}
</Group>
))}
</Stack>
</MCard>
)}
{(data.deadlines.missed > 0 || data.deadlines.met > 0) && (
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconHourglass size={14} />
<Text fw={600} size="sm">
Deadlines
</Text>
<Badge size="xs" variant="light" color="teal">
{data.deadlines.met} on-time
</Badge>
<Badge size="xs" variant="light" color="red">
{data.deadlines.missed} vencidos
</Badge>
</Group>
{data.deadlines.list.length > 0 && (
<Stack gap={4}>
{data.deadlines.list.map((d) => (
<Group key={d.card_id} gap={6} justify="space-between" wrap="nowrap">
<UnstyledButton onClick={() => onJumpToCard?.(d.card_id)} style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" truncate td="underline">
{d.title}
</Text>
</UnstyledButton>
<Text size="xs" c="red">
+{formatDuration(d.late_ms)} tarde
</Text>
</Group>
))}
</Stack>
)}
</MCard>
)}
<MCard withBorder radius="md" p="sm">
<Group gap={6} mb={6}>
<IconTrendingUp size={14} />
<Text fw={600} size="sm">
Cards estancadas (al final del dia)
</Text>
<Badge size="xs" variant="light" color="orange">
{data.stale_cards.d7.length}d7
</Badge>
<Badge size="xs" variant="light" color="red">
{data.stale_cards.d14.length}d14
</Badge>
<Badge size="xs" variant="filled" color="red">
{data.stale_cards.d30.length}d30
</Badge>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="xs">
<Box>
<Text size="xs" fw={500} c="orange" mb={4}>
7-13 dias
</Text>
<Stack gap={2}>
{data.stale_cards.d7.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate>
{s.title}{" "}
<Text span c="dimmed" size="xs">
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d7.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
<Box>
<Text size="xs" fw={500} c="red" mb={4}>
14-29 dias
</Text>
<Stack gap={2}>
{data.stale_cards.d14.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate>
{s.title}{" "}
<Text span c="dimmed" size="xs">
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d14.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
<Box>
<Text size="xs" fw={500} c="red.8" mb={4}>
30+ dias
</Text>
<Stack gap={2}>
{data.stale_cards.d30.slice(0, 8).map((s) => (
<UnstyledButton key={s.card_id} onClick={() => onJumpToCard?.(s.card_id)}>
<Text size="xs" truncate fw={600}>
{s.title}{" "}
<Text span c="dimmed" size="xs" fw={400}>
· {s.column_name} · {s.days}d
</Text>
</Text>
</UnstyledButton>
))}
{data.stale_cards.d30.length === 0 && (
<Text size="xs" c="dimmed">
Ninguna.
</Text>
)}
</Stack>
</Box>
</SimpleGrid>
</MCard>
<Divider />
<Group gap={6} justify="space-between">
<Group gap={4}>
<IconClock size={14} />
<Text size="xs" c="dimmed">
TZ: {data.tz} · cards archivadas hoy: {data.archived_today}
</Text>
</Group>
</Group>
</Stack>
);
}