Compare commits

..

10 Commits

Author SHA1 Message Date
juan.beldomenico 695401f237 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>
2026-04-27 15:09:04 -03:00
kvilches1410 76edc176f9 refactor: implement dashboard UI enhancements, add health alert tooltips, and introduce modular JS application structure with Netlify data functions. 2026-04-07 17:32:33 -03:00
kvilches1410 72050df789 Cambios 2026-04-07 10:51:27 -03:00
kvilches1410 d7f7339fde Muchos cambios 2026-04-06 18:59:47 -03:00
kvilches1410 8dfa3bf137 CR + Tooltips + Promedios 2026-04-04 00:49:55 -03:00
kvilches1410 c8806a6701 Nuevos graficos 2026-04-04 00:35:55 -03:00
kvilches1410 a2d49b441e tercer commit 2026-04-01 11:34:43 -03:00
kvilches1410 7410b22623 Update README.md 2026-03-31 17:15:37 -03:00
kvilches1410 5467427a08 nuevo dash 2026-03-31 16:57:26 -03:00
kvilches1410 b4a0dfae24 Create README.md 2026-03-31 13:17:50 -03:00
18 changed files with 1997 additions and 637 deletions
+3
View File
@@ -1,3 +1,6 @@
.vercel
.env
ex.env
# Local Netlify folder
.netlify
+112
View File
@@ -0,0 +1,112 @@
# 📊 Dashboard LABS — JIRA Story Points
Un dashboard en tiempo real para visualizar métricas, progreso de Story Points (SP), y la velocidad del equipo utilizando datos extraídos directamente de la API de Jira.
Este proyecto está diseñado con un frontend ligero sin frameworks pesados y un backend *Serverless* preparado para ser desplegado en plataformas como Vercel o funcionar de manera local.
---
## 🏗️ Stack Tecnológico
- **Frontend:** HTML5 + Vanilla TypeScript/JavaScript + [Chart.js (v4.4.1)](https://www.chartjs.org/)
- **Backend:** Node.js Serverless Function
- **API:** Integración directa con Jira REST API v3
- **Autenticación:** Basic Auth mediante Email y API Token de Jira
- **Caching:** Caché en memoria (24hs) + CDN-level Cache con "stale-while-revalidate"
## ✨ Características Principales
1. **Estado Actual del Equipo:**
- Visualización de KPIS clave: SP en Progreso, SP por Hacer, SP Completados y Desarrolladores Activos.
- Gráficos detallados del progreso por desarrollador.
- Tabla que resume el porcentaje de carga de trabajo de cada uno.
2. **Histórico por Período:**
- Resumen mensual de los Story Points completados.
- Velocidad histórica del equipo a lo largo del tiempo.
- Gráfico de top Épicas y distribución de SP.
3. **Performance Optimizado:**
- Consulta eficiente con parámetros JQL directos.
- Sistema de caché dual (Node memory + Vercel Edge) para evitar excesos de *Rate Limiting* en Jira y cargar instantáneamente.
---
## 🚀 Instalación y Configuración Local
### 1. Clonar el repositorio
Cloná este repositorio y accedé a la carpeta del proyecto:
```bash
git clone https://github.com/kvilches1410/dashlabs.git
cd dashlabs
```
### 2. Instalar el CLI de Vercel
Para correr las funciones *serverless* del backend (`api/data.js` de Vercel) localmente, necesitas instalar Vercel CLI de forma global:
```bash
npm install -g vercel
```
### 3. Configurar Variables de Entorno
Copiá el archivo de entorno de ejemplo como `.env` en la raíz del proyecto.
```bash
cp ex.env .env
```
Luego, editá el `.env` e ingresá tus credenciales de Jira:
```env
# Ejemplo de .env
JIRA_EMAIL=tu-email@tudominio.com
JIRA_TOKEN=tu-jira-api-token
```
> **Nota:** Podés generar un API Token de Jira en tu la configuración de seguridad de tu cuenta de Atlassian.
### 4. Entorno de Desarrollo
Para levantar el proyecto y la API simulando el entorno de Vercel, ejecutá:
```bash
vercel dev
```
Luego, abrí `http://localhost:3000` en tu navegador.
---
## 🔧 Configuración del Modelo de Datos de JIRA
Los campos personalizados (Custom Fields) varían según cada entorno de Jira. Actualmente, el mapeo de campos está de la siguiente manera:
| Campo / Tipo | Código (Custom Field) | Descripción |
| :--- | :--- | :--- |
| **Story Points Estimate** | `customfield_10016` | La estimación de SP original del ticket |
| **Story Points Finales** | `customfield_11217` | SP finales acordados (Suele tener prioridad) |
| **Desarrollador** | `customfield_10628` | Campo Multi-usuario para asignación de tareas |
---
## 📁 Estructura del Proyecto
```text
dashlabs/
├── index.html # Frontend central. Contiene todo el layout, CSS y JS con Chart.js
├── package.json # Dependencias y scripts
├── vercel.json # Configuración para despliegue en Vercel
├── .gitignore
├── ex.env # Variables de entorno de ejemplo (TEMPLATE)
└── api/
├── config.js # [Opcional] Configuración modular para el Request de JIRA
└── data.js # Vercel Serverless Function: Maneja el fetch, transformación de datos y caché
```
---
## 🌐 Despliegue en Producción (Vercel)
El proyecto está diseñado para funcionar nativamente dentro de [Vercel](https://vercel.com/).
1. Conectá el repositorio en el panel de Vercel.
2. Agregá en Vercel **Environment Variables**:
- `JIRA_EMAIL`
- `JIRA_TOKEN`
3. Dale a **Deploy**.
La carpeta `/api` será parseada de manera automática y transformada en endpoints `/api/data` para que el `index.html` los consuma.
---
+9
View File
@@ -73,6 +73,15 @@ export const config = {
spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
developer: getEnv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
// ── QA Fields ────────────────────────────────────────────────────
qaAssignee: 'customfield_10632',
qaEntryRtqa: 'customfield_10821',
qaTotalTimeRtqa: 'customfield_10822',
qaLastDurRtqa: 'customfield_10823',
qaEntryQa: 'customfield_10824',
qaTotalTimeQa: 'customfield_10825',
qaLastDurQa: 'customfield_10826',
timesReturned: 'customfield_11217',
},
// ── JQL Query ──────────────────────────────────────────────────────
-249
View File
@@ -1,249 +0,0 @@
/**
* api/data.js — Vercel Serverless Function (Refactorizado)
*
* Consulta JIRA en tiempo real y devuelve datos procesados del dashboard.
*
* Usa: api/config.js para centralizar variables de entorno
*/
import { config } from './config.js';
// ─── Cache en memoria ──────────────────────────────────────────────
let _cache = null;
let _cacheTime = 0;
// ─── JIRA Fetch ────────────────────────────────────────────────────
/**
* Consulta JIRA API con Basic Auth
* @param {string} email - Email JIRA
* @param {string} token - API Token
* @returns {Promise<{issues: Array, total: number}>}
*/
async function fetchJiraIssues(email, token) {
const auth = Buffer.from(`${email}:${token}`).toString('base64');
const url = `https://${config.jira.host}/rest/api/3/search/jql`
+ `?jql=${encodeURIComponent(config.jql)}`
+ `&fields=${encodeURIComponent(buildFieldsList())}`
+ `&maxResults=${config.api.maxResults}`;
console.log(`📡 Consultando JIRA: ${config.jira.project} (${config.api.maxResults} resultados max)`);
console.log(`📋 JQL: ${config.jql}`);
const res = await fetch(url, {
headers: {
'Authorization': `Basic ${auth}`,
'Accept': 'application/json',
},
});
if (!res.ok) {
const text = await res.text();
const errorMsg = `JIRA API ${res.status}: ${text.slice(0, 200)}`;
console.error(`❌ ${errorMsg}`);
throw new Error(errorMsg);
}
const data = await res.json();
console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`);
return data;
}
// ─── Build Fields List ─────────────────────────────────────────────
/**
* Construye lista de campos a traer de JIRA
*/
function buildFieldsList() {
return [
'summary',
'status',
'assignee',
config.fields.spEstimate, // Story points estimate
config.fields.spFinal, // Story points finales
config.fields.developer, // Desarrollador (multi-user)
'parent',
'resolutiondate',
'created',
].join(',');
}
// ─── Process Issues ────────────────────────────────────────────────
/**
* Procesa issues de JIRA y agrupa los datos
* @param {Array} issues - Array de issues de JIRA
* @returns {Object} Datos procesados
*/
function processIssues(issues) {
const assigneeData = {};
const monthlyDone = {};
const epicMap = {};
let processedCount = 0;
let skippedCount = 0;
for (const issue of issues) {
try {
const f = issue.fields;
// ── Extraer desarrolladores ────────────────────────────────
// Prioridad: customfield_10622 (multi) → assignee (single)
let devs = [];
if (f[config.fields.developer]?.length) {
// Multi-usuario
devs = f[config.fields.developer].map(d => d.displayName);
} else if (f.assignee) {
// Single usuario
devs = [f.assignee.displayName];
} else {
// Sin asignar
devs = ['Sin asignar'];
}
// ── Extraer Story Points ───────────────────────────────────
// Prioridad: SP Finales → SP Estimate
const sp = (f[config.fields.spFinal] != null
? f[config.fields.spFinal]
: f[config.fields.spEstimate]) ?? 0;
// ── Extraer Status ─────────────────────────────────────────
const statusKey = f.status?.statusCategory?.key ?? 'new';
const isDone = statusKey === 'done';
const isInProgress = statusKey === 'indeterminate';
// ── Extraer Fechas (YYYY-MM) ──────────────────────────────
const resMonth = f.resolutiondate
? f.resolutiondate.slice(0, 7)
: null;
const creMonth = f.created
? f.created.slice(0, 7)
: null;
// ── Extraer Épica ─────────────────────────────────────────
const epicName = f.parent?.fields?.summary ?? 'Sin épica';
// ── Acumular por Desarrollador ────────────────────────────
for (const dev of devs) {
if (!assigneeData[dev]) {
assigneeData[dev] = { done: 0, in_progress: 0, todo: 0 };
}
if (isDone) {
assigneeData[dev].done += sp;
} else if (isInProgress) {
assigneeData[dev].in_progress += sp;
} else {
assigneeData[dev].todo += sp;
}
}
// ── Acumular completados por mes ───────────────────────────
if (isDone) {
const month = resMonth || creMonth;
if (month) {
if (!monthlyDone[month]) monthlyDone[month] = {};
for (const dev of devs) {
monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp;
}
}
}
// ── Acumular por Épica ─────────────────────────────────────
if (!epicMap[epicName]) {
epicMap[epicName] = { name: epicName, done: 0, in_progress: 0, todo: 0 };
}
if (isDone) {
epicMap[epicName].done += sp;
} else if (isInProgress) {
epicMap[epicName].in_progress += sp;
} else {
epicMap[epicName].todo += sp;
}
processedCount++;
} catch (error) {
console.warn(`⚠️ Error procesando issue ${issue.key}: ${error.message}`);
skippedCount++;
}
}
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount}`);
return {
lastUpdated: new Date().toISOString(),
totalIssues: processedCount,
skippedIssues: skippedCount,
assigneeData,
monthlyDone,
epicData: Object.values(epicMap)
.sort((a, b) =>
(b.done + b.in_progress + b.todo) - (a.done + a.in_progress + a.todo)
),
};
}
// ─── Main Handler ──────────────────────────────────────────────────
/**
* Vercel Serverless Function Handler
*/
export default async (req, res) => {
// ── CORS ───────────────────────────────────────────────────────
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
// ── Caching ────────────────────────────────────────────────────
// CDN cache: 24hs fresco, sirve stale mientras recarga
res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600');
try {
// Force refresh con parámetro ?refresh=1
const forceRefresh = req.query?.refresh === '1';
// Servir desde caché si está fresco
if (!forceRefresh && _cache && (Date.now() - _cacheTime) < config.api.cacheMsec) {
console.log('📦 Sirviendo desde caché (en memoria)');
return res.status(200).json({ ..._cache, fromCache: true });
}
// ── Consultar JIRA ─────────────────────────────────────────
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token);
const data = processIssues(jiraData.issues);
// Agregar metadata
data.jiraTotal = jiraData.total; // Total real en JIRA (puede ser > maxResults)
data.config = {
project: config.jira.project,
host: config.jira.host,
maxResults: config.api.maxResults,
cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60),
};
// Guardar en caché en memoria
_cache = data;
_cacheTime = Date.now();
console.log('✅ Datos frescos desde JIRA almacenados en caché');
return res.status(200).json({ ...data, fromCache: false });
} catch (err) {
console.error('❌ [dashboard/api/data] Error:', err.message);
// Intentar servir desde caché si hay error
if (_cache) {
console.log('⚠️ Sirviendo caché anterior debido a error');
return res.status(200).json({
..._cache,
fromCache: true,
error: `Error al consultar JIRA: ${err.message}. Sirviendo datos en caché.`,
});
}
// Si no hay caché, retornar error
return res.status(500).json({
error: err.message,
hint: 'Verifica JIRA_EMAIL y JIRA_TOKEN en variables de entorno',
timestamp: new Date().toISOString(),
});
}
};
+33
View File
@@ -0,0 +1,33 @@
import { clearCache } from './data.js';
export default async (req, res) => {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST');
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Método no permitido. Usa POST.' });
}
// ── Validación de Secret ──────────────────────────────────────────────
const expectedSecret = process.env.WEBHOOK_SECRET;
const providedSecret = req.query.secret;
// Si hay un secret configurado en .env, exigimos que coincida
if (expectedSecret && providedSecret !== expectedSecret) {
console.warn('⚠️ Intento de Webhook con secret inválido.');
return res.status(401).json({ error: 'No autorizado / Secret inválido.' });
}
try {
console.log('🔔 Webhook recibido desde Jira!');
// Invocamos la limpieza de caché del módulo data.js
clearCache();
return res.status(200).json({ success: true, message: 'Caché invalidada exitosamente.' });
} catch (error) {
console.error('❌ Error manejando webhook:', error);
return res.status(500).json({ error: 'Error interno invalidando caché.' });
}
};
+162
View File
@@ -0,0 +1,162 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #172b4d; }
header { background: #0052cc; color: white; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
header h1 { font-size: 22px; font-weight: 700; }
.header-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.header-meta span { font-size: 12px; opacity: 0.75; }
.refresh-btn { background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.3); color: white; padding: 6px 14px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: background .15s; }
.refresh-btn:hover { background: rgba(255,255,255,.25); }
.refresh-btn:disabled { opacity: .5; cursor: not-allowed; }
.cache-badge { font-size: 11px; opacity: .7; }
/* Loading */
.loading-overlay { position: fixed; inset: 0; background: rgba(240,242,245,.9); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 1000; gap: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #dfe1e6; border-top-color: #0052cc; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay p { color: #5e6c84; font-size: 14px; }
/* Error e Info */
.error-banner { background: #ffebe6; border: 1px solid #ff5630; color: #bf2600; padding: 14px 32px; font-size: 14px; display: none; }
/* Headers de sección */
.section-header { padding: 14px 32px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #dfe1e6; background: white; flex-wrap: wrap; }
.section-header.current { border-color: #0065ff; }
.section-header.history { border-color: #36b37e; }
.section-header.health { border-color: #ff5630; }
.section-header h2 { font-size: 14px; font-weight: 700; }
.section-header .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.section-header.current .dot { background: #0065ff; }
.section-header.history .dot { background: #36b37e; }
.section-header.health .dot { background: #ff5630; }
/* Inputs y selects */
.section-header label { font-size: 13px; font-weight: 600; color: #5e6c84; margin-left: auto; }
.filter-group { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.filter-group label { margin-left: 0; }
select { padding: 6px 12px; border: 1px solid #dfe1e6; border-radius: 4px; font-size: 14px; color: #172b4d; background: #fafbfc; cursor: pointer; outline: none; }
select:focus { border-color: #0052cc; }
.badge { background: #e3f2fd; color: #0052cc; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
.section-block { margin-bottom: 36px; }
/* KPIs */
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.kpi-row-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
.kpi { background: white; border-radius: 8px; padding: 20px 24px; border-left: 4px solid #dfe1e6; box-shadow: 0 1px 3px rgba(0,0,0,.06); transition: transform .2s; }
.kpi:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,.1); }
.kpi.done { border-color: #36b37e; }
.kpi.progress{ border-color: #0065ff; }
.kpi.todo { border-color: #ff8b00; }
.kpi.members { border-color: #6554c0; }
.kpi.warning { border-color: #ff5630; }
.kpi.qa { border-color: #6554c0; }
.kpi.qa-time { border-color: #00b8d9; }
.kpi.qa-queue { border-color: #36b37e; }
.kpi.returned { border-color: #ff5630; }
.kpi-value { font-size: 36px; font-weight: 700; line-height: 1; }
.kpi.done .kpi-value { color: #36b37e; }
.kpi.progress .kpi-value { color: #0065ff; }
.kpi.todo .kpi-value { color: #ff8b00; }
.kpi.members .kpi-value { color: #6554c0; }
.kpi.warning .kpi-value { color: #ff5630; }
.kpi.qa .kpi-value { color: #6554c0; }
.kpi.qa-time .kpi-value { color: #00b8d9; }
.kpi.qa-queue .kpi-value { color: #36b37e; }
.kpi.returned .kpi-value { color: #ff5630; }
.kpi-label { font-size: 12px; color: #5e6c84; margin-top: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
.kpi-sub { font-size: 11px; color: #97a0af; margin-top: 4px; }
/* Section header QA */
.section-header.qa { border-color: #6554c0; }
.section-header.qa .dot { background: #6554c0; }
/* Pill y row QA */
.pill.qa { background: #f3f0ff; color: #403294; }
.pill.returned { background: #ffebe6; color: #bf2600; }
.row-critical td { background: #fff3f0; }
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.chart-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.chart-card.full { grid-column: 1 / -1; }
.chart-card h3 { font-size: 14px; font-weight: 700; color: #172b4d; margin-bottom: 4px; }
.chart-card .sub { font-size: 12px; color: #5e6c84; margin-bottom: 20px; }
/* Tables */
.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; }
.table-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 14px; background: #f4f5f7; color: #5e6c84; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #dfe1e6; }
td { padding: 12px 14px; border-bottom: 1px solid #f4f5f7; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8f9fa; }
/* Píldoras y avatares */
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.pill.done { background: #e3fcef; color: #006644; }
.pill.progress { background: #deebff; color: #0052cc; }
.pill.todo { background: #fffae6; color: #7a4b00; }
.pill.warning { background: #ffebe6; color: #bf2600; }
.pbar-wrap { background: #f4f5f7; border-radius: 4px; height: 8px; width: 140px; overflow: hidden; display: flex; }
.pbar-done { background: #36b37e; height: 100%; }
.pbar-ip { background: #0065ff; height: 100%; }
.pbar-todo { background: #ff8b00; height: 100%; }
.avatar { width: 28px; height: 28px; border-radius: 50%; color: white; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; }
.name-cell { display: flex; align-items: center; }
.no-data { text-align: center; color: #97a0af; padding: 40px; font-size: 14px; }
footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; }
/* Health Warnings Panel */
.warning-list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.warning-item { display: flex; align-items: center; gap: 12px; padding: 12px; background: #fffcfcf5; border-left: 4px solid #ff5630; border-radius: 4px; }
.warning-item .badge { background: #ffebe6; color: #bf2600; }
.warning-item .title { font-weight: 600; font-size: 13px; color: #172b4d;}
.warning-item .desc { font-size: 12px; color: #5e6c84; }
/* Popover Tooltips */
.pop-trigger { position: relative; display: inline-block; cursor: help; }
.pop-content {
display: none;
position: absolute;
bottom: 120%;
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(23,43,77,.25);
border: 1px solid #dfe1e6;
padding: 12px;
width: 280px;
z-index: 100;
}
.pop-trigger:hover .pop-content { display: block; animation: popIn .2s ease-out; }
@keyframes popIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px) scale(.95); }
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
.pop-item { font-size: 11px; padding: 6px 0; border-bottom: 1px solid #f4f5f7; line-height: 1.4; color: #5e6c84; }
.pop-item:last-child { border-bottom: none; }
.pop-item a { color: #0052cc; text-decoration: none; font-weight: 600; margin-right: 4px; }
.pop-item a:hover { text-decoration: underline; }
/* Jira Links */
.jira-link { color: #0052cc; text-decoration: none; font-weight: bold; border-bottom: 1px dashed rgba(0,82,204,.3); transition: color .2s; }
.jira-link:hover { color: #0747a6; border-bottom-style: solid; text-decoration: underline; }
/* Responsive */
@media (max-width: 768px) {
.kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; }
.charts-grid { grid-template-columns: 1fr; }
.chart-card.full { grid-column: 1; }
.main { padding: 16px; }
.section-header { gap: 8px; flex-direction: column; align-items: flex-start;}
.filter-group { margin-left: 0; }
.table-card { overflow-x: auto; }
}
+50
View File
@@ -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}")
+62
View File
@@ -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"
}
]
Generated
+11
View File
@@ -0,0 +1,11 @@
{
"version": "5",
"remote": {
"https://edge.netlify.com/": "fd941d61d88673d5f28aab283fb86fcc50f08a3bc80ee5470498fcfa88c65cfb",
"https://edge.netlify.com/bootstrap/config.ts": "6a2ce0e544e15e8f8883a5c18da5948e37fd0f2619f68cb31f3af53c51817025",
"https://edge.netlify.com/bootstrap/context.ts": "e97240232121e2f369f6546ce961490f34d961ea1ea54be3ff09633e3f08373f",
"https://edge.netlify.com/bootstrap/cookie.ts": "8b0baae708989ca183c6f3b4ab3d029e6abcbc2e43f93edeb0ff447b3bbc3a05",
"https://edge.netlify.com/bootstrap/edge_function.ts": "b8253e86aa83c67341f5cfedeba5049d77fbf84dcab7eceff7566b7728ae9b39",
"https://edge.netlify.com/bootstrap/globals/types.ts": "eaa6148ded3121d8dee62dd91c86e7fe76601df0f3ca8d7962243a30f4c8935f"
}
}
+91 -388
View File
@@ -3,454 +3,157 @@
<head>
<meta charset="UTF-8">
<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">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #172b4d; }
header { background: #0052cc; color: white; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
header h1 { font-size: 22px; font-weight: 700; }
.header-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.header-meta span { font-size: 12px; opacity: 0.75; }
.refresh-btn { background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.3); color: white; padding: 6px 14px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: background .15s; }
.refresh-btn:hover { background: rgba(255,255,255,.25); }
.refresh-btn:disabled { opacity: .5; cursor: not-allowed; }
.cache-badge { font-size: 11px; opacity: .7; }
/* Loading */
.loading-overlay { position: fixed; inset: 0; background: rgba(240,242,245,.9); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100; gap: 16px; }
.spinner { width: 40px; height: 40px; border: 3px solid #dfe1e6; border-top-color: #0052cc; border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.loading-overlay p { color: #5e6c84; font-size: 14px; }
/* Error */
.error-banner { background: #ffebe6; border: 1px solid #ff5630; color: #bf2600; padding: 14px 32px; font-size: 14px; display: none; }
.section-header { padding: 14px 32px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #dfe1e6; background: white; flex-wrap: wrap; }
.section-header.current { border-color: #0065ff; }
.section-header.history { border-color: #36b37e; }
.section-header h2 { font-size: 14px; font-weight: 700; }
.section-header .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.section-header.current .dot { background: #0065ff; }
.section-header.history .dot { background: #36b37e; }
.section-header label { font-size: 13px; font-weight: 600; color: #5e6c84; margin-left: auto; }
.section-header select { padding: 6px 12px; border: 1px solid #dfe1e6; border-radius: 4px; font-size: 14px; color: #172b4d; background: #fafbfc; cursor: pointer; outline: none; }
.section-header select:focus { border-color: #0052cc; }
.badge { background: #e3f2fd; color: #0052cc; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
.section-block { margin-bottom: 36px; }
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.kpi-row-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
.kpi { background: white; border-radius: 8px; padding: 20px 24px; border-left: 4px solid #dfe1e6; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.kpi.done { border-color: #36b37e; }
.kpi.progress{ border-color: #0065ff; }
.kpi.todo { border-color: #ff8b00; }
.kpi.members { border-color: #6554c0; }
.kpi-value { font-size: 36px; font-weight: 700; line-height: 1; }
.kpi.done .kpi-value { color: #36b37e; }
.kpi.progress .kpi-value { color: #0065ff; }
.kpi.todo .kpi-value { color: #ff8b00; }
.kpi.members .kpi-value { color: #6554c0; }
.kpi-label { font-size: 12px; color: #5e6c84; margin-top: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
.kpi-sub { font-size: 11px; color: #97a0af; margin-top: 4px; }
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
.chart-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
.chart-card.full { grid-column: 1 / -1; }
.chart-card h3 { font-size: 14px; font-weight: 700; color: #172b4d; margin-bottom: 4px; }
.chart-card .sub { font-size: 12px; color: #5e6c84; margin-bottom: 20px; }
.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; }
.table-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 10px 14px; background: #f4f5f7; color: #5e6c84; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #dfe1e6; }
td { padding: 12px 14px; border-bottom: 1px solid #f4f5f7; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #f8f9fa; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.pill.done { background: #e3fcef; color: #006644; }
.pill.progress { background: #deebff; color: #0052cc; }
.pill.todo { background: #fffae6; color: #7a4b00; }
.pbar-wrap { background: #f4f5f7; border-radius: 4px; height: 8px; width: 140px; overflow: hidden; display: flex; }
.pbar-done { background: #36b37e; height: 100%; }
.pbar-ip { background: #0065ff; height: 100%; }
.pbar-todo { background: #ff8b00; height: 100%; }
.avatar { width: 28px; height: 28px; border-radius: 50%; color: white; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; }
.name-cell { display: flex; align-items: center; }
.no-data { text-align: center; color: #97a0af; padding: 40px; font-size: 14px; }
footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; }
@media (max-width: 768px) {
.kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; }
.charts-grid { grid-template-columns: 1fr; }
.chart-card.full { grid-column: 1; }
.main { padding: 16px; }
.section-header { gap: 8px; }
.section-header label { margin-left: 0; }
}
</style>
<script type="module" src="./js/app.js" defer></script>
</head>
<body>
<!-- Loading overlay -->
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner"></div>
<p>Consultando Jira en tiempo real…</p>
</div>
<!-- Error banner -->
<div class="error-banner" id="errorBanner"></div>
<div class="error-banner" id="limitBanner" style="background:#fff7d6; color:#a15900; border-color:#ffab00;"></div>
<!-- Header Principal -->
<header>
<div>
<h1>📊 Dashboard LABS — Story Points</h1>
<h1>📊 Dashboard Infra</h1>
<div style="font-size:13px;opacity:.7;margin-top:4px;">
Proyecto: <strong>LABS</strong> · invera.atlassian.net · Campo: Desarrollador
Proyecto: <strong>Infra</strong> · invera.atlassian.net
</div>
</div>
<div class="header-meta">
<span id="lastUpdatedLabel">—</span>
<span class="cache-badge" id="cacheBadge"></span>
<button class="refresh-btn" id="refreshBtn" onclick="loadData(true)">↻ Actualizar</button>
<label style="font-size:12px;opacity:0.8;color:white;margin-right:-6px;">Rango:</label>
<select id="timeRangeFilter" style="padding:4px 8px; font-size:12px; border-radius:4px; max-width:120px;">
<option value="1d">Hoy</option>
<option value="7d">Últimos 7 días</option>
<option value="30d">Último mes</option>
<option value="90d" selected>Últimos 3 meses</option>
<option value="365d">Último año</option>
<option value="all">Histórico completo</option>
</select>
<button class="refresh-btn" id="refreshBtn">↻ Actualizar</button>
</div>
</header>
<!-- ── SECCIÓN 1: Estado Actual ── -->
<!-- ── SECCIÓN 1: Carga de Trabajo (Pendientes) ── -->
<div class="section-header current">
<div class="dot"></div>
<h2>Estado Actual del Equipo</h2>
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Tickets activos en el tablero ahora mismo</span>
<div class="dot" style="background:#0065ff;"></div>
<h2>Carga de Trabajo Actual</h2>
<span id="currentTicketsLabel" style="font-size:12px;color:#5e6c84;margin-left:8px;">Tickets pendientes en el tablero</span>
<div class="filter-group">
<label>Filtrar por Dev:</label>
<select id="devFilter"><option value="all">Cargando...</option></select>
</div>
</div>
<div class="main">
<div class="section-block">
<div class="kpi-row">
<div class="kpi progress"><div class="kpi-value" id="kpi-ip">—</div><div class="kpi-label">SP En Progreso</div><div class="kpi-sub">En curso · Code Review · QA</div></div>
<div class="kpi todo"> <div class="kpi-value" id="kpi-todo">—</div><div class="kpi-label">SP Por Hacer</div><div class="kpi-sub">Backlog asignado</div></div>
<div class="kpi done"> <div class="kpi-value" id="kpi-done-total">—</div><div class="kpi-label">SP Completados (total)</div><div class="kpi-sub">Histórico acumulado</div></div>
<div class="kpi members"><div class="kpi-value" id="kpi-members">—</div><div class="kpi-label">Devs activos</div><div class="kpi-sub">Con SP asignados</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="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 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 class="charts-grid">
<div class="chart-card">
<h3>🔵 SP en Progreso — por Desarrollador</h3>
<div class="sub">Estado actual · En curso / Code Review / QA</div>
<div style="position:relative;height:260px"><canvas id="chartInProgress"></canvas></div>
</div>
<div class="chart-card">
<h3>📦 Estado actual por Desarrollador</h3>
<div class="sub">Completados · En Progreso · Por Hacer (acumulado)</div>
<h3>🔵 Tkt pendientes</h3>
<div class="sub">Story Points con trabajo pendiente</div>
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
</div>
<div class="chart-card">
<h3 style="display:flex;align-items:center;">📢 Alertas de Salud
<div class="pop-trigger" style="margin-left:8px; font-size:14px; cursor:help; border:none;">ℹ️
<div class="pop-content" style="text-transform:none; font-weight:normal; letter-spacing:normal; text-align:left; font-size:12px; width:260px;">
<strong>Tipos de Alertas:</strong><br><br>
<span class="badge" style="background:#ffab00;color:white;font-size:10px;padding:2px 4px;">ESTANCADO</span> &rsaquo; +14 días en curso.<br><br>
<span class="badge" style="background:#ff5630;color:white;font-size:10px;padding:2px 4px;">VENCIDO</span> &rsaquo; Fecha de entrega caducada.<br><br>
<span class="badge" style="background:#6554c0;color:white;font-size:10px;padding:2px 4px;">SIN SP</span> &rsaquo; Completado sin estimación.<br><br>
<span class="badge" style="background:#ff8b00;color:white;font-size:10px;padding:2px 4px;">HUÉRFANO</span> &rsaquo; Ticket con SP sin asignar.<br><br>
<span class="badge" style="background:#36b37e;color:white;font-size:10px;padding:2px 4px;">DEV LIBRE</span> &rsaquo; Dev sin tickets pendientes ni en curso.
</div>
</div>
</h3>
<div class="sub">Tickets estancados o sin estimación</div>
<div id="warningsSection" style="display:none; overflow-y:auto; max-height:230px; margin-top:10px;">
<ul class="warning-list" id="warningsList" style="padding:0; margin:0;"></ul>
</div>
<div id="noWarningsMsg" style="text-align:center; color:#36b37e; padding:40px 0; display:none;">
✅ No hay alertas pendientes
</div>
</div>
</div>
<div class="table-card">
<h3>Detalle por Desarrollador — Estado Actual</h3>
<h3>Detalle de Carga de Trabajo</h3>
<table>
<thead><tr><th>Desarrollador</th><th>✅ Completados</th><th>🔵 En Progreso</th><th>⏳ Por Hacer</th><th>Total SP</th><th>Progreso</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>
</table>
</div>
</div>
</div>
<!-- ── SECCIÓN 2: Completados por período ── -->
<!-- ── SECCIÓN 2: Rendimiento & Resultados ── -->
<div class="section-header history">
<div class="dot"></div>
<h2>Completados por Período</h2>
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Filtrar por mes de resolución</span>
<label>Ver mes:</label>
<select id="monthFilter" onchange="updateHistory()"></select>
<span class="badge" id="filterBadge">Todos los meses</span>
<div class="dot" style="background:#36b37e;"></div>
<h2>Resultados del Período</h2>
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Story Points finalizados y velocidad</span>
<span class="badge" id="filterBadge" style="display:none;"></span>
</div>
<div class="main">
<div class="section-block">
<div class="kpi-row-3">
<div class="kpi done"> <div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">SP Completados</div><div class="kpi-sub" id="kpi-done-period-sub">en el período</div></div>
<div class="kpi members"><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"><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 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 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" 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="charts-grid">
<div class="chart-card">
<h3>✅ SP Completados — por Desarrollador</h3>
<div class="sub" id="subtitle-history-dev">Período seleccionado</div>
<h3>✅ Tickets Completados</h3>
<div class="sub">Período seleccionado</div>
<div style="position:relative;height:260px"><canvas id="chartDoneDev"></canvas></div>
</div>
<div class="chart-card">
<h3>📈 Velocidad del Equipo</h3>
<div class="sub">SP completados por mes · cada color = un dev</div>
<h3>📥 Solicitudes de Tickets</h3>
<div class="sub">Tickets creados por persona en el período</div>
<div style="position:relative;height:260px"><canvas id="chartVelocity"></canvas></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-card full">
<h3>🗂 SP por Épica — Estado Actual</h3>
<div class="sub">Completados · En Progreso · Por Hacer en cada épica</div>
<div style="position:relative;height:220px"><canvas id="chartEpic"></canvas></div>
</div>
</div>
<div class="table-card">
<h3 id="table-history-title">SP Completados — Todos los meses</h3>
<h3 style="display:flex; justify-content:space-between; align-items:center;">
<span>SP Completados — Tabla Resumen</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>
<table>
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>% del total del período</th></tr></thead>
<thead><tr><th>Desarrollador</th><th>✅ Tickets Total</th><th>📉 % del equipo</th></tr></thead>
<tbody id="tableHistory"></tbody>
</table>
</div>
</div>
</div>
<!-- ── SECCIÓN: Revisión de Código (Code Review) ── -->
<footer>
Dashboard en tiempo real · Proyecto LABS · invera.atlassian.net ·
Campo: <strong>Desarrollador</strong> (customfield_10622) + SP Finales (customfield_11019) ·
Cache: 5 min · <span id="totalIssuesLabel"></span>
Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
</footer>
<script>
// ── Colores ────────────────────────────────────────────────────────────────
const COLORS = [
'#0052cc','#36b37e','#ff5630','#6554c0','#ff8b00',
'#00b8d9','#57d9a3','#f99cdb','#8777d9','#344563',
'#403294','#00875a','#de350b','#505f79','#42526e',
];
const MONTH_LABELS_MAP = {
'2025-01':'Ene 2025','2025-02':'Feb 2025','2025-03':'Mar 2025',
'2025-04':'Abr 2025','2025-05':'May 2025','2025-06':'Jun 2025',
'2025-07':'Jul 2025','2025-08':'Ago 2025','2025-09':'Sep 2025',
'2025-10':'Oct 2025','2025-11':'Nov 2025','2025-12':'Dic 2025',
'2026-01':'Ene 2026','2026-02':'Feb 2026','2026-03':'Mar 2026',
'2026-04':'Abr 2026','2026-05':'May 2026','2026-06':'Jun 2026',
};
function monthLabel(m) { return MONTH_LABELS_MAP[m] || m; }
// ── Helpers ────────────────────────────────────────────────────────────────
let _peopleOrder = [];
function personColor(name) {
let idx = _peopleOrder.indexOf(name);
if (idx < 0) { _peopleOrder.push(name); idx = _peopleOrder.length - 1; }
return COLORS[idx % COLORS.length];
}
function initials(name) { return name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(); }
function shortName(name) { const p = name.split(' '); return p[0] + (p[1] ? ' ' + p[1] : ''); }
// ── Chart instances ────────────────────────────────────────────────────────
let charts = {};
function destroyChart(id) { if (charts[id]) { charts[id].destroy(); delete charts[id]; } }
// ── Global data ────────────────────────────────────────────────────────────
let DATA = null;
// ── Load data from API ─────────────────────────────────────────────────────
async function loadData(forceRefresh = false) {
document.getElementById('loadingOverlay').style.display = 'flex';
document.getElementById('errorBanner').style.display = 'none';
document.getElementById('refreshBtn').disabled = true;
try {
const url = '/api/data' + (forceRefresh ? '?refresh=1' : '');
const res = await fetch(url);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `HTTP ${res.status}`);
}
DATA = await res.json();
_peopleOrder = Object.keys(DATA.assigneeData);
buildMonthSelector();
renderCurrentSection();
renderHistorySection();
// Last updated label
const d = new Date(DATA.lastUpdated);
document.getElementById('lastUpdatedLabel').textContent =
'Actualizado: ' + d.toLocaleString('es-AR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
document.getElementById('cacheBadge').textContent = DATA.fromCache ? '(desde caché)' : '(CACHE)';
if (DATA.jiraTotal > DATA.totalIssues) {
document.getElementById('totalIssuesLabel').textContent =
`Mostrando ${DATA.totalIssues} de ${DATA.jiraTotal} issues totales con SP`;
}
} catch(e) {
const banner = document.getElementById('errorBanner');
banner.style.display = 'block';
banner.textContent = '⚠️ Error al cargar datos: ' + e.message;
console.error(e);
} finally {
document.getElementById('loadingOverlay').style.display = 'none';
document.getElementById('refreshBtn').disabled = false;
}
}
// ── Month selector ─────────────────────────────────────────────────────────
function buildMonthSelector() {
const sel = document.getElementById('monthFilter');
const months = Object.keys(DATA.monthlyDone).sort();
sel.innerHTML = '<option value="all">Todos los meses</option>'
+ months.map(m => `<option value="${m}">${monthLabel(m)}</option>`).join('');
// Default: mostrar el mes más reciente
if (months.length > 0) sel.value = months[months.length - 1];
}
// ── Sección 1: Estado Actual ───────────────────────────────────────────────
function renderCurrentSection() {
const ad = DATA.assigneeData;
const people = Object.keys(ad).filter(p => ad[p].done + ad[p].in_progress + ad[p].todo > 0);
// KPIs
let ip = 0, todo = 0, done = 0, devs = 0;
people.forEach(p => {
ip += ad[p].in_progress;
todo += ad[p].todo;
done += ad[p].done;
devs++;
});
document.getElementById('kpi-ip').textContent = ip;
document.getElementById('kpi-todo').textContent = todo;
document.getElementById('kpi-done-total').textContent = done;
document.getElementById('kpi-members').textContent = devs;
// Chart: solo en progreso
{
destroyChart('inProgress');
const rows = people.filter(p => ad[p].in_progress > 0).sort((a,b) => ad[b].in_progress - ad[a].in_progress);
charts['inProgress'] = new Chart(document.getElementById('chartInProgress'), {
type: 'bar',
data: {
labels: rows.map(shortName),
datasets: [{ label: 'SP en progreso', data: rows.map(p => ad[p].in_progress),
backgroundColor: rows.map(personColor), borderRadius: 5 }]
},
options: { responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:false } },
scales:{ x:{ grid:{display:false}, ticks:{font:{size:11}} },
y:{ beginAtZero:true, ticks:{font:{size:11}}, grid:{color:'#f0f2f5'} } } }
});
}
// Chart: estado full
{
destroyChart('currentState');
const rows = [...people].sort((a,b) => (ad[b].done+ad[b].in_progress)-(ad[a].done+ad[a].in_progress));
charts['currentState'] = new Chart(document.getElementById('chartCurrentState'), {
type: 'bar',
data: {
labels: rows.map(shortName),
datasets: [
{ label:'Completados', data:rows.map(p=>ad[p].done), backgroundColor:'#36b37e', borderRadius:3 },
{ label:'En Progreso', data:rows.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 },
{ label:'Por Hacer', data:rows.map(p=>ad[p].todo), backgroundColor:'#ff8b00', borderRadius:3 },
]
},
options:{ responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ position:'bottom', labels:{ font:{size:11} } } },
scales:{ x:{stacked:true,grid:{display:false},ticks:{font:{size:11}}},
y:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
});
}
// Tabla estado actual
const tbody = document.getElementById('tableCurrentState');
tbody.innerHTML = '';
const sorted = [...people].sort((a,b) => (ad[b].done+ad[b].in_progress)-(ad[a].done+ad[a].in_progress));
sorted.forEach(p => {
const d = ad[p], total = d.done + d.in_progress + d.todo;
const dp = pct(d.done, total), ipp = pct(d.in_progress, total), tp = pct(d.todo, total);
const c = personColor(p);
tbody.innerHTML += `<tr>
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
<td><span class="pill done">${d.done} SP</span></td>
<td><span class="pill progress">${d.in_progress} SP</span></td>
<td><span class="pill todo">${d.todo} SP</span></td>
<td><strong>${total}</strong></td>
<td><div class="pbar-wrap"><div class="pbar-done" style="width:${dp}%"></div><div class="pbar-ip" style="width:${ipp}%"></div><div class="pbar-todo" style="width:${tp}%"></div></div></td>
</tr>`;
});
}
// ── Sección 2: Historial ───────────────────────────────────────────────────
function renderHistorySection() {
buildVelocityChart();
buildEpicChart();
updateHistory();
}
function buildVelocityChart() {
destroyChart('velocity');
const months = Object.keys(DATA.monthlyDone).sort();
const allDevs = [...new Set(months.flatMap(m => Object.keys(DATA.monthlyDone[m])))];
charts['velocity'] = new Chart(document.getElementById('chartVelocity'), {
type: 'bar',
data: {
labels: months.map(monthLabel),
datasets: allDevs.map((p, i) => ({
label: shortName(p),
data: months.map(m => DATA.monthlyDone[m][p] || 0),
backgroundColor: COLORS[i % COLORS.length],
borderRadius: 3,
}))
},
options:{ responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ position:'bottom', labels:{ font:{size:10}, boxWidth:12 } } },
scales:{ x:{stacked:true,grid:{display:false},ticks:{font:{size:11}}},
y:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
});
}
function buildEpicChart() {
destroyChart('epic');
const top = [...DATA.epicData]
.sort((a,b) => (b.done+b.in_progress+b.todo)-(a.done+a.in_progress+a.todo))
.slice(0, 15);
charts['epic'] = new Chart(document.getElementById('chartEpic'), {
type: 'bar',
data: {
labels: top.map(e => e.name.length > 42 ? e.name.slice(0,40)+'…' : e.name),
datasets: [
{ label:'Completados', data:top.map(e=>e.done), backgroundColor:'#36b37e', borderRadius:3 },
{ label:'En Progreso', data:top.map(e=>e.in_progress), backgroundColor:'#0065ff', borderRadius:3 },
{ label:'Por Hacer', data:top.map(e=>e.todo), backgroundColor:'#ff8b00', borderRadius:3 },
]
},
options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ position:'bottom', labels:{ font:{size:11} } } },
scales:{ x:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}},
y:{stacked:true,grid:{display:false},ticks:{font:{size:11}}} } }
});
}
function updateHistory() {
if (!DATA) return;
const month = document.getElementById('monthFilter').value;
document.getElementById('filterBadge').textContent =
month === 'all' ? 'Todos los meses' : monthLabel(month);
// Aggregate
let periodData = {};
if (month === 'all') {
Object.values(DATA.monthlyDone).forEach(md => {
Object.entries(md).forEach(([p, sp]) => { periodData[p] = (periodData[p] || 0) + sp; });
});
} else {
periodData = { ...(DATA.monthlyDone[month] || {}) };
}
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length;
const avgSP = devCount > 0 ? Math.round(totalSP / devCount) : 0;
document.getElementById('kpi-done-period').textContent = totalSP;
document.getElementById('kpi-devs-period').textContent = devCount;
document.getElementById('kpi-avg-period').textContent = avgSP;
const lbl = month === 'all' ? 'todos los meses' : monthLabel(month);
document.getElementById('kpi-done-period-sub').textContent = `SP completados · ${lbl}`;
document.getElementById('subtitle-history-dev').textContent = `SP completados en ${lbl}`;
document.getElementById('table-history-title').textContent =
`SP Completados — ${month === 'all' ? 'Todos los meses' : monthLabel(month)}`;
// Chart done by dev
destroyChart('doneDev');
const sorted = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
charts['doneDev'] = new Chart(document.getElementById('chartDoneDev'), {
type: 'bar',
data: {
labels: sorted.map(([p]) => shortName(p)),
datasets: [{ label:'SP completados', data:sorted.map(([,sp]) => sp),
backgroundColor: sorted.map(([p]) => personColor(p)), borderRadius:5 }]
},
options:{ responsive:true, maintainAspectRatio:false,
plugins:{ legend:{ display:false } },
scales:{ x:{grid:{display:false},ticks:{font:{size:11}}},
y:{beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
});
// Tabla historial
const tbody = document.getElementById('tableHistory');
tbody.innerHTML = '';
if (sorted.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="no-data">No hay datos para el período seleccionado</td></tr>';
return;
}
sorted.forEach(([p, sp]) => {
const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0;
const c = personColor(p);
tbody.innerHTML += `<tr>
<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><div style="display:flex;align-items:center;gap:8px;">
<div style="background:#f0f2f5;border-radius:4px;height:8px;width:120px;overflow:hidden;">
<div style="background:#36b37e;height:100%;width:${p_}%"></div>
</div>
<span style="font-size:12px;color:#5e6c84;">${p_}%</span>
</div></td>
</tr>`;
});
}
// ── Utils ──────────────────────────────────────────────────────────────────
function pct(val, total) { return total > 0 ? (val / total * 100).toFixed(1) : 0; }
// ── Auto-refresh cada 24 horas ────────────────────────────────────────────
setInterval(() => loadData(false), 24 * 60 * 60 * 1000);
// ── Init ───────────────────────────────────────────────────────────────────
loadData();
</script>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
/**
* js/api.js
* Centraliza la comunicación con el Backend (Netlify Functions)
*/
export const api = {
data: null, // Guarda la última respuesta en memoria del cliente
/**
* Obtiene los datos de JIRA desde nuestro backend
* @param {boolean} forceRefresh - Forzar bypassing de la caché del backend
*/
async fetchDashboardData(forceRefresh = false, period = '90d') {
// Para Netlify Functions + Redirección en netlify.toml
let url = `/api/data?period=${period}`;
if (forceRefresh) url += '&refresh=1';
try {
const res = await fetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
throw new Error(err.error || `HTTP ${res.status}`);
}
const json = await res.json();
this.data = json; // Guardar en memoria global de la app
return json;
} catch (e) {
console.error('Error al cargar datos:', e);
throw e;
}
}
};
+357
View File
@@ -0,0 +1,357 @@
/**
* js/app.js
* Controlador principal de la UI. Lee desde api.js y actualiza el DOM + charts.js
*/
import { api } from './api.js';
import { chartsManager } from './charts.js';
// utils
function shortName(name) { const p = name.split(' '); return p[0] + (p[1] ? ' ' + p[1] : ''); }
function initials(name) { return name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(); }
function pct(val, total) { return total > 0 ? (val / total * 100).toFixed(1) : 0; }
const state = {
selectedDev: 'all',
selectedPeriod: '90d'
};
// ── INIT ───────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
// Load from localStorage
const savedDev = localStorage.getItem('dash_selectedDev');
const savedPeriod = localStorage.getItem('dash_selectedPeriod');
if (savedDev) state.selectedDev = savedDev;
if (savedPeriod) state.selectedPeriod = savedPeriod;
document.getElementById('refreshBtn').addEventListener('click', () => loadAndRender(true));
document.getElementById('devFilter').addEventListener('change', (e) => {
state.selectedDev = e.target.value;
localStorage.setItem('dash_selectedDev', state.selectedDev);
renderCurrentSection();
renderHistorySection();
});
document.getElementById('timeRangeFilter').addEventListener('change', (e) => {
state.selectedPeriod = e.target.value;
localStorage.setItem('dash_selectedPeriod', state.selectedPeriod);
loadAndRender(false);
});
// Set initial value in UI from state (which might come from localStorage)
const periodFilter = document.getElementById('timeRangeFilter');
if (periodFilter) periodFilter.value = state.selectedPeriod;
loadAndRender();
});
async function loadAndRender(forceRefresh = false) {
const loading = document.getElementById('loadingOverlay');
const errorBanner = document.getElementById('errorBanner');
loading.style.display = 'flex';
errorBanner.style.display = 'none';
document.getElementById('refreshBtn').disabled = true;
try {
const data = await api.fetchDashboardData(forceRefresh, state.selectedPeriod);
const limitBanner = document.getElementById('limitBanner');
if (data.isTruncated) {
limitBanner.style.display = 'block';
limitBanner.textContent = `⚠️ Advertencia de límite: Solo se procesaron 1500 tickets en este período debido a las políticas de seguridad (Total real encontrado: ${data.jiraTotal}). Reducí el rango de fechas para ver el detalle exacto.`;
} else {
limitBanner.style.display = 'none';
}
buildFilters(data);
renderHeader(data);
renderHealthWarnings(data.warnings);
renderCurrentSection();
renderHistorySection();
} catch(e) {
errorBanner.style.display = 'block';
errorBanner.textContent = '⚠️ Error al cargar datos: ' + e.message;
} finally {
loading.style.display = 'none';
document.getElementById('refreshBtn').disabled = false;
}
}
// ── FILTERS & HEADER ───────────────────────────────────────────────────────
function buildFilters(data) {
const devsSet = new Set();
data.issuesList.forEach(issue => {
issue.devs.forEach(d => devsSet.add(d));
});
const devSel = document.getElementById('devFilter');
devSel.innerHTML = '<option value="all">Todos los desarrolladores</option>'
+ [...devsSet].sort().map(d => `<option value="${d}">${d}</option>`).join('');
devSel.value = state.selectedDev;
}
function renderHeader(data) {
const d = new Date(data.lastUpdated);
document.getElementById('lastUpdatedLabel').textContent =
'Actualizado: ' + d.toLocaleString('es-AR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
document.getElementById('cacheBadge').textContent = data.fromCache ? '(desde caché)' : '(CACHE)';
}
function getJiraUrl(key) {
const host = api.data?.config?.host || 'invera.atlassian.net';
return `https://${host}/browse/${key}`;
}
// ── HEALTH WARNINGS ────────────────────────────────────────────────────────
function renderHealthWarnings(warnings = []) {
const container = document.getElementById('warningsSection');
const list = document.getElementById('warningsList');
const noMsg = document.getElementById('noWarningsMsg');
if(!warnings || warnings.length === 0) {
container.style.display = 'none';
if (noMsg) noMsg.style.display = 'block';
return;
}
container.style.display = 'block';
if (noMsg) noMsg.style.display = 'none';
list.innerHTML = warnings.map(w => {
let text = '';
let badge = w.type;
const link = w.key ? `<a href="${getJiraUrl(w.key)}" target="_blank" class="jira-link">${w.key}</a>` : '';
if(w.type === 'stalled') { badge = 'ESTANCADO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.devs.join(', ')})`; }
if(w.type === 'expired') { badge = 'VENCIDO'; text = `🗓️ [${link}] ${w.summary.slice(0,50)}... (Vencido hace ${w.days} días)`; }
if(w.type === 'done_no_sp') { badge = 'SIN SP'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (Completado sin estimación)`; }
if(w.type === 'unassigned_sp') { badge = 'HUÉRFANO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.sp} SP sin dueño)`; }
if(w.type === 'free_dev') { badge = 'DEV LIBRE'; text = `👤 ${w.summary}`; }
return `<li class="warning-item" title="${w.summary}"><span class="badge" style="width:90px;text-align:center;">${badge}</span> <span class="title" style="font-size:11px;">${text}</span></li>`;
}).join('');
}
// ── CURRENT SECTION (Interactive filtered) ─────────────────────────────────
function renderCurrentSection() {
const data = api.data;
if (!data) return;
// 1. Filtrar solo pendientes (En progreso o To Do) + Filtro de Dev
let allPending = data.issuesList.filter(i => i.status !== 'done');
// Para el label "Mostrando X de Y", filtramos por Dev
let filtered = allPending;
if (state.selectedDev !== 'all') {
filtered = allPending.filter(i => i.devs.includes(state.selectedDev));
}
// Agrupar métricas para los KPIs (basado en el filtro de Dev)
const ad = {};
let totalDevs = new Set();
filtered.forEach(i => {
i.devs.forEach(dev => {
if(state.selectedDev !== 'all' && dev !== state.selectedDev) return;
if (!ad[dev]) ad[dev] = { in_progress: 0, todo: 0, issues_ip: [], issues_todo: [] };
if (i.status === 'indeterminate') {
ad[dev].in_progress += i.sp;
ad[dev].issues_ip.push(i);
} else {
ad[dev].todo += i.sp;
ad[dev].issues_todo.push(i);
}
totalDevs.add(dev);
});
});
let ip = 0, todo = 0;
Object.values(ad).forEach(d => { ip += d.in_progress; todo += d.todo; });
document.getElementById('kpi-ip').textContent = ip;
document.getElementById('kpi-todo').textContent = todo;
document.getElementById('kpi-members').textContent = totalDevs.size;
// Conteo de tickets
const label = document.getElementById('currentTicketsLabel');
if (label) {
label.textContent = state.selectedDev === 'all'
? `Hay ${allPending.length} tickets pendientes en total`
: `Mostrando ${filtered.length} tickets pendientes para ${state.selectedDev}`;
}
// Gráfico: Comparativa IP vs Todo (Stacked)
// "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), [
{ 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:colorTodo, borderRadius:3 },
], { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
// Tabla detallada (Solo pendientes)
const tbody = document.getElementById('tableCurrentState');
tbody.innerHTML = '';
people.forEach(p => {
const d = ad[p], total = d.in_progress + d.todo;
const c = p === 'Sin asignar' ? '#97a0af' : chartsManager.personColor(p);
const hasIPAlert = d.issues_ip.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled'));
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, '&quot;')}">${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>
<td>
<div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div>
</td>
<td>
<div class="pop-trigger">
<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>
</td>
<td>
<div class="pop-trigger">
<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>
</td>
<td><strong>${total} tkts</strong></td>
<td><div class="pbar-wrap">
<div class="pbar-ip" style="width:${pct(d.in_progress, total)}%"></div>
<div class="pbar-todo" style="width:${pct(d.todo, total)}%"></div>
</div></td>
</tr>`;
});
}
// ── HISTORY SECTION ────────────────────────────────────────────────────────
function renderHistorySection() {
const data = api.data;
if(!data) return;
// Consolidar todos los meses del rango actual
let periodData = {};
let periodTicketCount = {};
Object.keys(data.assigneeData || {}).forEach(p => {
if (state.selectedDev !== 'all' && p !== state.selectedDev) return;
periodData[p] = 0;
periodTicketCount[p] = 0;
});
data.issuesList.filter(i => i.status === 'done').forEach(i => {
i.devs.forEach(p => {
if (state.selectedDev !== 'all' && p !== state.selectedDev) return;
periodData[p] = (periodData[p] || 0) + i.sp;
periodTicketCount[p] = (periodTicketCount[p] || 0) + 1;
});
});
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
const devsWithActiveTickets = Object.keys(periodData).filter(p => periodData[p] > 0).length;
// Promedio global del equipo usando total de devs (incluyendo los de 0) para medir impacto real
const allDevsCount = Object.keys(periodData).length;
const teamAvgSP = allDevsCount > 0 ? (totalSP / allDevsCount) : 0;
const labelEl = document.getElementById('teamAvgLabel');
if (labelEl && teamAvgSP > 0 && state.selectedDev === 'all') {
labelEl.textContent = `🎯 Media Equipo: ${teamAvgSP.toFixed(1)} SP por Dev`;
labelEl.style.display = 'inline-block';
} else if (labelEl) {
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-avg-period').textContent = avgPerWeek;
// 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)
const sortedGraph = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
chartsManager.drawBarChart('doneDev', 'chartDoneDev', sortedGraph.map(([p]) => shortName(p)), [{
label: 'SP entregados', data: sortedGraph.map(([,sp]) => sp),
backgroundColor: sortedGraph.map(([p]) => chartsManager.personColor(p)), borderRadius: 5
}], { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}} } });
// Tabla de Resultados
const sortedAll = Object.entries(periodData).sort((a,b) => b[1]-a[1]);
const tbody = document.getElementById('tableHistory');
tbody.innerHTML = '';
if (sortedAll.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="no-data">No hay entregas en este período</td></tr>';
} else {
sortedAll.forEach(([p, sp]) => {
const p_vsTeam = teamAvgSP > 0 ? (sp / teamAvgSP * 100).toFixed(0) : 0;
const c = chartsManager.personColor(p);
const tickets = periodTicketCount[p] || 0;
const avgPerTicket = tickets > 0 ? (sp / tickets).toFixed(1) : 0;
let badgeHtml = '';
if (sp === 0) {
badgeHtml = `<span class="badge" style="background:#ff5630;color:white;font-size:10px;">0 SP (SIN ENTREGAS)</span>`;
} else if (p_vsTeam >= 150) {
badgeHtml = `<span style="font-size:12px;color:#36b37e;font-weight:bold;">🔥 +${p_vsTeam - 100}% sup. prom.</span>`;
} else if (p_vsTeam <= 50) {
badgeHtml = `<span style="font-size:12px;color:#ff8b00;font-weight:bold;">📉 -${100 - p_vsTeam}% del prom.</span>`;
} else {
badgeHtml = `<span style="font-size:12px;color:#5e6c84;">~ ${p_vsTeam}% del prom.</span>`;
}
tbody.innerHTML += `<tr>
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
<td><span class="pill done">${tickets} tkts</span></td>
<td>${badgeHtml}</td>
</tr>`;
});
}
// Gráfico de Solicitudes por persona (reporter)
const reporterData = data.reporterData || {};
const sortedReporters = Object.entries(reporterData).sort((a, b) => b[1] - a[1]);
chartsManager.drawBarChart('velocity', 'chartVelocity',
sortedReporters.map(([p]) => shortName(p)),
[{
label: 'Tickets solicitados',
data: sortedReporters.map(([, v]) => v),
backgroundColor: sortedReporters.map(([p]) => chartsManager.personColor(p)),
borderRadius: 5
}],
{ plugins:{ legend:{ display:false } }, scales:{ x:{ grid:{ display:false } } } }
);
}
+84
View File
@@ -0,0 +1,84 @@
/**
* js/charts.js
* Encargado de instanciar, dibujar y destruir gráficos de Chart.js
*/
const COLORS = [
'#0052cc','#36b37e','#ff5630','#6554c0','#ff8b00',
'#00b8d9','#57d9a3','#f99cdb','#8777d9','#344563',
'#403294','#00875a','#de350b','#505f79','#42526e',
];
export const chartsManager = {
instances: {},
_peopleOrder: [],
personColor(name) {
let idx = this._peopleOrder.indexOf(name);
if (idx < 0) {
this._peopleOrder.push(name);
idx = this._peopleOrder.length - 1;
}
return COLORS[idx % COLORS.length];
},
destroyChart(id) {
if (this.instances[id]) {
this.instances[id].destroy();
delete this.instances[id];
}
},
drawBarChart(id, canvasId, labels, datasets, options = {}) {
this.destroyChart(id);
const ctx = document.getElementById(canvasId);
if (!ctx) return;
this.instances[id] = new Chart(ctx, {
type: 'bar',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
...options,
}
});
},
drawDoughnutChart(id, canvasId, labels, data, backgroundColors, options = {}) {
this.destroyChart(id);
const ctx = document.getElementById(canvasId);
if (!ctx) return;
this.instances[id] = new Chart(ctx, {
type: 'doughnut',
data: {
labels,
datasets: [{ data, backgroundColor: backgroundColors, borderWidth: 2, borderColor: '#fff' }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } },
...options,
}
});
},
drawLineChart(id, canvasId, labels, datasets, options = {}) {
this.destroyChart(id);
const ctx = document.getElementById(canvasId);
if (!ctx) return;
this.instances[id] = new Chart(ctx, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
tension: 0.4,
...options,
}
});
},
};
+36
View File
@@ -0,0 +1,36 @@
# Configuración de Netlify para Dashboard JIRA
[build]
# Comando para compilar el proyecto (si usas un build tool)
# command = "npm run build"
# publish = "dist"
# Si NO tienes build tool, comenta lo anterior
# Redireccionar /api/* a las funciones Netlify
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/:splat"
status = 200
# Redireccionar todas las demás rutas a index.html (SPA)
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Configuración de funciones Netlify
[functions]
# Ruta donde están las funciones
directory = "netlify/functions"
# Bundler para Node.js
node_bundler = "esbuild"
# Headers para CORS y cache
[[headers]]
for = "/.netlify/functions/*"
[headers.values]
Access-Control-Allow-Origin = "*"
Access-Control-Allow-Methods = "GET, POST, OPTIONS"
Access-Control-Allow-Headers = "Content-Type"
Cache-Control = "s-maxage=3600, stale-while-revalidate=86400"
+450
View File
@@ -0,0 +1,450 @@
/**
* netlify/functions/data.js — Netlify Function (Convertido de Vercel)
*
* Consulta JIRA en tiempo real y devuelve datos procesados del dashboard.
*/
// Configuración desde variables de entorno
const config = {
jira: {
host: process.env.JIRA_HOST || 'invera.atlassian.net',
project: process.env.JIRA_PROJECT || 'Infra',
email: process.env.JIRA_EMAIL,
token: process.env.JIRA_TOKEN,
},
fields: {
spEstimate: process.env.JIRA_FIELD_SP_ESTIMATE || 'customfield_10016',
spFinal: process.env.JIRA_FIELD_SP_FINAL || 'customfield_11019',
developer: process.env.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',
},
jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND issuetype not in (Epic, "Sub-task", Subtask) ORDER BY updated DESC',
api: {
cacheMsec: parseInt(process.env.CACHE_MS || '3600000'), // 1 hora por defecto
},
};
// ─── Cache en memoria (Netlify Functions pueden persistir entre llamadas calientes) ───
let _cache = {};
let _cacheTime = {};
// ─── JIRA Fetch ────────────────────────────────────────────────────
async function fetchJiraIssues(email, token, period) {
const auth = Buffer.from(`${email}:${token}`).toString('base64');
const maxResultsWanted = 1500;
let allIssues = [];
let startAt = 0;
let dynamicJql = config.jql
.replace('${PROJECT}', config.jira.project)
.replace('${FIELD_SP_ESTIMATE}', config.fields.spEstimate);
if (period !== 'all') {
const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`;
if (dynamicJql.includes(' ORDER BY ')) {
dynamicJql = dynamicJql.replace(' ORDER BY ', ` AND updated >= ${timeFilter} ORDER BY `);
} else {
dynamicJql += ` AND updated >= ${timeFilter}`;
}
}
console.log(`📡 Consultando JIRA: ${config.jira.project} (periodo: ${period})`);
let nextPageToken = null;
const fieldsParam = encodeURIComponent(buildFieldsList());
const jqlParam = encodeURIComponent(dynamicJql);
while (allIssues.length < maxResultsWanted) {
let searchUrl = `https://${config.jira.host}/rest/api/3/search/jql?jql=${jqlParam}&fields=${fieldsParam}&maxResults=100`;
if (nextPageToken) {
searchUrl += `&nextPageToken=${encodeURIComponent(nextPageToken)}`;
}
const res = await fetch(searchUrl, {
method: 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Accept': 'application/json'
}
});
if (!res.ok) {
const text = await res.text();
throw new Error(`JIRA API ${res.status}: ${text.slice(0, 200)}`);
}
const data = await res.json();
const items = data.issues || [];
allIssues = allIssues.concat(items);
nextPageToken = data.nextPageToken;
if (!nextPageToken || items.length === 0) {
break;
}
}
return { issues: allIssues, isTruncated: !!nextPageToken };
}
function buildFieldsList() {
return [
'summary', 'status', 'assignee', 'resolutiondate', 'created', 'updated', 'duedate',
config.fields.spEstimate, config.fields.spFinal, config.fields.developer,
config.fields.qaAssignee, config.fields.qaEntryRtqa, config.fields.qaTotalTimeRtqa,
config.fields.qaLastDurRtqa, config.fields.qaEntryQa, config.fields.qaTotalTimeQa,
config.fields.qaLastDurQa, config.fields.timesReturned, config.fields.spFinalReal,
config.fields.crEntry, config.fields.crTotalTime, config.fields.enCursoEntry,
config.fields.enCursoTotalTime,
].join(',');
}
function minsToHrs(mins) {
if (mins == null || mins === 0) return 0;
return Math.round((mins / 60) * 10) / 10;
}
function processIssues(issues, period = '90d') {
const assigneeData = {};
const monthlyDone = {};
const issuesList = [];
const warnings = [];
const now = new Date();
let cutoffDate = null;
if (period !== 'all') {
const periodDays = period === '1d' ? 1 : parseInt(period.replace('d', ''));
if (!isNaN(periodDays)) {
if (period === '1d') {
cutoffDate = new Date();
cutoffDate.setHours(0,0,0,0);
} else {
cutoffDate = new Date(Date.now() - (periodDays * 24 * 60 * 60 * 1000));
}
}
}
const qaData = {
tickets: 0, ticketsReturned: 0, totalTimeQa: 0, totalTimeRtqa: 0,
monthlyTotalQa: {}, monthlyCountQa: {}, monthlyTotalRtqa: {}, monthlyCountRtqa: {}, issues: [],
timesQa: [], timesRtqa: []
};
const deliveryData = {
totalLeadTimeDays: 0, totalCycleTimeDays: 0, doneTicketsLead: 0, doneTicketsCycle: 0,
doneSpOriginal: 0, doneSpFinal: 0
};
const crData = {
tickets: 0, totalTimeCr: 0, issues: [], monthlyTotalCr: {}, monthlyCountCr: {}, stalledIssues: [], times: []
};
const currentStates = {
codeReview: { count: 0, sp: 0 },
readyToQa: { count: 0, sp: 0 },
inQa: { count: 0, sp: 0 }
};
for (const issue of issues) {
try {
const f = issue.fields;
let devs = [];
if (f[config.fields.developer]?.length) {
devs = f[config.fields.developer].map(d => d.displayName);
} else if (f.assignee) {
devs = [f.assignee.displayName];
} else {
devs = ['Sin asignar'];
}
const INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno'];
devs = devs.filter(d => !INACTIVE_DEVS.includes(d));
if (devs.length === 0) continue;
const sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0;
const statusKey = f.status?.statusCategory?.key ?? 'new';
const statusName = f.status?.name?.toLowerCase() ?? '';
const isDone = statusKey === 'done';
if (isDone && f.resolutiondate && cutoffDate) {
const resDate = new Date(f.resolutiondate);
if (resDate < cutoffDate) {
continue; // Ticket resuelto antes del período (actualizado recientemente por otro motivo)
}
}
const isInProgress = statusKey === 'indeterminate';
if (!isDone) {
if (statusName === 'code review' || statusName.includes('code review') || statusName === 'cr') {
currentStates.codeReview.count++;
currentStates.codeReview.sp += sp;
} else if (statusName === 'ready to qa' || statusName.includes('ready to qa') || statusName === 'listo para qa') {
currentStates.readyToQa.count++;
currentStates.readyToQa.sp += sp;
} else if (statusName === 'qa' || statusName.includes('in qa') || statusName === 'en qa') {
currentStates.inQa.count++;
currentStates.inQa.sp += sp;
}
}
const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null;
const creMonth = f.created ? f.created.slice(0, 7) : null;
for (const dev of devs) {
if (!assigneeData[dev]) assigneeData[dev] = { done: 0, in_progress: 0, todo: 0, pending_count: 0 };
if (isDone) {
assigneeData[dev].done += sp;
} else if (isInProgress) {
assigneeData[dev].in_progress += sp;
assigneeData[dev].pending_count++;
} else {
assigneeData[dev].todo += sp;
assigneeData[dev].pending_count++;
}
}
if (isDone) {
const month = resMonth || creMonth;
if (month) {
if (!monthlyDone[month]) monthlyDone[month] = {};
for (const dev of devs) {
monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp;
}
}
// Delivery Metrics
const createdDate = f.created ? new Date(f.created) : null;
const resolvedDate = f.resolutiondate ? new Date(f.resolutiondate) : null;
const enCursoDate = f[config.fields.enCursoEntry] ? new Date(f[config.fields.enCursoEntry]) : null;
const spOriginal = f[config.fields.spEstimate] || 0;
const spReal = f[config.fields.spFinalReal] != null ? f[config.fields.spFinalReal] : spOriginal;
if (resolvedDate) {
if (createdDate) {
deliveryData.totalLeadTimeDays += (resolvedDate - createdDate) / (1000 * 60 * 60 * 24);
deliveryData.doneTicketsLead++;
}
if (enCursoDate) {
deliveryData.totalCycleTimeDays += (resolvedDate - enCursoDate) / (1000 * 60 * 60 * 24);
deliveryData.doneTicketsCycle++;
}
deliveryData.doneSpOriginal += spOriginal;
deliveryData.doneSpFinal += spReal;
}
}
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 timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
let timesRet = f[config.fields.timesReturned] ?? 0;
// Ignorar valores erróneos acumulados por la automatización vieja
const automationFixDate = new Date('2026-04-07T00:00:00-03:00');
const issueCreated = f.created ? new Date(f.created) : null;
if (issueCreated && issueCreated < automationFixDate) {
timesRet = 1; // Lo forzamos a 1 (no devuelto) para que no rompa las métricas con data sucia
}
const qaMonth = resMonth || creMonth;
const timeCrMin = f[config.fields.crTotalTime] ?? 0;
const timeDevMin = f[config.fields.enCursoTotalTime] ?? 0;
if (timeQaMin > 0) {
qaData.tickets++;
qaData.totalTimeQa += timeQaMin;
qaData.timesQa.push(timeQaMin);
if (timesRet > 1) qaData.ticketsReturned++;
if (qaMonth) {
qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin;
qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1;
}
qaData.issues.push({
key: issue.key, summary: f.summary, timeQa: minsToHrs(timeQaMin), timeRtqa: minsToHrs(timeRtqaMin),
timesReturned: timesRet, status: statusKey, resMonth: qaMonth,
timeCr: minsToHrs(timeCrMin), timeDev: minsToHrs(timeDevMin)
});
}
if (timeRtqaMin > 0) {
qaData.timesRtqa.push(timeRtqaMin);
if (qaMonth) {
qaData.totalTimeRtqa += timeRtqaMin;
qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin;
qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1;
}
}
if (timeCrMin > 0) {
crData.tickets++;
crData.totalTimeCr += timeCrMin;
crData.times.push(timeCrMin);
if (qaMonth) {
crData.monthlyTotalCr[qaMonth] = (crData.monthlyTotalCr[qaMonth] || 0) + timeCrMin;
crData.monthlyCountCr[qaMonth] = (crData.monthlyCountCr[qaMonth] || 0) + 1;
}
crData.issues.push({ key: issue.key, summary: f.summary, timeCr: minsToHrs(timeCrMin), status: statusKey });
}
const crEntryDate = f[config.fields.crEntry];
if (crEntryDate && !isDone && !f[config.fields.qaEntryRtqa]) {
const daysInCr = (now - new Date(crEntryDate)) / (1000 * 60 * 60 * 24);
if (daysInCr > 2) {
crData.stalledIssues.push({ key: issue.key, summary: f.summary, days: Math.round(daysInCr * 10)/10 });
}
}
// Las alertas de salud se generan y empujan arriba (junto con issueWarnings)
} catch (e) {
console.warn(`Error procesando issue ${issue.key}: ${e.message}`);
}
}
const monthlyAvgQa = {};
for (const m of Object.keys(qaData.monthlyTotalQa)) monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]);
const monthlyAvgRtqa = {};
for (const m of Object.keys(qaData.monthlyTotalRtqa)) monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]);
const EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca'];
for (const dev of Object.keys(assigneeData)) {
if (!EXCLUDED_FROM_ALERTS.includes(dev) && assigneeData[dev].pending_count === 0) {
warnings.push({ type: 'free_dev', summary: `${dev} no tiene tickets pendientes ni en curso`, devs: [dev] });
}
}
const monthlyAvgCr = {};
for (const m of Object.keys(crData.monthlyTotalCr)) monthlyAvgCr[m] = minsToHrs(crData.monthlyTotalCr[m] / crData.monthlyCountCr[m]);
const crTimesSorted = crData.times.sort((a, b) => a - b);
function getPercentile(sortedArr, p) {
if (sortedArr.length === 0) return 0;
if (p <= 0) return sortedArr[0];
if (p >= 1) return sortedArr[sortedArr.length - 1];
const index = (sortedArr.length - 1) * p;
const lower = Math.floor(index);
const upper = lower + 1;
const weight = index % 1;
if (upper >= sortedArr.length) return sortedArr[lower];
return sortedArr[lower] * (1 - weight) + sortedArr[upper] * weight;
}
const p50Cr = minsToHrs(getPercentile(crTimesSorted, 0.5));
const p90Cr = minsToHrs(getPercentile(crTimesSorted, 0.9));
const p99Cr = minsToHrs(getPercentile(crTimesSorted, 0.99));
const qaTimesSorted = qaData.timesQa.sort((a, b) => a - b);
const p50Qa = minsToHrs(getPercentile(qaTimesSorted, 0.5));
const p90Qa = minsToHrs(getPercentile(qaTimesSorted, 0.9));
const p99Qa = minsToHrs(getPercentile(qaTimesSorted, 0.99));
const rtqaTimesSorted = qaData.timesRtqa.sort((a, b) => a - b);
const p50Rtqa = minsToHrs(getPercentile(rtqaTimesSorted, 0.5));
const p90Rtqa = minsToHrs(getPercentile(rtqaTimesSorted, 0.9));
const p99Rtqa = minsToHrs(getPercentile(rtqaTimesSorted, 0.99));
return {
lastUpdated: new Date().toISOString(),
assigneeData, monthlyDone, issuesList, warnings, currentStates,
deliveryData: {
avgLeadTime: deliveryData.doneTicketsLead > 0 ? Math.round(deliveryData.totalLeadTimeDays / deliveryData.doneTicketsLead) : 0,
avgCycleTime: deliveryData.doneTicketsCycle > 0 ? Math.round(deliveryData.totalCycleTimeDays / deliveryData.doneTicketsCycle) : 0,
spOriginal: deliveryData.doneSpOriginal,
spFinal: deliveryData.doneSpFinal,
spDeviationPct: deliveryData.doneSpOriginal > 0 ? ((deliveryData.doneSpFinal - deliveryData.doneSpOriginal) / deliveryData.doneSpOriginal * 100).toFixed(1) : 0
},
crData: {
...crData, monthlyAvgCr, p50Cr, p90Cr, p99Cr,
avgTimeCr: crData.tickets > 0 ? minsToHrs(crData.totalTimeCr / crData.tickets) : 0
},
qaData: {
...qaData, monthlyAvgQa, monthlyAvgRtqa,
p50Qa, p90Qa, p99Qa, p50Rtqa, p90Rtqa, p99Rtqa,
avgTimeQa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0,
avgTimeRtqa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0
}
};
}
// ─── Handler Netlify ───
exports.handler = async (event) => {
try {
const queryParams = event.queryStringParameters || {};
const forceRefresh = queryParams.refresh === '1';
const period = queryParams.period || '90d';
if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) {
return {
statusCode: 200,
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
body: JSON.stringify({ ..._cache[period], fromCache: true })
};
}
if (!config.jira.email || !config.jira.token) throw new Error('Faltan credenciales JIRA en variables de entorno');
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period);
const data = processIssues(jiraData.issues, period);
data.isTruncated = jiraData.isTruncated;
data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length;
data.period = period;
_cache[period] = data;
_cacheTime[period] = Date.now();
return {
statusCode: 200,
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, fromCache: false })
};
} catch (err) {
return {
statusCode: 500,
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
body: JSON.stringify({ error: err.message })
};
}
};
+3
View File
@@ -0,0 +1,3 @@
flask
requests
python-dotenv
+494
View File
@@ -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)
+8
View File
@@ -5,6 +5,14 @@
"src": "/api/(.*)",
"dest": "/api/$1"
},
{
"src": "/css/(.*)",
"dest": "/css/$1"
},
{
"src": "/js/(.*)",
"dest": "/js/$1"
},
{
"src": "/(.*)",
"dest": "/index.html"