Migración a Python/Flask y adaptación para proyecto Infra
- Reemplaza Netlify Functions con servidor Python/Flask (server.py) - Adapta dashboard para contar tickets en lugar de Story Points - Conecta al proyecto IO (Infra) de Jira - Elimina secciones de QA y Code Review - Agrega métricas de solicitudes semanales y reporters - Excluye devs inactivos y unifica nombres duplicados Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
debug_fields.py — Lista todas las personas asignadas en el proyecto
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
email = os.getenv('JIRA_EMAIL')
|
||||||
|
token = os.getenv('JIRA_TOKEN')
|
||||||
|
host = os.getenv('JIRA_HOST', 'invera.atlassian.net')
|
||||||
|
project = os.getenv('JIRA_PROJECT', 'Infra')
|
||||||
|
|
||||||
|
credentials = base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||||
|
headers = { 'Authorization': f'Basic {credentials}', 'Accept': 'application/json' }
|
||||||
|
|
||||||
|
print(f"🔍 Conectando a {host} — Proyecto: {project}\n")
|
||||||
|
|
||||||
|
jql = f'project = {project} AND issuetype not in (Epic, "Sub-task", Subtask) AND created >= "2026-01-01" ORDER BY updated DESC'
|
||||||
|
url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=500&fields=summary,status,assignee"
|
||||||
|
res = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if not res.ok:
|
||||||
|
print(f"❌ Error {res.status_code}: {res.text[:300]}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
issues = res.json().get('issues', [])
|
||||||
|
print(f" → {len(issues)} tickets obtenidos\n")
|
||||||
|
|
||||||
|
assignees = {}
|
||||||
|
for issue in issues:
|
||||||
|
f = issue['fields']
|
||||||
|
name = (f.get('assignee') or {}).get('displayName', 'Sin asignar')
|
||||||
|
status_cat = (f.get('status') or {}).get('statusCategory', {}).get('key', '')
|
||||||
|
if name not in assignees:
|
||||||
|
assignees[name] = {'done': 0, 'pending': 0}
|
||||||
|
if status_cat == 'done':
|
||||||
|
assignees[name]['done'] += 1
|
||||||
|
else:
|
||||||
|
assignees[name]['pending'] += 1
|
||||||
|
|
||||||
|
print(f"{'─'*60}")
|
||||||
|
print(f"👥 TODAS LAS PERSONAS — {project} (desde 2026-01-01):")
|
||||||
|
print(f"{'─'*60}")
|
||||||
|
for name, counts in sorted(assignees.items()):
|
||||||
|
print(f" {name:35s} → {counts['done']:2} finalizados | {counts['pending']:2} pendientes")
|
||||||
|
print(f"{'─'*60}")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "IO-167",
|
||||||
|
"summary": "Pipeline - Configurar Bitbucket para Producción",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-166",
|
||||||
|
"summary": "ArgoCD - Actualizar ApplicationSet",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-165",
|
||||||
|
"summary": "Kubernetes & GitOps - Configurar prd-values.yaml",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-161",
|
||||||
|
"summary": "Documentar infraestructura y proceso de deployment",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-160",
|
||||||
|
"summary": "Ejecutar migraciones y deployment inicial",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-159",
|
||||||
|
"summary": "Configurar Ingress y DNS para dominio",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-158",
|
||||||
|
"summary": "Crear namespace y configurar RBAC en EKS",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-157",
|
||||||
|
"summary": "Configurar usuario readonly en RDS InvWallet",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-156",
|
||||||
|
"summary": " Crear ArgoCD Application para Dashboard",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-155",
|
||||||
|
"summary": "Configurar Bitbucket Pipeline en repositorio de la app",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
}
|
||||||
|
]
|
||||||
+15
-218
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Dashboard LABS — Story Points</title>
|
<title>Dashboard Infra — Tickets</title>
|
||||||
<link rel="stylesheet" href="./css/styles.css">
|
<link rel="stylesheet" href="./css/styles.css">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||||
<script type="module" src="./js/app.js" defer></script>
|
<script type="module" src="./js/app.js" defer></script>
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
<!-- Header Principal -->
|
<!-- Header Principal -->
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h1>📊 Dashboard LABS — Story Points</h1>
|
<h1>📊 Dashboard Infra</h1>
|
||||||
<div style="font-size:13px;opacity:.7;margin-top:4px;">
|
<div style="font-size:13px;opacity:.7;margin-top:4px;">
|
||||||
Proyecto: <strong>LABS</strong> · invera.atlassian.net
|
Proyecto: <strong>Infra</strong> · invera.atlassian.net
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-meta">
|
<div class="header-meta">
|
||||||
@@ -59,14 +59,14 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<div class="kpi-row">
|
<div class="kpi-row">
|
||||||
<div class="kpi progress" title="Suma de Story Points (SP) de todos los tickets actualmente en la columna En Progreso"><div class="kpi-value" id="kpi-ip">—</div><div class="kpi-label">SP En Progreso</div><div class="kpi-sub">Trabajo en curso</div></div>
|
<div class="kpi progress" title="Tickets actualmente en la columna En Progreso"><div class="kpi-value" id="kpi-ip">—</div><div class="kpi-label">Tickets En Progreso</div><div class="kpi-sub">Trabajo en curso</div></div>
|
||||||
<div class="kpi todo" title="Suma de Story Points de tickets pendientes que aún no comenzaron"><div class="kpi-value" id="kpi-todo">—</div><div class="kpi-label">SP Por Hacer</div><div class="kpi-sub">Backlog del periodo</div></div>
|
<div class="kpi todo" title="Tickets pendientes que aún no comenzaron"><div class="kpi-value" id="kpi-todo">—</div><div class="kpi-label">Tickets Por Hacer</div><div class="kpi-sub">Backlog del periodo</div></div>
|
||||||
<div class="kpi members" title="Cantidad de personas con carga de trabajo (tickets) asignados en este momento"><div class="kpi-value" id="kpi-members">—</div><div class="kpi-label">Devs con Carga</div><div class="kpi-sub">Con tickets pendientes</div></div>
|
<div class="kpi members" title="Cantidad de personas con tickets asignados en este momento"><div class="kpi-value" id="kpi-members">—</div><div class="kpi-label">DevOps</div><div class="kpi-sub">Con tickets pendientes</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="charts-grid">
|
<div class="charts-grid">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3>🔵 Pendientes por Desarrollador</h3>
|
<h3>🔵 Tkt pendientes</h3>
|
||||||
<div class="sub">Story Points con trabajo pendiente</div>
|
<div class="sub">Story Points con trabajo pendiente</div>
|
||||||
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
|
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
<div class="table-card">
|
<div class="table-card">
|
||||||
<h3>Detalle de Carga de Trabajo</h3>
|
<h3>Detalle de Carga de Trabajo</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Desarrollador</th><th>🔵 En Progreso</th><th>⏳ Por Hacer</th><th>Total Pendiente</th><th>Carga Visual</th></tr></thead>
|
<thead><tr><th>Desarrollador</th><th>🔵 En Progreso</th><th>⏳ Por Hacer</th><th>Total Pendiente</th><th>Distribución</th></tr></thead>
|
||||||
<tbody id="tableCurrentState"></tbody>
|
<tbody id="tableCurrentState"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,25 +114,20 @@
|
|||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<div class="kpi-row-3">
|
<div class="kpi-row-3">
|
||||||
<div class="kpi done" title="Suma total de Story Points entregados y completados en el período de tiempo seleccionado"><div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">SP Completados</div><div class="kpi-sub">en el período</div></div>
|
<div class="kpi done" title="Total de tickets completados en el período de tiempo seleccionado"><div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">Tickets Completados</div><div class="kpi-sub">en el período</div></div>
|
||||||
<div class="kpi members" title="Cantidad de desarrolladores que han cerrado al menos 1 Story Point en este período"><div class="kpi-value" id="kpi-devs-period">—</div><div class="kpi-label">Desarrolladores</div><div class="kpi-sub">que completaron SP</div></div>
|
<div class="kpi progress" title="Promedio de tickets resueltos por semana en el período seleccionado"><div class="kpi-value" id="kpi-avg-period">—</div><div class="kpi-label">Promedio tkts / semana</div><div class="kpi-sub" id="kpi-avg-period-sub">tickets por semana</div></div>
|
||||||
<div class="kpi progress" title="Promedio simple: Total de SP completados dividido por la cantidad de desarrolladores"><div class="kpi-value" id="kpi-avg-period">—</div><div class="kpi-label">SP promedio / dev</div><div class="kpi-sub">en el período</div></div>
|
<div class="kpi" style="border-color:#6554c0;" title="Promedio de tickets nuevos solicitados por semana en el período seleccionado"><div class="kpi-value" id="kpi-created-week">—</div><div class="kpi-label">Tkts solicitados / semana</div><div class="kpi-sub" id="kpi-created-week-sub">tickets por semana</div></div>
|
||||||
</div>
|
|
||||||
<div class="kpi-row-3" style="margin-top:-8px;">
|
|
||||||
<div class="kpi" style="border-color:#36b37e;" title="Lead Time: Tiempo total promedio que vive un ticket desde su creación hasta que es cerrado"><div class="kpi-value" id="kpi-lead-time">—</div><div class="kpi-label">Promedio Lead Time</div><div class="kpi-sub">Días (Creación a Fin)</div></div>
|
|
||||||
<div class="kpi" style="border-color:#00b8d9;" title="Cycle Time: Velocidad de desarrollo neto. Promedio en días desde que un developer mueve el ticket 'En Curso' hasta cerrarse"><div class="kpi-value" id="kpi-cycle-time">—</div><div class="kpi-label">Promedio Cycle Time</div><div class="kpi-sub">Días (En Curso a Fin)</div></div>
|
|
||||||
<div class="kpi warning" title="Variación entre el Story Point estimado al sprint vs el final real; un número en (+) indica Scope Creep (inflación de puntos)"><div class="kpi-value" id="kpi-sp-deviation">—</div><div class="kpi-label">Desviación SP</div><div class="kpi-sub" id="kpi-sp-deviation-sub">Original vs Final</div></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="charts-grid">
|
<div class="charts-grid">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3>✅ SP Completados — por Desarrollador</h3>
|
<h3>✅ Tickets Completados</h3>
|
||||||
<div class="sub">Período seleccionado</div>
|
<div class="sub">Período seleccionado</div>
|
||||||
<div style="position:relative;height:260px"><canvas id="chartDoneDev"></canvas></div>
|
<div style="position:relative;height:260px"><canvas id="chartDoneDev"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3>📈 Velocidad Histórica</h3>
|
<h3>📥 Solicitudes de Tickets</h3>
|
||||||
<div class="sub">SP completados mes a mes</div>
|
<div class="sub">Tickets creados por persona en el período</div>
|
||||||
<div style="position:relative;height:260px"><canvas id="chartVelocity"></canvas></div>
|
<div style="position:relative;height:260px"><canvas id="chartVelocity"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +138,7 @@
|
|||||||
<span id="teamAvgLabel" style="font-size:12px; font-weight:normal; background:#e6fcff; color:#0065ff; padding:4px 8px; border-radius:4px; display:none;"></span>
|
<span id="teamAvgLabel" style="font-size:12px; font-weight:normal; background:#e6fcff; color:#0065ff; padding:4px 8px; border-radius:4px; display:none;"></span>
|
||||||
</h3>
|
</h3>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Desarrollador</th><th>✅ SP Total</th><th>🎟️ Tickets</th><th>📉 Media SP/Ticket</th><th>% del impacto total vs Equipo</th></tr></thead>
|
<thead><tr><th>Desarrollador</th><th>✅ Tickets Total</th><th>📉 % del equipo</th></tr></thead>
|
||||||
<tbody id="tableHistory"></tbody>
|
<tbody id="tableHistory"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,207 +148,9 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- ── SECCIÓN: Revisión de Código (Code Review) ── -->
|
<!-- ── SECCIÓN: Revisión de Código (Code Review) ── -->
|
||||||
<div class="section-header" style="border-color:#00b8d9;">
|
|
||||||
<div class="dot" style="background:#00b8d9;"></div>
|
|
||||||
<h2>Revisión de Código (Code Review)</h2>
|
|
||||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Métricas de revisión entre pares antes de QA</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main">
|
|
||||||
<div class="section-block">
|
|
||||||
<!-- Colas Actuales -->
|
|
||||||
<div class="kpi-row" style="margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #dfe1e6;">
|
|
||||||
<div class="kpi warning" style="border-color:#ff8b00;" title="Tickets que se encuentran en este preciso instante en la columna Code Review esperando o siendo revisados">
|
|
||||||
<div class="kpi-value" id="kpi-cr-queue-now">—</div>
|
|
||||||
<div class="kpi-label">En Cola Actual CR</div>
|
|
||||||
<div class="kpi-sub" id="kpi-cr-queue-sp">— SP en espera</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi warning" title="Tickets que se encuentran actualmente y desde hace más de 2 días atascados solos en la columna de revisión">
|
|
||||||
<div class="kpi-value" id="kpi-cr-stalled">—</div>
|
|
||||||
<div class="kpi-label">Tickets Atascados CR</div>
|
|
||||||
<div class="kpi-sub">Demorados actualmente (más de 2 días)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Historial del período -->
|
|
||||||
<h3 style="font-size:14px; margin-bottom:12px; color:#172b4d;">Métricas del período seleccionado</h3>
|
|
||||||
<div class="kpi-row">
|
|
||||||
<div class="kpi" style="border-color:#00b8d9;" title="Cantidad de tickets que registraron actividad en la fase de Code Review">
|
|
||||||
<div class="kpi-value" id="kpi-cr-tickets">—</div>
|
|
||||||
<div class="kpi-label">Tickets en CR</div>
|
|
||||||
<div class="kpi-sub">Procesados en el período</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi" style="border-color:#00b8d9;">
|
|
||||||
<div class="kpi-value" id="kpi-cr-p50">—</div>
|
|
||||||
<div class="kpi-label">
|
|
||||||
<div class="pop-trigger" style="border-bottom: 1px dotted #5e6c84;">Tiempo P50 CR
|
|
||||||
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left;">
|
|
||||||
<div class="pop-item" style="font-size:12px;"><strong>Percentil 50 (Mediana)</strong><br>El 50% de las revisiones de código se completan en esta cantidad de horas o menos.<br><br>A diferencia del promedio tradicional, el P50 muestra un valor "habitual" sin ser distorsionado por tickets que queden atascados durante meses.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-sub">Horas (Mediana)</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi" style="border-color:#ff8b00;">
|
|
||||||
<div class="kpi-value" id="kpi-cr-p90">—</div>
|
|
||||||
<div class="kpi-label">
|
|
||||||
<div class="pop-trigger" style="border-bottom: 1px dotted #5e6c84;">Tiempos P90 y P99
|
|
||||||
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left;">
|
|
||||||
<div class="pop-item" style="font-size:12px;"><strong>Métricas de Cuello de Botella</strong><br>Muestran los "peores escenarios" posibles.<br><br><strong>P90:</strong> El 90% de los tickets logra pasar en este tiempo. El 10% restante demoró aún más.<br><strong>P99:</strong> El 99% pasa en este tiempo. Ideal para medir los bloqueos máximos.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-sub" id="kpi-cr-p99-sub">P99: — hs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="charts-grid">
|
|
||||||
<div class="chart-card full">
|
|
||||||
<h3>📈 Tiempo Promedio en Code Review — Mes a Mes</h3>
|
|
||||||
<div class="sub">Evolución de la velocidad de revisión de pares (horas)</div>
|
|
||||||
<div style="position:relative;height:240px"><canvas id="chartCrMonthly"></canvas></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ── SECCIÓN 3: Control de Calidad (QA) ── -->
|
|
||||||
<div class="section-header qa">
|
|
||||||
<div class="dot qa"></div>
|
|
||||||
<h2>Control de Calidad (QA)</h2>
|
|
||||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Métricas del proceso de QA · Tiempos, colas y devoluciones</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main">
|
|
||||||
<div class="section-block">
|
|
||||||
|
|
||||||
<!-- Colas Actuales -->
|
|
||||||
<div class="kpi-row" style="margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #dfe1e6;">
|
|
||||||
<div class="kpi qa-queue" style="border-color:#ff8b00;" title="Tickets que se encuentran en este preciso instante en la columna Ready to QA">
|
|
||||||
<div class="kpi-value" id="kpi-qa-queue-now">—</div>
|
|
||||||
<div class="kpi-label">En Cola Actual RTQA</div>
|
|
||||||
<div class="kpi-sub" id="kpi-qa-queue-now-sp">— SP esperando</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi qa" style="border-color:#0065ff;" title="Tickets que se encuentran en este preciso instante siendo probados en la columna QA">
|
|
||||||
<div class="kpi-value" id="kpi-qa-active-now">—</div>
|
|
||||||
<div class="kpi-label">En Cola Actual QA</div>
|
|
||||||
<div class="kpi-sub" id="kpi-qa-active-now-sp">— SP en prueba</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- KPIs del período -->
|
|
||||||
<h3 style="font-size:14px; margin-bottom:12px; color:#172b4d;">Métricas del período seleccionado</h3>
|
|
||||||
<div class="kpi-row-3" style="margin-bottom:16px;">
|
|
||||||
<div class="kpi qa" title="Total de tickets que han pasado por QA">
|
|
||||||
<div class="kpi-value" id="kpi-qa-tickets">—</div>
|
|
||||||
<div class="kpi-label">Tickets QA</div>
|
|
||||||
<div class="kpi-sub">Procesados en el período</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi qa-time">
|
|
||||||
<div class="kpi-value" id="kpi-qa-p50">—</div>
|
|
||||||
<div class="kpi-label">
|
|
||||||
<div class="pop-trigger" style="border-bottom: 1px dotted #5e6c84;">Tiempo P50 QA
|
|
||||||
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left;">
|
|
||||||
<div class="pop-item" style="font-size:12px;"><strong>Percentil 50 (Mediana)</strong><br>El 50% de las pruebas se completan en esta cantidad de horas netas o menos.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-sub">Horas en prueba</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi qa-time" style="border-color:#ff8b00;">
|
|
||||||
<div class="kpi-value" id="kpi-qa-p90">—</div>
|
|
||||||
<div class="kpi-label">
|
|
||||||
<div class="pop-trigger" style="border-bottom: 1px dotted #5e6c84;">Tiempos P90 y P99 QA
|
|
||||||
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left;">
|
|
||||||
<div class="pop-item" style="font-size:12px;"><strong>Cuellos de Botella QA</strong><br><strong>P90:</strong> El 90% logran probarse en este tiempo.<br><strong>P99:</strong> El 99% logra terminar en este tiempo.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-sub" id="kpi-qa-p99-sub">P99: — hs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="kpi-row-3">
|
|
||||||
<div class="kpi returned" title="Tickets que sufrieron rechazos en revisiones (Code Review, Ready to QA o QA) y volvieron a desarrollo.">
|
|
||||||
<div class="kpi-value" id="kpi-qa-returned">—</div>
|
|
||||||
<div class="kpi-label">Tickets con Retrabajo</div>
|
|
||||||
<div class="kpi-sub" id="kpi-qa-returned-pct">Sufrieron rechazos</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi qa-queue">
|
|
||||||
<div class="kpi-value" id="kpi-qa-rtqa-p50">—</div>
|
|
||||||
<div class="kpi-label">
|
|
||||||
<div class="pop-trigger" style="border-bottom: 1px dotted #5e6c84;">Tiempo P50 RTQA
|
|
||||||
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left;">
|
|
||||||
<div class="pop-item" style="font-size:12px;"><strong>Percentil 50 (Mediana)</strong><br>El 50% de los tickets espera esta cantidad de horas (o menos) en la cola antes de ser tomado por QA.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-sub">Horas en cola</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi qa-queue" style="border-color:#ff8b00;">
|
|
||||||
<div class="kpi-value" id="kpi-qa-rtqa-p90">—</div>
|
|
||||||
<div class="kpi-label">
|
|
||||||
<div class="pop-trigger" style="border-bottom: 1px dotted #5e6c84;">Tiempos P90 y P99 Cola
|
|
||||||
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left;">
|
|
||||||
<div class="pop-item" style="font-size:12px;"><strong>Cuellos de Botella RTQA</strong><br>Casos atascados sin tester asignado.<br><br><strong>P90:</strong> El 90% de los tickets sale de la cola en este tiempo.<br><strong>P99:</strong> Bloqueos máximos en la cola.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="kpi-sub" id="kpi-qa-rtqa-p99-sub">P99: — hs</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gráficos fila 1 -->
|
|
||||||
<div class="charts-grid">
|
|
||||||
<div class="chart-card">
|
|
||||||
<h3>📊 Distribución de Tiempos en QA</h3>
|
|
||||||
<div class="sub">Rangos: <30min · 30min–2hs · 2–8hs · >8hs</div>
|
|
||||||
<div style="position:relative;height:240px"><canvas id="chartQaDist"></canvas></div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-card">
|
|
||||||
<h3>🔄 Flujo Directo vs Retrabajo</h3>
|
|
||||||
<div class="sub">Tickets con 0 rechazos vs con devoluciones (CR/QA)</div>
|
|
||||||
<div style="position:relative;height:240px"><canvas id="chartQaReturned"></canvas></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gráficos fila 2 -->
|
|
||||||
<div class="charts-grid">
|
|
||||||
<div class="chart-card">
|
|
||||||
<h3>📈 Tiempo Promedio en QA — Mes a Mes</h3>
|
|
||||||
<div class="sub">Evolución de la velocidad de revisión (horas)</div>
|
|
||||||
<div style="position:relative;height:240px"><canvas id="chartQaMonthly"></canvas></div>
|
|
||||||
</div>
|
|
||||||
<div class="chart-card">
|
|
||||||
<h3>⏳ Tiempo en Cola RTQA — Mes a Mes</h3>
|
|
||||||
<div class="sub">Horas promedio esperando ser tomado por QA</div>
|
|
||||||
<div style="position:relative;height:240px"><canvas id="chartRtqaMonthly"></canvas></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabla de tickets QA -->
|
|
||||||
<div class="table-card">
|
|
||||||
<h3>🔍 Detalle de Tickets — QA</h3>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Ticket</th>
|
|
||||||
<th>Resumen</th>
|
|
||||||
<th>⏱️ Dev (hs)</th>
|
|
||||||
<th>⏱️ CR (hs)</th>
|
|
||||||
<th>⏱️ QA (hs)</th>
|
|
||||||
<th>🔄 Veces devuelto</th>
|
|
||||||
<th>Estado</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="tableQA"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<footer>
|
<footer>
|
||||||
Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
|
Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ async function loadAndRender(forceRefresh = false) {
|
|||||||
|
|
||||||
renderCurrentSection();
|
renderCurrentSection();
|
||||||
renderHistorySection();
|
renderHistorySection();
|
||||||
renderCRSection();
|
|
||||||
renderQASection();
|
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
errorBanner.style.display = 'block';
|
errorBanner.style.display = 'block';
|
||||||
@@ -188,10 +186,17 @@ function renderCurrentSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Gráfico: Comparativa IP vs Todo (Stacked)
|
// Gráfico: Comparativa IP vs Todo (Stacked)
|
||||||
const people = Object.keys(ad).sort((a,b) => (ad[b].in_progress+ad[b].todo)-(ad[a].in_progress+ad[a].todo));
|
// "Sin asignar" siempre va último y con color gris
|
||||||
|
const people = Object.keys(ad).sort((a, b) => {
|
||||||
|
if (a === 'Sin asignar') return 1;
|
||||||
|
if (b === 'Sin asignar') return -1;
|
||||||
|
return (ad[b].in_progress + ad[b].todo) - (ad[a].in_progress + ad[a].todo);
|
||||||
|
});
|
||||||
|
const colorIP = people.map(p => p === 'Sin asignar' ? '#97a0af' : '#0065ff');
|
||||||
|
const colorTodo = people.map(p => p === 'Sin asignar' ? '#c1c7d0' : '#ff8b00');
|
||||||
chartsManager.drawBarChart('currentState', 'chartCurrentState', people.map(shortName), [
|
chartsManager.drawBarChart('currentState', 'chartCurrentState', people.map(shortName), [
|
||||||
{ label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 },
|
{ label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:colorIP, borderRadius:3 },
|
||||||
{ label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:'#ff8b00', borderRadius:3 },
|
{ label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:colorTodo, borderRadius:3 },
|
||||||
], { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
|
], { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
|
||||||
|
|
||||||
// Tabla detallada (Solo pendientes)
|
// Tabla detallada (Solo pendientes)
|
||||||
@@ -199,10 +204,20 @@ function renderCurrentSection() {
|
|||||||
tbody.innerHTML = '';
|
tbody.innerHTML = '';
|
||||||
people.forEach(p => {
|
people.forEach(p => {
|
||||||
const d = ad[p], total = d.in_progress + d.todo;
|
const d = ad[p], total = d.in_progress + d.todo;
|
||||||
const c = chartsManager.personColor(p);
|
const c = p === 'Sin asignar' ? '#97a0af' : chartsManager.personColor(p);
|
||||||
|
|
||||||
const popoverIP = d.issues_ip.map(i => `<div class="pop-item"><a href="${getJiraUrl(i.key)}" target="_blank">${i.key}</a>: ${i.summary.slice(0,30)}... <strong>(${i.sp} SP)</strong></div>`).join('');
|
const hasIPAlert = d.issues_ip.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
|
||||||
const popoverTodo = d.issues_todo.map(i => `<div class="pop-item"><a href="${getJiraUrl(i.key)}" target="_blank">${i.key}</a>: ${i.summary.slice(0,30)}... <strong>(${i.sp} SP)</strong></div>`).join('');
|
const hasTodoAlert = d.issues_todo.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
|
||||||
|
|
||||||
|
const genPopover = (issues) => issues.map(i => {
|
||||||
|
let badge = '';
|
||||||
|
if(i.warnings && i.warnings.some(w => w.type === 'expired')) badge = '<span style="color:#ff5630;font-size:11px;font-weight:bold;margin-left:4px;">(Vencido)</span>';
|
||||||
|
else if(i.warnings && i.warnings.some(w => w.type === 'stalled')) badge = '<span style="color:#ffab00;font-size:11px;font-weight:bold;margin-left:4px;">(Estancado)</span>';
|
||||||
|
return `<div class="pop-item" style="display:flex;align-items:center;white-space:nowrap;"><a href="${getJiraUrl(i.key)}" target="_blank" style="margin-right:6px;">${i.key}</a> <span style="overflow:hidden;text-overflow:ellipsis;max-width:180px;" title="${i.summary.replace(/"/g, '"')}">${i.summary}</span> <strong style="margin-left:auto;padding-left:8px;">(${i.sp} SP)</strong>${badge}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const popoverIP = genPopover(d.issues_ip);
|
||||||
|
const popoverTodo = genPopover(d.issues_todo);
|
||||||
|
|
||||||
tbody.innerHTML += `<tr>
|
tbody.innerHTML += `<tr>
|
||||||
<td>
|
<td>
|
||||||
@@ -210,17 +225,17 @@ function renderCurrentSection() {
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="pop-trigger">
|
<div class="pop-trigger">
|
||||||
<span class="pill progress">${d.in_progress} SP</span>
|
<span class="pill progress" ${hasIPAlert ? 'style="border-color:#ff5630;color:#ff5630;"' : ''}>${d.in_progress} tkts${hasIPAlert ? ' ⚠️' : ''}</span>
|
||||||
<div class="pop-content">${popoverIP || 'No hay tickets'}</div>
|
<div class="pop-content">${popoverIP || 'No hay tickets'}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="pop-trigger">
|
<div class="pop-trigger">
|
||||||
<span class="pill todo">${d.todo} SP</span>
|
<span class="pill todo" ${hasTodoAlert ? 'style="border-color:#ff5630;color:#ff5630;"' : ''}>${d.todo} tkts${hasTodoAlert ? ' ⚠️' : ''}</span>
|
||||||
<div class="pop-content">${popoverTodo || 'No hay tickets'}</div>
|
<div class="pop-content">${popoverTodo || 'No hay tickets'}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><strong>${total} SP</strong></td>
|
<td><strong>${total} tkts</strong></td>
|
||||||
<td><div class="pbar-wrap">
|
<td><div class="pbar-wrap">
|
||||||
<div class="pbar-ip" style="width:${pct(d.in_progress, total)}%"></div>
|
<div class="pbar-ip" style="width:${pct(d.in_progress, total)}%"></div>
|
||||||
<div class="pbar-todo" style="width:${pct(d.todo, total)}%"></div>
|
<div class="pbar-todo" style="width:${pct(d.todo, total)}%"></div>
|
||||||
@@ -266,9 +281,23 @@ function renderHistorySection() {
|
|||||||
labelEl.style.display = 'none';
|
labelEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calcular semanas del período seleccionado
|
||||||
|
const periodDays = state.selectedPeriod === 'all' ? 365
|
||||||
|
: state.selectedPeriod === '1d' ? 1
|
||||||
|
: parseInt(state.selectedPeriod.replace('d', ''));
|
||||||
|
const periodWeeks = Math.max(periodDays / 7, 1);
|
||||||
|
const totalTickets = Object.values(periodTicketCount).reduce((a,b) => a+b, 0);
|
||||||
|
const avgPerWeek = (totalTickets / periodWeeks).toFixed(1);
|
||||||
|
|
||||||
document.getElementById('kpi-done-period').textContent = totalSP;
|
document.getElementById('kpi-done-period').textContent = totalSP;
|
||||||
document.getElementById('kpi-devs-period').textContent = devsWithActiveTickets;
|
document.getElementById('kpi-avg-period').textContent = avgPerWeek;
|
||||||
document.getElementById('kpi-avg-period').textContent = devsWithActiveTickets > 0 ? Math.round(totalSP / devsWithActiveTickets) : 0;
|
|
||||||
|
// Tickets solicitados (creados) por semana
|
||||||
|
const ticketsCreated = data.ticketsCreated || 0;
|
||||||
|
const avgCreatedPerWeek = (ticketsCreated / periodWeeks).toFixed(1);
|
||||||
|
document.getElementById('kpi-created-week').textContent = avgCreatedPerWeek;
|
||||||
|
const createdSubEl = document.getElementById('kpi-created-week-sub');
|
||||||
|
if (createdSubEl) createdSubEl.textContent = `${ticketsCreated} tkts en el período`;
|
||||||
|
|
||||||
// Gráfico de impacto personal (Terminados) (solo mayores a 0)
|
// Gráfico de impacto personal (Terminados) (solo mayores a 0)
|
||||||
const sortedGraph = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
|
const sortedGraph = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
|
||||||
@@ -303,195 +332,26 @@ function renderHistorySection() {
|
|||||||
|
|
||||||
tbody.innerHTML += `<tr>
|
tbody.innerHTML += `<tr>
|
||||||
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
||||||
<td><span class="pill done">${sp} SP</span></td>
|
<td><span class="pill done">${tickets} tkts</span></td>
|
||||||
<td><strong>${tickets} tkts</strong></td>
|
|
||||||
<td><span style="font-size:13px;color:#172b4d;">${avgPerTicket} SP/tkt</span></td>
|
|
||||||
<td>${badgeHtml}</td>
|
<td>${badgeHtml}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delivery KPIs
|
|
||||||
if (data.deliveryData) {
|
|
||||||
const { avgLeadTime, avgCycleTime, spOriginal, spFinal, spDeviationPct } = data.deliveryData;
|
|
||||||
document.getElementById('kpi-lead-time').textContent = avgLeadTime;
|
|
||||||
document.getElementById('kpi-cycle-time').textContent = avgCycleTime;
|
|
||||||
|
|
||||||
const deviationPrefix = spDeviationPct > 0 ? '+' : '';
|
|
||||||
document.getElementById('kpi-sp-deviation').textContent = `${deviationPrefix}${spDeviationPct}%`;
|
|
||||||
document.getElementById('kpi-sp-deviation-sub').textContent = `${spOriginal} orig. → ${spFinal} fin.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gráfico de Velocidad Mensual
|
// Gráfico de Solicitudes por persona (reporter)
|
||||||
const months = Object.keys(data.monthlyDone).sort();
|
const reporterData = data.reporterData || {};
|
||||||
const allDevsInHistory = [...new Set(months.flatMap(m => Object.keys(data.monthlyDone[m])))];
|
const sortedReporters = Object.entries(reporterData).sort((a, b) => b[1] - a[1]);
|
||||||
|
chartsManager.drawBarChart('velocity', 'chartVelocity',
|
||||||
// Si hay filtro de dev, solo mostrar ese dev en el gráfico de velocidad
|
sortedReporters.map(([p]) => shortName(p)),
|
||||||
const visibleDevs = state.selectedDev === 'all' ? allDevsInHistory : [state.selectedDev];
|
|
||||||
|
|
||||||
chartsManager.drawBarChart('velocity', 'chartVelocity', months, visibleDevs.map(p => ({
|
|
||||||
label: shortName(p),
|
|
||||||
data: months.map(m => data.monthlyDone[m][p] || 0),
|
|
||||||
backgroundColor: chartsManager.personColor(p), borderRadius: 3
|
|
||||||
})), { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── CODE REVIEW SECTION ────────────────────────────────────────────────────
|
|
||||||
function renderCRSection() {
|
|
||||||
const data = api.data;
|
|
||||||
if (!data || !data.crData) return;
|
|
||||||
|
|
||||||
const cr = data.crData;
|
|
||||||
const cur = data.currentStates || { codeReview: {count:0, sp:0} };
|
|
||||||
|
|
||||||
document.getElementById('kpi-cr-queue-now').textContent = cur.codeReview.count;
|
|
||||||
document.getElementById('kpi-cr-queue-sp').textContent = cur.codeReview.sp + ' SP en espera';
|
|
||||||
|
|
||||||
document.getElementById('kpi-cr-tickets').textContent = cr.tickets ?? '—';
|
|
||||||
document.getElementById('kpi-cr-p50').textContent = cr.p50Cr != null ? cr.p50Cr + 'hs' : '—';
|
|
||||||
document.getElementById('kpi-cr-p90').textContent = cr.p90Cr != null ? cr.p90Cr + 'hs' : '—';
|
|
||||||
document.getElementById('kpi-cr-p99-sub').textContent = cr.p99Cr != null ? 'P99: ' + cr.p99Cr + ' hs' : 'P99: — hs';
|
|
||||||
document.getElementById('kpi-cr-stalled').textContent = (cr.stalledIssues?.length) || '0';
|
|
||||||
|
|
||||||
const crMonths = Object.keys(cr.monthlyAvgCr || {}).sort();
|
|
||||||
chartsManager.drawLineChart(
|
|
||||||
'crMonthly', 'chartCrMonthly',
|
|
||||||
crMonths,
|
|
||||||
[{
|
[{
|
||||||
label: 'Prom. tiempo en CR (hs)',
|
label: 'Tickets solicitados',
|
||||||
data: crMonths.map(m => cr.monthlyAvgCr[m] || 0),
|
data: sortedReporters.map(([, v]) => v),
|
||||||
borderColor: '#00b8d9',
|
backgroundColor: sortedReporters.map(([p]) => chartsManager.personColor(p)),
|
||||||
backgroundColor: 'rgba(0,184,217,.12)',
|
borderRadius: 5
|
||||||
fill: true,
|
|
||||||
pointBackgroundColor: '#00b8d9',
|
|
||||||
borderWidth: 2,
|
|
||||||
}],
|
}],
|
||||||
{ plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } }
|
{ plugins:{ legend:{ display:false } }, scales:{ x:{ grid:{ display:false } } } }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── QA SECTION ─────────────────────────────────────────────────────────────
|
|
||||||
function renderQASection() {
|
|
||||||
const data = api.data;
|
|
||||||
if (!data || !data.qaData) return;
|
|
||||||
|
|
||||||
const qa = data.qaData;
|
|
||||||
const cur = data.currentStates || { readyToQa: {count:0, sp:0}, inQa: {count:0, sp:0} };
|
|
||||||
|
|
||||||
// ── COLAS ACTUALES ─────────────────────────────────────────────────────────
|
|
||||||
document.getElementById('kpi-qa-queue-now').textContent = cur.readyToQa.count;
|
|
||||||
document.getElementById('kpi-qa-queue-now-sp').textContent = cur.readyToQa.sp + ' SP esperando';
|
|
||||||
document.getElementById('kpi-qa-active-now').textContent = cur.inQa.count;
|
|
||||||
document.getElementById('kpi-qa-active-now-sp').textContent = cur.inQa.sp + ' SP en prueba';
|
|
||||||
|
|
||||||
// ── KPIs DEL PERÍODO ──────────────────────────────────────────────────────
|
|
||||||
document.getElementById('kpi-qa-tickets').textContent = qa.tickets ?? '—';
|
|
||||||
document.getElementById('kpi-qa-returned').textContent = qa.ticketsReturned ?? '—';
|
|
||||||
|
|
||||||
document.getElementById('kpi-qa-p50').textContent = qa.p50Qa != null ? qa.p50Qa + 'hs' : '—';
|
|
||||||
document.getElementById('kpi-qa-p90').textContent = qa.p90Qa != null ? qa.p90Qa + 'hs' : '—';
|
|
||||||
document.getElementById('kpi-qa-p99-sub').textContent = qa.p99Qa != null ? 'P99: ' + qa.p99Qa + ' hs' : 'P99: — hs';
|
|
||||||
|
|
||||||
document.getElementById('kpi-qa-rtqa-p50').textContent = qa.p50Rtqa != null ? qa.p50Rtqa + 'hs' : '—';
|
|
||||||
document.getElementById('kpi-qa-rtqa-p90').textContent = qa.p90Rtqa != null ? qa.p90Rtqa + 'hs' : '—';
|
|
||||||
document.getElementById('kpi-qa-rtqa-p99-sub').textContent = qa.p99Rtqa != null ? 'P99: ' + qa.p99Rtqa + ' hs' : 'P99: — hs';
|
|
||||||
|
|
||||||
const pctRet = qa.tickets > 0 ? ((qa.ticketsReturned / qa.tickets) * 100).toFixed(1) : 0;
|
|
||||||
document.getElementById('kpi-qa-returned-pct').textContent =
|
|
||||||
`${pctRet}% del total sufrió al menos 1 rechazo`;
|
|
||||||
|
|
||||||
// ── GRÁFICO 1: Distribución de tiempos en QA (doughnut) ───────────────────
|
|
||||||
const buckets = { '<30min': 0, '30min–2hs': 0, '2–8hs': 0, '>8hs': 0 };
|
|
||||||
(qa.issues || []).forEach(i => {
|
|
||||||
const hs = i.timeQa;
|
|
||||||
if (hs < 0.5) buckets['<30min']++;
|
|
||||||
else if (hs < 2) buckets['30min–2hs']++;
|
|
||||||
else if (hs < 8) buckets['2–8hs']++;
|
|
||||||
else buckets['>8hs']++;
|
|
||||||
});
|
|
||||||
chartsManager.drawDoughnutChart(
|
|
||||||
'qaDist', 'chartQaDist',
|
|
||||||
Object.keys(buckets),
|
|
||||||
Object.values(buckets),
|
|
||||||
['#36b37e', '#00b8d9', '#ff8b00', '#ff5630']
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── GRÁFICO 2: Devueltos vs Aprobados (doughnut) ──────────────────────────
|
|
||||||
const aprobados = qa.tickets - qa.ticketsReturned;
|
|
||||||
chartsManager.drawDoughnutChart(
|
|
||||||
'qaReturned', 'chartQaReturned',
|
|
||||||
['✅ Flujo directo (0 rechazos)', '🔄 Retrabajo (Rechazo en CR/QA)'],
|
|
||||||
[aprobados, qa.ticketsReturned],
|
|
||||||
['#36b37e', '#ff5630']
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── GRÁFICO 3: Tiempo promedio QA mes a mes (line) ────────────────────────
|
|
||||||
const qaMonths = Object.keys(qa.monthlyAvgQa || {}).sort();
|
|
||||||
chartsManager.drawLineChart(
|
|
||||||
'qaMonthly', 'chartQaMonthly',
|
|
||||||
qaMonths,
|
|
||||||
[{
|
|
||||||
label: 'Prom. tiempo en QA (hs)',
|
|
||||||
data: qaMonths.map(m => qa.monthlyAvgQa[m] || 0),
|
|
||||||
borderColor: '#6554c0',
|
|
||||||
backgroundColor: 'rgba(101,84,192,.12)',
|
|
||||||
fill: true,
|
|
||||||
pointBackgroundColor: '#6554c0',
|
|
||||||
borderWidth: 2,
|
|
||||||
}],
|
|
||||||
{ plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── GRÁFICO 4: Tiempo en cola RTQA mes a mes (bar) ────────────────────────
|
|
||||||
const rtqaMonths = Object.keys(qa.monthlyAvgRtqa || {}).sort();
|
|
||||||
chartsManager.drawBarChart(
|
|
||||||
'rtqaMonthly', 'chartRtqaMonthly',
|
|
||||||
rtqaMonths,
|
|
||||||
[{
|
|
||||||
label: 'Prom. tiempo en cola RTQA (hs)',
|
|
||||||
data: rtqaMonths.map(m => qa.monthlyAvgRtqa[m] || 0),
|
|
||||||
backgroundColor: '#00b8d9',
|
|
||||||
borderRadius: 4,
|
|
||||||
}],
|
|
||||||
{ plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── TABLA de tickets QA ───────────────────────────────────────────────────
|
|
||||||
const host = api.data?.config?.host || 'invera.atlassian.net';
|
|
||||||
const tbody = document.getElementById('tableQA');
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
const sorted = [...(qa.issues || [])].sort((a, b) => b.timeQa - a.timeQa);
|
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="no-data">No hay tickets con datos de QA en este período</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const top5 = sorted.slice(0, 5);
|
|
||||||
top5.forEach(i => {
|
|
||||||
const isCritical = i.timeQa > 4;
|
|
||||||
const isReturned = i.timesReturned > 1;
|
|
||||||
const jiraUrl = `https://${host}/browse/${i.key}`;
|
|
||||||
|
|
||||||
const statusLabel = i.status === 'done'
|
|
||||||
? '<span class="pill done">✅ Done</span>'
|
|
||||||
: i.status === 'indeterminate'
|
|
||||||
? '<span class="pill progress">🔵 En curso</span>'
|
|
||||||
: '<span class="pill todo">⏳ Pendiente</span>';
|
|
||||||
|
|
||||||
const returnBadge = isReturned
|
|
||||||
? `<span class="pill returned">🔄 ${i.timesReturned}x</span>`
|
|
||||||
: `<span class="pill qa">1x</span>`;
|
|
||||||
|
|
||||||
tbody.innerHTML += `<tr class="${isCritical ? 'row-critical' : ''}">
|
|
||||||
<td><a href="${jiraUrl}" target="_blank" class="jira-link">${i.key}</a></td>
|
|
||||||
<td style="max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${i.summary}">${i.summary}</td>
|
|
||||||
<td style="color:#0065ff;">${i.timeDev > 0 ? i.timeDev + 'hs' : '—'}</td>
|
|
||||||
<td style="color:#00b8d9;">${i.timeCr > 0 ? i.timeCr + 'hs' : '—'}</td>
|
|
||||||
<td><strong style="color:${isCritical ? '#ff5630' : '#36b37e'}">${i.timeQa}hs</strong>${isCritical ? ' ⚠️' : ''}</td>
|
|
||||||
<td>${returnBadge}</td>
|
|
||||||
<td>${statusLabel}</td>
|
|
||||||
</tr>`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
+34
-16
@@ -8,7 +8,7 @@
|
|||||||
const config = {
|
const config = {
|
||||||
jira: {
|
jira: {
|
||||||
host: process.env.JIRA_HOST || 'invera.atlassian.net',
|
host: process.env.JIRA_HOST || 'invera.atlassian.net',
|
||||||
project: process.env.JIRA_PROJECT || 'LABS',
|
project: process.env.JIRA_PROJECT || 'Infra',
|
||||||
email: process.env.JIRA_EMAIL,
|
email: process.env.JIRA_EMAIL,
|
||||||
token: process.env.JIRA_TOKEN,
|
token: process.env.JIRA_TOKEN,
|
||||||
},
|
},
|
||||||
@@ -30,7 +30,7 @@ const config = {
|
|||||||
enCursoEntry: 'customfield_10815',
|
enCursoEntry: 'customfield_10815',
|
||||||
enCursoTotalTime: 'customfield_10816',
|
enCursoTotalTime: 'customfield_10816',
|
||||||
},
|
},
|
||||||
jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND ${FIELD_SP_ESTIMATE} is not EMPTY ORDER BY updated DESC',
|
jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND issuetype not in (Epic, "Sub-task", Subtask) ORDER BY updated DESC',
|
||||||
api: {
|
api: {
|
||||||
cacheMsec: parseInt(process.env.CACHE_MS || '3600000'), // 1 hora por defecto
|
cacheMsec: parseInt(process.env.CACHE_MS || '3600000'), // 1 hora por defecto
|
||||||
},
|
},
|
||||||
@@ -244,7 +244,37 @@ function processIssues(issues, period = '90d') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp, devs, creMonth });
|
const issueWarnings = [];
|
||||||
|
|
||||||
|
if (isInProgress && f.updated) {
|
||||||
|
const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24);
|
||||||
|
if (days > 14) {
|
||||||
|
const w = { type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) };
|
||||||
|
warnings.push(w);
|
||||||
|
issueWarnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isDone && f.duedate) {
|
||||||
|
const dueDate = new Date(f.duedate);
|
||||||
|
if (dueDate < now) {
|
||||||
|
const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24));
|
||||||
|
const w = { type: 'expired', key: issue.key, summary: f.summary, devs, sp, days: daysOverdue };
|
||||||
|
warnings.push(w);
|
||||||
|
issueWarnings.push(w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isDone && sp === 0) {
|
||||||
|
const w = { type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 };
|
||||||
|
warnings.push(w);
|
||||||
|
issueWarnings.push(w);
|
||||||
|
}
|
||||||
|
if (!isDone && devs.includes('Sin asignar') && sp > 0) {
|
||||||
|
const w = { type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp };
|
||||||
|
warnings.push(w);
|
||||||
|
issueWarnings.push(w);
|
||||||
|
}
|
||||||
|
|
||||||
|
issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp, devs, creMonth, warnings: issueWarnings });
|
||||||
|
|
||||||
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
|
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
|
||||||
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
|
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
|
||||||
@@ -305,19 +335,7 @@ function processIssues(issues, period = '90d') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInProgress && f.updated) {
|
// Las alertas de salud se generan y empujan arriba (junto con issueWarnings)
|
||||||
const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24);
|
|
||||||
if (days > 14) warnings.push({ type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) });
|
|
||||||
}
|
|
||||||
if (!isDone && f.duedate) {
|
|
||||||
const dueDate = new Date(f.duedate);
|
|
||||||
if (dueDate < now) {
|
|
||||||
const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24));
|
|
||||||
warnings.push({ type: 'expired', key: issue.key, summary: f.summary, devs, sp, days: daysOverdue });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isDone && sp === 0) warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 });
|
|
||||||
if (!isDone && devs.includes('Sin asignar') && sp > 0) warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp });
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(`Error procesando issue ${issue.key}: ${e.message}`);
|
console.warn(`Error procesando issue ${issue.key}: ${e.message}`);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask
|
||||||
|
requests
|
||||||
|
python-dotenv
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""
|
||||||
|
server.py — Servidor Flask para Dashboard LABS/Infra — Story Points
|
||||||
|
Reemplaza las Netlify Functions. Sirve el frontend y el endpoint /api/data
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify, send_from_directory
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Cargar variables de entorno desde .env
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder='.')
|
||||||
|
|
||||||
|
# ─── Configuración ─────────────────────────────────────────────────────────
|
||||||
|
USE_TICKET_COUNT = os.getenv('USE_TICKET_COUNT', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'jira': {
|
||||||
|
'host': os.getenv('JIRA_HOST', 'invera.atlassian.net'),
|
||||||
|
'project': os.getenv('JIRA_PROJECT', 'IO'),
|
||||||
|
'email': os.getenv('JIRA_EMAIL'),
|
||||||
|
'token': os.getenv('JIRA_TOKEN'),
|
||||||
|
},
|
||||||
|
'fields': {
|
||||||
|
'spEstimate': os.getenv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
||||||
|
'spFinal': os.getenv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
||||||
|
'developer': os.getenv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
|
||||||
|
'qaAssignee': 'customfield_10632',
|
||||||
|
'qaEntryRtqa': 'customfield_10821',
|
||||||
|
'qaTotalTimeRtqa': 'customfield_10822',
|
||||||
|
'qaLastDurRtqa': 'customfield_10823',
|
||||||
|
'qaEntryQa': 'customfield_10824',
|
||||||
|
'qaTotalTimeQa': 'customfield_10825',
|
||||||
|
'qaLastDurQa': 'customfield_10826',
|
||||||
|
'timesReturned': 'customfield_11217',
|
||||||
|
'spFinalReal': 'customfield_11216',
|
||||||
|
'crEntry': 'customfield_10818',
|
||||||
|
'crTotalTime': 'customfield_10819',
|
||||||
|
'enCursoEntry': 'customfield_10815',
|
||||||
|
'enCursoTotalTime':'customfield_10816',
|
||||||
|
},
|
||||||
|
'cacheMsec': int(os.getenv('CACHE_MS', 3600000)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Cache en memoria ──────────────────────────────────────────────────────
|
||||||
|
_cache = {}
|
||||||
|
_cache_time = {}
|
||||||
|
|
||||||
|
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
def mins_to_hrs(mins):
|
||||||
|
if not mins:
|
||||||
|
return 0
|
||||||
|
return round((mins / 60) * 10) / 10
|
||||||
|
|
||||||
|
def get_percentile(sorted_arr, p):
|
||||||
|
if not sorted_arr:
|
||||||
|
return 0
|
||||||
|
if p <= 0:
|
||||||
|
return sorted_arr[0]
|
||||||
|
if p >= 1:
|
||||||
|
return sorted_arr[-1]
|
||||||
|
index = (len(sorted_arr) - 1) * p
|
||||||
|
lower = math.floor(index)
|
||||||
|
upper = lower + 1
|
||||||
|
weight = index % 1
|
||||||
|
if upper >= len(sorted_arr):
|
||||||
|
return sorted_arr[lower]
|
||||||
|
return sorted_arr[lower] * (1 - weight) + sorted_arr[upper] * weight
|
||||||
|
|
||||||
|
def build_fields_list():
|
||||||
|
f = config['fields']
|
||||||
|
return ','.join([
|
||||||
|
'summary', 'status', 'assignee', 'reporter', 'resolutiondate', 'created', 'updated', 'duedate',
|
||||||
|
f['spEstimate'], f['spFinal'], f['developer'],
|
||||||
|
f['qaAssignee'], f['qaEntryRtqa'], f['qaTotalTimeRtqa'], f['qaLastDurRtqa'],
|
||||||
|
f['qaEntryQa'], f['qaTotalTimeQa'], f['qaLastDurQa'],
|
||||||
|
f['timesReturned'], f['spFinalReal'], f['crEntry'], f['crTotalTime'],
|
||||||
|
f['enCursoEntry'], f['enCursoTotalTime'],
|
||||||
|
])
|
||||||
|
|
||||||
|
# ─── Fetch de Jira ─────────────────────────────────────────────────────────
|
||||||
|
def fetch_jira_issues(period):
|
||||||
|
email = config['jira']['email']
|
||||||
|
token = config['jira']['token']
|
||||||
|
host = config['jira']['host']
|
||||||
|
project = config['jira']['project']
|
||||||
|
|
||||||
|
credentials = base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Basic {credentials}',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
max_results_wanted = 1500
|
||||||
|
all_issues = []
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
jql = f'project = {project} ORDER BY updated DESC'
|
||||||
|
|
||||||
|
print(f" → Email: {email}")
|
||||||
|
print(f" → Token: {token[:20] if token else 'NO TOKEN'}...")
|
||||||
|
print(f" → JQL: {jql}")
|
||||||
|
|
||||||
|
print(f"📡 Consultando JIRA: {project} (periodo: {period})")
|
||||||
|
|
||||||
|
fields_param = build_fields_list()
|
||||||
|
next_page_token = None
|
||||||
|
|
||||||
|
while len(all_issues) < max_results_wanted:
|
||||||
|
url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=100&fields=summary,status,assignee,reporter,resolutiondate,created,updated,duedate"
|
||||||
|
if next_page_token:
|
||||||
|
url += f"&nextPageToken={requests.utils.quote(next_page_token)}"
|
||||||
|
print(f" → URL: {url[:200]}")
|
||||||
|
res = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
print(f" → HTTP {res.status_code} — respuesta: {res.text[:200]}")
|
||||||
|
|
||||||
|
if not res.ok:
|
||||||
|
raise Exception(f"JIRA API {res.status_code}: {res.text[:200]}")
|
||||||
|
|
||||||
|
data = res.json()
|
||||||
|
items = data.get('issues', [])
|
||||||
|
all_issues.extend(items)
|
||||||
|
next_page_token = data.get('nextPageToken')
|
||||||
|
|
||||||
|
print(f" → {len(items)} tickets en esta página | Total acumulado: {len(all_issues)}")
|
||||||
|
|
||||||
|
if not items or not next_page_token:
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"✅ Total tickets obtenidos de Jira: {len(all_issues)}")
|
||||||
|
return {'issues': all_issues, 'isTruncated': bool(next_page_token)}
|
||||||
|
|
||||||
|
# ─── Procesamiento de Issues ───────────────────────────────────────────────
|
||||||
|
def process_issues(issues, period='90d'):
|
||||||
|
f = config['fields']
|
||||||
|
assignee_data = {}
|
||||||
|
monthly_done = {}
|
||||||
|
issues_list = []
|
||||||
|
warnings = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
cutoff_date = None
|
||||||
|
if period != 'all':
|
||||||
|
period_days = 1 if period == '1d' else int(period.replace('d', ''))
|
||||||
|
if period == '1d':
|
||||||
|
cutoff_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
else:
|
||||||
|
cutoff_date = datetime.fromtimestamp(time.time() - period_days * 86400, tz=timezone.utc)
|
||||||
|
|
||||||
|
qa_data = {
|
||||||
|
'tickets': 0, 'ticketsReturned': 0, 'totalTimeQa': 0, 'totalTimeRtqa': 0,
|
||||||
|
'monthlyTotalQa': {}, 'monthlyCountQa': {},
|
||||||
|
'monthlyTotalRtqa': {}, 'monthlyCountRtqa': {},
|
||||||
|
'issues': [], 'timesQa': [], 'timesRtqa': []
|
||||||
|
}
|
||||||
|
|
||||||
|
tickets_created = 0 # Contador de tickets creados en el período
|
||||||
|
reporter_data = {} # Tickets solicitados por persona
|
||||||
|
|
||||||
|
delivery_data = {
|
||||||
|
'totalLeadTimeDays': 0, 'totalCycleTimeDays': 0,
|
||||||
|
'doneTicketsLead': 0, 'doneTicketsCycle': 0,
|
||||||
|
'doneSpOriginal': 0, 'doneSpFinal': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cr_data = {
|
||||||
|
'tickets': 0, 'totalTimeCr': 0, 'issues': [],
|
||||||
|
'monthlyTotalCr': {}, 'monthlyCountCr': {},
|
||||||
|
'stalledIssues': [], 'times': []
|
||||||
|
}
|
||||||
|
|
||||||
|
current_states = {
|
||||||
|
'codeReview': {'count': 0, 'sp': 0},
|
||||||
|
'readyToQa': {'count': 0, 'sp': 0},
|
||||||
|
'inQa': {'count': 0, 'sp': 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno', 'Josefina Vera Ocampo', 'Isidro Emir Pelli']
|
||||||
|
|
||||||
|
# Normalizar nombres de usuario a nombre real
|
||||||
|
NAME_MAP = {
|
||||||
|
'facundo.devoto': 'Devoto Facundo',
|
||||||
|
}
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
try:
|
||||||
|
fields = issue['fields']
|
||||||
|
|
||||||
|
# Devs
|
||||||
|
devs = []
|
||||||
|
if fields.get(f['developer']):
|
||||||
|
devs = [d['displayName'] for d in fields[f['developer']]]
|
||||||
|
elif fields.get('assignee'):
|
||||||
|
devs = [fields['assignee']['displayName']]
|
||||||
|
else:
|
||||||
|
devs = ['Sin asignar']
|
||||||
|
|
||||||
|
devs = [NAME_MAP.get(d, d) for d in devs]
|
||||||
|
devs = [d for d in devs if d not in INACTIVE_DEVS]
|
||||||
|
if not devs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if USE_TICKET_COUNT:
|
||||||
|
sp = 1
|
||||||
|
else:
|
||||||
|
sp = (fields.get(f['spFinal']) if fields.get(f['spFinal']) is not None
|
||||||
|
else fields.get(f['spEstimate'])) or 0
|
||||||
|
|
||||||
|
status_key = (fields.get('status') or {}).get('statusCategory', {}).get('key', 'new')
|
||||||
|
status_name = ((fields.get('status') or {}).get('name') or '').lower()
|
||||||
|
is_done = status_key == 'done'
|
||||||
|
is_in_progress = status_key == 'indeterminate'
|
||||||
|
|
||||||
|
# Contar tickets creados dentro del período + reporter
|
||||||
|
if fields.get('created'):
|
||||||
|
created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00'))
|
||||||
|
if cutoff_date is None or created_dt >= cutoff_date:
|
||||||
|
tickets_created += 1
|
||||||
|
reporter = (fields.get('reporter') or {}).get('displayName', 'Sin asignar')
|
||||||
|
reporter_data[reporter] = reporter_data.get(reporter, 0) + 1
|
||||||
|
|
||||||
|
# Filtrar done tickets fuera del período
|
||||||
|
if is_done and fields.get('resolutiondate') and cutoff_date:
|
||||||
|
res_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00'))
|
||||||
|
if res_date < cutoff_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Estado actual (colas)
|
||||||
|
if not is_done:
|
||||||
|
if 'code review' in status_name or status_name == 'cr':
|
||||||
|
current_states['codeReview']['count'] += 1
|
||||||
|
current_states['codeReview']['sp'] += sp
|
||||||
|
elif 'ready to qa' in status_name or status_name == 'listo para qa':
|
||||||
|
current_states['readyToQa']['count'] += 1
|
||||||
|
current_states['readyToQa']['sp'] += sp
|
||||||
|
elif status_name == 'qa' or 'in qa' in status_name or status_name == 'en qa':
|
||||||
|
current_states['inQa']['count'] += 1
|
||||||
|
current_states['inQa']['sp'] += sp
|
||||||
|
|
||||||
|
res_month = fields['resolutiondate'][:7] if fields.get('resolutiondate') else None
|
||||||
|
cre_month = fields['created'][:7] if fields.get('created') else None
|
||||||
|
|
||||||
|
for dev in devs:
|
||||||
|
if dev not in assignee_data:
|
||||||
|
assignee_data[dev] = {'done': 0, 'in_progress': 0, 'todo': 0, 'pending_count': 0}
|
||||||
|
if is_done:
|
||||||
|
assignee_data[dev]['done'] += sp
|
||||||
|
elif is_in_progress:
|
||||||
|
assignee_data[dev]['in_progress'] += sp
|
||||||
|
assignee_data[dev]['pending_count'] += 1
|
||||||
|
else:
|
||||||
|
assignee_data[dev]['todo'] += sp
|
||||||
|
assignee_data[dev]['pending_count'] += 1
|
||||||
|
|
||||||
|
if is_done:
|
||||||
|
month = res_month or cre_month
|
||||||
|
if month:
|
||||||
|
if month not in monthly_done:
|
||||||
|
monthly_done[month] = {}
|
||||||
|
for dev in devs:
|
||||||
|
monthly_done[month][dev] = monthly_done[month].get(dev, 0) + sp
|
||||||
|
|
||||||
|
# Delivery metrics
|
||||||
|
created_date = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None
|
||||||
|
resolved_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00')) if fields.get('resolutiondate') else None
|
||||||
|
en_curso_date = datetime.fromisoformat(fields[f['enCursoEntry']].replace('Z', '+00:00')) if fields.get(f['enCursoEntry']) else None
|
||||||
|
sp_original = fields.get(f['spEstimate']) or 0
|
||||||
|
sp_real = fields.get(f['spFinalReal']) if fields.get(f['spFinalReal']) is not None else sp_original
|
||||||
|
|
||||||
|
if resolved_date:
|
||||||
|
if created_date:
|
||||||
|
delivery_data['totalLeadTimeDays'] += (resolved_date - created_date).total_seconds() / 86400
|
||||||
|
delivery_data['doneTicketsLead'] += 1
|
||||||
|
if en_curso_date:
|
||||||
|
delivery_data['totalCycleTimeDays'] += (resolved_date - en_curso_date).total_seconds() / 86400
|
||||||
|
delivery_data['doneTicketsCycle'] += 1
|
||||||
|
delivery_data['doneSpOriginal'] += sp_original
|
||||||
|
delivery_data['doneSpFinal'] += sp_real
|
||||||
|
|
||||||
|
# Warnings
|
||||||
|
issue_warnings = []
|
||||||
|
|
||||||
|
if is_in_progress and fields.get('updated'):
|
||||||
|
updated = datetime.fromisoformat(fields['updated'].replace('Z', '+00:00'))
|
||||||
|
days = (now - updated).total_seconds() / 86400
|
||||||
|
if days > 14:
|
||||||
|
w = {'type': 'stalled', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': round(days)}
|
||||||
|
warnings.append(w)
|
||||||
|
issue_warnings.append(w)
|
||||||
|
|
||||||
|
if not is_done and fields.get('duedate'):
|
||||||
|
due_date = datetime.fromisoformat(fields['duedate'] + 'T00:00:00+00:00')
|
||||||
|
if due_date < now:
|
||||||
|
days_overdue = (now - due_date).days
|
||||||
|
w = {'type': 'expired', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': days_overdue}
|
||||||
|
warnings.append(w)
|
||||||
|
issue_warnings.append(w)
|
||||||
|
|
||||||
|
if is_done and sp == 0:
|
||||||
|
w = {'type': 'done_no_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': 0}
|
||||||
|
warnings.append(w)
|
||||||
|
issue_warnings.append(w)
|
||||||
|
|
||||||
|
if not is_done and 'Sin asignar' in devs and sp > 0:
|
||||||
|
w = {'type': 'unassigned_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp}
|
||||||
|
warnings.append(w)
|
||||||
|
issue_warnings.append(w)
|
||||||
|
|
||||||
|
issues_list.append({
|
||||||
|
'id': issue['id'], 'key': issue['key'],
|
||||||
|
'summary': fields['summary'], 'status': status_key,
|
||||||
|
'sp': sp, 'devs': devs, 'creMonth': cre_month,
|
||||||
|
'warnings': issue_warnings
|
||||||
|
})
|
||||||
|
|
||||||
|
# QA & CR
|
||||||
|
time_qa_min = fields.get(f['qaTotalTimeQa']) or 0
|
||||||
|
time_rtqa_min = fields.get(f['qaTotalTimeRtqa']) or 0
|
||||||
|
time_cr_min = fields.get(f['crTotalTime']) or 0
|
||||||
|
time_dev_min = fields.get(f['enCursoTotalTime']) or 0
|
||||||
|
times_ret = fields.get(f['timesReturned']) or 0
|
||||||
|
qa_month = res_month or cre_month
|
||||||
|
|
||||||
|
# Fix datos sucios anteriores al 7-Apr-2026
|
||||||
|
automation_fix_date = datetime(2026, 4, 7, tzinfo=timezone.utc)
|
||||||
|
issue_created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None
|
||||||
|
if issue_created_dt and issue_created_dt < automation_fix_date:
|
||||||
|
times_ret = 1
|
||||||
|
|
||||||
|
if time_qa_min > 0:
|
||||||
|
qa_data['tickets'] += 1
|
||||||
|
qa_data['totalTimeQa'] += time_qa_min
|
||||||
|
qa_data['timesQa'].append(time_qa_min)
|
||||||
|
if times_ret > 1:
|
||||||
|
qa_data['ticketsReturned'] += 1
|
||||||
|
if qa_month:
|
||||||
|
qa_data['monthlyTotalQa'][qa_month] = qa_data['monthlyTotalQa'].get(qa_month, 0) + time_qa_min
|
||||||
|
qa_data['monthlyCountQa'][qa_month] = qa_data['monthlyCountQa'].get(qa_month, 0) + 1
|
||||||
|
qa_data['issues'].append({
|
||||||
|
'key': issue['key'], 'summary': fields['summary'],
|
||||||
|
'timeQa': mins_to_hrs(time_qa_min), 'timeRtqa': mins_to_hrs(time_rtqa_min),
|
||||||
|
'timesReturned': times_ret, 'status': status_key, 'resMonth': qa_month,
|
||||||
|
'timeCr': mins_to_hrs(time_cr_min), 'timeDev': mins_to_hrs(time_dev_min)
|
||||||
|
})
|
||||||
|
|
||||||
|
if time_rtqa_min > 0:
|
||||||
|
qa_data['timesRtqa'].append(time_rtqa_min)
|
||||||
|
if qa_month:
|
||||||
|
qa_data['totalTimeRtqa'] += time_rtqa_min
|
||||||
|
qa_data['monthlyTotalRtqa'][qa_month] = qa_data['monthlyTotalRtqa'].get(qa_month, 0) + time_rtqa_min
|
||||||
|
qa_data['monthlyCountRtqa'][qa_month] = qa_data['monthlyCountRtqa'].get(qa_month, 0) + 1
|
||||||
|
|
||||||
|
if time_cr_min > 0:
|
||||||
|
cr_data['tickets'] += 1
|
||||||
|
cr_data['totalTimeCr'] += time_cr_min
|
||||||
|
cr_data['times'].append(time_cr_min)
|
||||||
|
if qa_month:
|
||||||
|
cr_data['monthlyTotalCr'][qa_month] = cr_data['monthlyTotalCr'].get(qa_month, 0) + time_cr_min
|
||||||
|
cr_data['monthlyCountCr'][qa_month] = cr_data['monthlyCountCr'].get(qa_month, 0) + 1
|
||||||
|
cr_data['issues'].append({'key': issue['key'], 'summary': fields['summary'], 'timeCr': mins_to_hrs(time_cr_min), 'status': status_key})
|
||||||
|
|
||||||
|
cr_entry_date = fields.get(f['crEntry'])
|
||||||
|
if cr_entry_date and not is_done and not fields.get(f['qaEntryRtqa']):
|
||||||
|
cr_entry_dt = datetime.fromisoformat(cr_entry_date.replace('Z', '+00:00'))
|
||||||
|
days_in_cr = (now - cr_entry_dt).total_seconds() / 86400
|
||||||
|
if days_in_cr > 2:
|
||||||
|
cr_data['stalledIssues'].append({'key': issue['key'], 'summary': fields['summary'], 'days': round(days_in_cr * 10) / 10})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error procesando {issue.get('key', '?')}: {e}")
|
||||||
|
|
||||||
|
# Alertas de devs libres
|
||||||
|
EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca']
|
||||||
|
for dev, ad in assignee_data.items():
|
||||||
|
if dev not in EXCLUDED_FROM_ALERTS and ad['pending_count'] == 0:
|
||||||
|
warnings.append({'type': 'free_dev', 'summary': f'{dev} no tiene tickets pendientes ni en curso', 'devs': [dev]})
|
||||||
|
|
||||||
|
# Promedios mensuales QA/RTQA/CR
|
||||||
|
monthly_avg_qa = {m: mins_to_hrs(qa_data['monthlyTotalQa'][m] / qa_data['monthlyCountQa'][m]) for m in qa_data['monthlyTotalQa']}
|
||||||
|
monthly_avg_rtqa = {m: mins_to_hrs(qa_data['monthlyTotalRtqa'][m] / qa_data['monthlyCountRtqa'][m]) for m in qa_data['monthlyTotalRtqa']}
|
||||||
|
monthly_avg_cr = {m: mins_to_hrs(cr_data['monthlyTotalCr'][m] / cr_data['monthlyCountCr'][m]) for m in cr_data['monthlyTotalCr']}
|
||||||
|
|
||||||
|
# Percentiles
|
||||||
|
cr_sorted = sorted(cr_data['times'])
|
||||||
|
qa_sorted = sorted(qa_data['timesQa'])
|
||||||
|
rtqa_sorted = sorted(qa_data['timesRtqa'])
|
||||||
|
|
||||||
|
avg_lead = round(delivery_data['totalLeadTimeDays'] / delivery_data['doneTicketsLead']) if delivery_data['doneTicketsLead'] else 0
|
||||||
|
avg_cycle = round(delivery_data['totalCycleTimeDays'] / delivery_data['doneTicketsCycle']) if delivery_data['doneTicketsCycle'] else 0
|
||||||
|
sp_orig = delivery_data['doneSpOriginal']
|
||||||
|
sp_fin = delivery_data['doneSpFinal']
|
||||||
|
sp_dev_pct = round((sp_fin - sp_orig) / sp_orig * 100, 1) if sp_orig else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'lastUpdated': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'assigneeData': assignee_data,
|
||||||
|
'monthlyDone': monthly_done,
|
||||||
|
'issuesList': issues_list,
|
||||||
|
'warnings': warnings,
|
||||||
|
'currentStates': current_states,
|
||||||
|
'ticketsCreated': tickets_created,
|
||||||
|
'reporterData': reporter_data,
|
||||||
|
'deliveryData': {
|
||||||
|
'avgLeadTime': avg_lead,
|
||||||
|
'avgCycleTime': avg_cycle,
|
||||||
|
'spOriginal': sp_orig,
|
||||||
|
'spFinal': sp_fin,
|
||||||
|
'spDeviationPct': sp_dev_pct,
|
||||||
|
},
|
||||||
|
'crData': {
|
||||||
|
**cr_data,
|
||||||
|
'monthlyAvgCr': monthly_avg_cr,
|
||||||
|
'p50Cr': mins_to_hrs(get_percentile(cr_sorted, 0.5)),
|
||||||
|
'p90Cr': mins_to_hrs(get_percentile(cr_sorted, 0.9)),
|
||||||
|
'p99Cr': mins_to_hrs(get_percentile(cr_sorted, 0.99)),
|
||||||
|
'avgTimeCr': mins_to_hrs(cr_data['totalTimeCr'] / cr_data['tickets']) if cr_data['tickets'] else 0,
|
||||||
|
},
|
||||||
|
'qaData': {
|
||||||
|
**qa_data,
|
||||||
|
'monthlyAvgQa': monthly_avg_qa,
|
||||||
|
'monthlyAvgRtqa': monthly_avg_rtqa,
|
||||||
|
'p50Qa': mins_to_hrs(get_percentile(qa_sorted, 0.5)),
|
||||||
|
'p90Qa': mins_to_hrs(get_percentile(qa_sorted, 0.9)),
|
||||||
|
'p99Qa': mins_to_hrs(get_percentile(qa_sorted, 0.99)),
|
||||||
|
'p50Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.5)),
|
||||||
|
'p90Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.9)),
|
||||||
|
'p99Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.99)),
|
||||||
|
'avgTimeQa': mins_to_hrs(qa_data['totalTimeQa'] / qa_data['tickets']) if qa_data['tickets'] else 0,
|
||||||
|
'avgTimeRtqa': mins_to_hrs(qa_data['totalTimeRtqa'] / qa_data['tickets']) if qa_data['tickets'] else 0,
|
||||||
|
},
|
||||||
|
'config': {'host': config['jira']['host'], 'useTicketCount': USE_TICKET_COUNT},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Rutas Flask ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route('/api/data')
|
||||||
|
def api_data():
|
||||||
|
try:
|
||||||
|
period = request.args.get('period', '90d')
|
||||||
|
force_refresh = request.args.get('refresh') == '1'
|
||||||
|
|
||||||
|
cache_valid = (
|
||||||
|
not force_refresh
|
||||||
|
and period in _cache
|
||||||
|
and (time.time() * 1000 - _cache_time.get(period, 0)) < config['cacheMsec']
|
||||||
|
)
|
||||||
|
|
||||||
|
if cache_valid:
|
||||||
|
return jsonify({**_cache[period], 'fromCache': True})
|
||||||
|
|
||||||
|
if not config['jira']['email'] or not config['jira']['token']:
|
||||||
|
return jsonify({'error': 'Faltan credenciales JIRA en el archivo .env'}), 500
|
||||||
|
|
||||||
|
jira_result = fetch_jira_issues(period)
|
||||||
|
data = process_issues(jira_result['issues'], period)
|
||||||
|
|
||||||
|
data['isTruncated'] = jira_result['isTruncated']
|
||||||
|
data['jiraTotal'] = '1500+' if jira_result['isTruncated'] else len(jira_result['issues'])
|
||||||
|
data['period'] = period
|
||||||
|
data['fromCache'] = False
|
||||||
|
|
||||||
|
_cache[period] = data
|
||||||
|
_cache_time[period] = time.time() * 1000
|
||||||
|
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# Servir archivos estáticos (CSS, JS)
|
||||||
|
@app.route('/css/<path:filename>')
|
||||||
|
def css(filename):
|
||||||
|
return send_from_directory('css', filename)
|
||||||
|
|
||||||
|
@app.route('/js/<path:filename>')
|
||||||
|
def js(filename):
|
||||||
|
return send_from_directory('js', filename)
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return send_from_directory('.', 'index.html')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("🚀 Dashboard corriendo en http://localhost:8888")
|
||||||
|
app.run(host='0.0.0.0', port=8888, debug=True)
|
||||||
Reference in New Issue
Block a user