Compare commits
11 Commits
7042054333
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e87d25826 | |||
| 695401f237 | |||
| 76edc176f9 | |||
| 72050df789 | |||
| d7f7339fde | |||
| 8dfa3bf137 | |||
| c8806a6701 | |||
| a2d49b441e | |||
| 7410b22623 | |||
| 5467427a08 | |||
| b4a0dfae24 |
@@ -1,3 +1,6 @@
|
|||||||
.vercel
|
.vercel
|
||||||
.env
|
.env
|
||||||
ex.env
|
ex.env
|
||||||
|
|
||||||
|
# Local Netlify folder
|
||||||
|
.netlify
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 📊 Dashboard Infra — Tickets
|
||||||
|
|
||||||
|
Dashboard en tiempo real para visualizar la carga de trabajo y métricas del equipo de Infraestructura, usando datos extraídos directamente de la API de Jira.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Stack Tecnológico
|
||||||
|
|
||||||
|
- **Frontend:** HTML5 + Vanilla JavaScript + [Chart.js (v4.4.1)](https://www.chartjs.org/)
|
||||||
|
- **Backend:** Python + Flask
|
||||||
|
- **API:** Integración directa con Jira REST API v3
|
||||||
|
- **Autenticación:** Basic Auth mediante Email y API Token de Jira
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Instalación y Configuración Local
|
||||||
|
|
||||||
|
### 1. Clonar el repositorio
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.beldo.duckdns.org/jpbeldomenico/dashboard-infra.git
|
||||||
|
cd dashboard-infra
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Instalar dependencias Python
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configurar Variables de Entorno
|
||||||
|
|
||||||
|
Copiá el archivo de ejemplo y completá con tus credenciales:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ex.env .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Editá el `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
JIRA_HOST=invera.atlassian.net
|
||||||
|
JIRA_EMAIL=tu-email@dominio.com
|
||||||
|
JIRA_TOKEN=tu-jira-api-token
|
||||||
|
JIRA_PROJECT=IO
|
||||||
|
USE_TICKET_COUNT=true
|
||||||
|
```
|
||||||
|
|
||||||
|
> Podés generar un API Token en: https://id.atlassian.com/manage-profile/security/api-tokens
|
||||||
|
|
||||||
|
### 4. Levantar el servidor
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Abrí el navegador en: `http://localhost:8888`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Estructura del Proyecto
|
||||||
|
|
||||||
|
```text
|
||||||
|
dashboard-infra/
|
||||||
|
├── index.html # Frontend: layout y gráficos
|
||||||
|
├── js/
|
||||||
|
│ └── app.js # Lógica del dashboard (Chart.js, KPIs, filtros)
|
||||||
|
├── css/
|
||||||
|
│ └── styles.css # Estilos
|
||||||
|
├── server.py # Backend Flask: fetch de Jira y endpoint /api/data
|
||||||
|
├── requirements.txt # Dependencias Python
|
||||||
|
├── ex.env # Ejemplo de variables de entorno
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Métricas disponibles
|
||||||
|
|
||||||
|
- **Carga de trabajo actual:** tickets En Progreso y Por Hacer por desarrollador
|
||||||
|
- **Alertas de salud:** tickets estancados, vencidos, sin asignar
|
||||||
|
- **Resultados del período:** tickets completados, promedio semanal, solicitudes por semana
|
||||||
|
- Filtro por desarrollador y por rango de tiempo (hoy, 7d, 30d, 90d, 1 año, histórico)
|
||||||
@@ -73,6 +73,15 @@ export const config = {
|
|||||||
spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
spEstimate: getEnv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
||||||
spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
spFinal: getEnv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
||||||
developer: getEnv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
|
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 ──────────────────────────────────────────────────────
|
// ── JQL Query ──────────────────────────────────────────────────────
|
||||||
|
|||||||
-249
@@ -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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""
|
||||||
|
debug_fields.py — Lista todas las personas asignadas en el proyecto
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
email = os.getenv('JIRA_EMAIL')
|
||||||
|
token = os.getenv('JIRA_TOKEN')
|
||||||
|
host = os.getenv('JIRA_HOST', 'invera.atlassian.net')
|
||||||
|
project = os.getenv('JIRA_PROJECT', 'Infra')
|
||||||
|
|
||||||
|
credentials = base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||||
|
headers = { 'Authorization': f'Basic {credentials}', 'Accept': 'application/json' }
|
||||||
|
|
||||||
|
print(f"🔍 Conectando a {host} — Proyecto: {project}\n")
|
||||||
|
|
||||||
|
jql = f'project = {project} AND issuetype not in (Epic, "Sub-task", Subtask) AND created >= "2026-01-01" ORDER BY updated DESC'
|
||||||
|
url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=500&fields=summary,status,assignee"
|
||||||
|
res = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
if not res.ok:
|
||||||
|
print(f"❌ Error {res.status_code}: {res.text[:300]}")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
issues = res.json().get('issues', [])
|
||||||
|
print(f" → {len(issues)} tickets obtenidos\n")
|
||||||
|
|
||||||
|
assignees = {}
|
||||||
|
for issue in issues:
|
||||||
|
f = issue['fields']
|
||||||
|
name = (f.get('assignee') or {}).get('displayName', 'Sin asignar')
|
||||||
|
status_cat = (f.get('status') or {}).get('statusCategory', {}).get('key', '')
|
||||||
|
if name not in assignees:
|
||||||
|
assignees[name] = {'done': 0, 'pending': 0}
|
||||||
|
if status_cat == 'done':
|
||||||
|
assignees[name]['done'] += 1
|
||||||
|
else:
|
||||||
|
assignees[name]['pending'] += 1
|
||||||
|
|
||||||
|
print(f"{'─'*60}")
|
||||||
|
print(f"👥 TODAS LAS PERSONAS — {project} (desde 2026-01-01):")
|
||||||
|
print(f"{'─'*60}")
|
||||||
|
for name, counts in sorted(assignees.items()):
|
||||||
|
print(f" {name:35s} → {counts['done']:2} finalizados | {counts['pending']:2} pendientes")
|
||||||
|
print(f"{'─'*60}")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "IO-167",
|
||||||
|
"summary": "Pipeline - Configurar Bitbucket para Producción",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-166",
|
||||||
|
"summary": "ArgoCD - Actualizar ApplicationSet",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-165",
|
||||||
|
"summary": "Kubernetes & GitOps - Configurar prd-values.yaml",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-161",
|
||||||
|
"summary": "Documentar infraestructura y proceso de deployment",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-160",
|
||||||
|
"summary": "Ejecutar migraciones y deployment inicial",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-159",
|
||||||
|
"summary": "Configurar Ingress y DNS para dominio",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-158",
|
||||||
|
"summary": "Crear namespace y configurar RBAC en EKS",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-157",
|
||||||
|
"summary": "Configurar usuario readonly en RDS InvWallet",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-156",
|
||||||
|
"summary": " Crear ArgoCD Application para Dashboard",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "IO-155",
|
||||||
|
"summary": "Configurar Bitbucket Pipeline en repositorio de la app",
|
||||||
|
"assignee": "Josefina Vera Ocampo",
|
||||||
|
"reporter": "Nelson Argañaraz"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
@@ -3,454 +3,157 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Dashboard LABS — Story Points</title>
|
<title>Dashboard Infra — Tickets</title>
|
||||||
|
<link rel="stylesheet" href="./css/styles.css">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||||
<style>
|
<script type="module" src="./js/app.js" defer></script>
|
||||||
* { 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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Loading overlay -->
|
<!-- Loading overlay -->
|
||||||
<div class="loading-overlay" id="loadingOverlay">
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Consultando Jira en tiempo real…</p>
|
<p>Consultando Jira en tiempo real…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error banner -->
|
<!-- Error banner -->
|
||||||
<div class="error-banner" id="errorBanner"></div>
|
<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>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h1>📊 Dashboard LABS — Story Points</h1>
|
<h1>📊 Dashboard Infra</h1>
|
||||||
<div style="font-size:13px;opacity:.7;margin-top:4px;">
|
<div style="font-size:13px;opacity:.7;margin-top:4px;">
|
||||||
Proyecto: <strong>LABS</strong> · invera.atlassian.net · Campo: Desarrollador
|
Proyecto: <strong>Infra</strong> · invera.atlassian.net
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-meta">
|
<div class="header-meta">
|
||||||
<span id="lastUpdatedLabel">—</span>
|
<span id="lastUpdatedLabel">—</span>
|
||||||
<span class="cache-badge" id="cacheBadge"></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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<!-- ── SECCIÓN 1: Estado Actual ── -->
|
|
||||||
|
<!-- ── SECCIÓN 1: Carga de Trabajo (Pendientes) ── -->
|
||||||
<div class="section-header current">
|
<div class="section-header current">
|
||||||
<div class="dot"></div>
|
<div class="dot" style="background:#0065ff;"></div>
|
||||||
<h2>Estado Actual del Equipo</h2>
|
<h2>Carga de Trabajo Actual</h2>
|
||||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Tickets activos en el tablero ahora mismo</span>
|
<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>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<div class="kpi-row">
|
<div class="kpi-row">
|
||||||
<div class="kpi progress"><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 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"> <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 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 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" 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 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>
|
</div>
|
||||||
|
|
||||||
<div class="charts-grid">
|
<div class="charts-grid">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3>🔵 SP en Progreso — por Desarrollador</h3>
|
<h3>🔵 Tkt pendientes</h3>
|
||||||
<div class="sub">Estado actual · En curso / Code Review / QA</div>
|
<div class="sub">Story Points con trabajo pendiente</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>
|
|
||||||
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
|
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
|
||||||
</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> › +14 días en curso.<br><br>
|
||||||
|
<span class="badge" style="background:#ff5630;color:white;font-size:10px;padding:2px 4px;">VENCIDO</span> › Fecha de entrega caducada.<br><br>
|
||||||
|
<span class="badge" style="background:#6554c0;color:white;font-size:10px;padding:2px 4px;">SIN SP</span> › Completado sin estimación.<br><br>
|
||||||
|
<span class="badge" style="background:#ff8b00;color:white;font-size:10px;padding:2px 4px;">HUÉRFANO</span> › Ticket con SP sin asignar.<br><br>
|
||||||
|
<span class="badge" style="background:#36b37e;color:white;font-size:10px;padding:2px 4px;">DEV LIBRE</span> › 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>
|
||||||
|
|
||||||
<div class="table-card">
|
<div class="table-card">
|
||||||
<h3>Detalle por Desarrollador — Estado Actual</h3>
|
<h3>Detalle de Carga de Trabajo</h3>
|
||||||
<table>
|
<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>
|
<tbody id="tableCurrentState"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- ── SECCIÓN 2: Completados por período ── -->
|
|
||||||
|
<!-- ── SECCIÓN 2: Rendimiento & Resultados ── -->
|
||||||
<div class="section-header history">
|
<div class="section-header history">
|
||||||
<div class="dot"></div>
|
<div class="dot" style="background:#36b37e;"></div>
|
||||||
<h2>Completados por Período</h2>
|
<h2>Resultados del Período</h2>
|
||||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Filtrar por mes de resolución</span>
|
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Story Points finalizados y velocidad</span>
|
||||||
<label>Ver mes:</label>
|
<span class="badge" id="filterBadge" style="display:none;"></span>
|
||||||
<select id="monthFilter" onchange="updateHistory()"></select>
|
|
||||||
<span class="badge" id="filterBadge">Todos los meses</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="section-block">
|
<div class="section-block">
|
||||||
<div class="kpi-row-3">
|
<div class="kpi-row-3">
|
||||||
<div class="kpi done"> <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 done" title="Total de tickets completados en el período de tiempo seleccionado"><div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">Tickets Completados</div><div class="kpi-sub">en el período</div></div>
|
||||||
<div class="kpi members"><div class="kpi-value" id="kpi-devs-period">—</div><div class="kpi-label">Desarrolladores</div><div class="kpi-sub">que completaron SP</div></div>
|
<div class="kpi progress" title="Promedio de tickets resueltos por semana en el período seleccionado"><div class="kpi-value" id="kpi-avg-period">—</div><div class="kpi-label">Promedio tkts / semana</div><div class="kpi-sub" id="kpi-avg-period-sub">tickets por semana</div></div>
|
||||||
<div class="kpi progress"><div class="kpi-value" id="kpi-avg-period">—</div><div class="kpi-label">SP promedio / dev</div><div class="kpi-sub">en el período</div></div>
|
<div class="kpi" style="border-color:#6554c0;" title="Promedio de tickets nuevos solicitados por semana en el período seleccionado"><div class="kpi-value" id="kpi-created-week">—</div><div class="kpi-label">Tkts solicitados / semana</div><div class="kpi-sub" id="kpi-created-week-sub">tickets por semana</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="charts-grid">
|
<div class="charts-grid">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3>✅ SP Completados — por Desarrollador</h3>
|
<h3>✅ Tickets Completados</h3>
|
||||||
<div class="sub" id="subtitle-history-dev">Período seleccionado</div>
|
<div class="sub">Período seleccionado</div>
|
||||||
<div style="position:relative;height:260px"><canvas id="chartDoneDev"></canvas></div>
|
<div style="position:relative;height:260px"><canvas id="chartDoneDev"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3>📈 Velocidad del Equipo</h3>
|
<h3>📥 Solicitudes de Tickets</h3>
|
||||||
<div class="sub">SP completados por mes · cada color = un dev</div>
|
<div class="sub">Tickets creados por persona en el período</div>
|
||||||
<div style="position:relative;height:260px"><canvas id="chartVelocity"></canvas></div>
|
<div style="position:relative;height:260px"><canvas id="chartVelocity"></canvas></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<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>
|
<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>
|
<tbody id="tableHistory"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ── SECCIÓN: Revisión de Código (Code Review) ── -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
Dashboard en tiempo real · Proyecto LABS · invera.atlassian.net ·
|
Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
|
||||||
Campo: <strong>Desarrollador</strong> (customfield_10622) + SP Finales (customfield_11019) ·
|
|
||||||
Cache: 5 min · <span id="totalIssuesLabel"></span>
|
|
||||||
</footer>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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, '"')}">${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 } } } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
@@ -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 })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask
|
||||||
|
requests
|
||||||
|
python-dotenv
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
"""
|
||||||
|
server.py — Servidor Flask para Dashboard LABS/Infra — Story Points
|
||||||
|
Reemplaza las Netlify Functions. Sirve el frontend y el endpoint /api/data
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify, send_from_directory
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Cargar variables de entorno desde .env
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder='.')
|
||||||
|
|
||||||
|
# ─── Configuración ─────────────────────────────────────────────────────────
|
||||||
|
USE_TICKET_COUNT = os.getenv('USE_TICKET_COUNT', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'jira': {
|
||||||
|
'host': os.getenv('JIRA_HOST', 'invera.atlassian.net'),
|
||||||
|
'project': os.getenv('JIRA_PROJECT', 'IO'),
|
||||||
|
'email': os.getenv('JIRA_EMAIL'),
|
||||||
|
'token': os.getenv('JIRA_TOKEN'),
|
||||||
|
},
|
||||||
|
'fields': {
|
||||||
|
'spEstimate': os.getenv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'),
|
||||||
|
'spFinal': os.getenv('JIRA_FIELD_SP_FINAL', 'customfield_11019'),
|
||||||
|
'developer': os.getenv('JIRA_FIELD_DEVELOPER', 'customfield_10622'),
|
||||||
|
'qaAssignee': 'customfield_10632',
|
||||||
|
'qaEntryRtqa': 'customfield_10821',
|
||||||
|
'qaTotalTimeRtqa': 'customfield_10822',
|
||||||
|
'qaLastDurRtqa': 'customfield_10823',
|
||||||
|
'qaEntryQa': 'customfield_10824',
|
||||||
|
'qaTotalTimeQa': 'customfield_10825',
|
||||||
|
'qaLastDurQa': 'customfield_10826',
|
||||||
|
'timesReturned': 'customfield_11217',
|
||||||
|
'spFinalReal': 'customfield_11216',
|
||||||
|
'crEntry': 'customfield_10818',
|
||||||
|
'crTotalTime': 'customfield_10819',
|
||||||
|
'enCursoEntry': 'customfield_10815',
|
||||||
|
'enCursoTotalTime':'customfield_10816',
|
||||||
|
},
|
||||||
|
'cacheMsec': int(os.getenv('CACHE_MS', 3600000)),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Cache en memoria ──────────────────────────────────────────────────────
|
||||||
|
_cache = {}
|
||||||
|
_cache_time = {}
|
||||||
|
|
||||||
|
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
def mins_to_hrs(mins):
|
||||||
|
if not mins:
|
||||||
|
return 0
|
||||||
|
return round((mins / 60) * 10) / 10
|
||||||
|
|
||||||
|
def get_percentile(sorted_arr, p):
|
||||||
|
if not sorted_arr:
|
||||||
|
return 0
|
||||||
|
if p <= 0:
|
||||||
|
return sorted_arr[0]
|
||||||
|
if p >= 1:
|
||||||
|
return sorted_arr[-1]
|
||||||
|
index = (len(sorted_arr) - 1) * p
|
||||||
|
lower = math.floor(index)
|
||||||
|
upper = lower + 1
|
||||||
|
weight = index % 1
|
||||||
|
if upper >= len(sorted_arr):
|
||||||
|
return sorted_arr[lower]
|
||||||
|
return sorted_arr[lower] * (1 - weight) + sorted_arr[upper] * weight
|
||||||
|
|
||||||
|
def build_fields_list():
|
||||||
|
f = config['fields']
|
||||||
|
return ','.join([
|
||||||
|
'summary', 'status', 'assignee', 'reporter', 'resolutiondate', 'created', 'updated', 'duedate',
|
||||||
|
f['spEstimate'], f['spFinal'], f['developer'],
|
||||||
|
f['qaAssignee'], f['qaEntryRtqa'], f['qaTotalTimeRtqa'], f['qaLastDurRtqa'],
|
||||||
|
f['qaEntryQa'], f['qaTotalTimeQa'], f['qaLastDurQa'],
|
||||||
|
f['timesReturned'], f['spFinalReal'], f['crEntry'], f['crTotalTime'],
|
||||||
|
f['enCursoEntry'], f['enCursoTotalTime'],
|
||||||
|
])
|
||||||
|
|
||||||
|
# ─── Fetch de Jira ─────────────────────────────────────────────────────────
|
||||||
|
def fetch_jira_issues(period):
|
||||||
|
email = config['jira']['email']
|
||||||
|
token = config['jira']['token']
|
||||||
|
host = config['jira']['host']
|
||||||
|
project = config['jira']['project']
|
||||||
|
|
||||||
|
credentials = base64.b64encode(f"{email}:{token}".encode()).decode()
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Basic {credentials}',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
max_results_wanted = 1500
|
||||||
|
all_issues = []
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
jql = f'project = {project} ORDER BY updated DESC'
|
||||||
|
|
||||||
|
print(f" → Email: {email}")
|
||||||
|
print(f" → Token: {token[:20] if token else 'NO TOKEN'}...")
|
||||||
|
print(f" → JQL: {jql}")
|
||||||
|
|
||||||
|
print(f"📡 Consultando JIRA: {project} (periodo: {period})")
|
||||||
|
|
||||||
|
fields_param = build_fields_list()
|
||||||
|
next_page_token = None
|
||||||
|
|
||||||
|
while len(all_issues) < max_results_wanted:
|
||||||
|
url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=100&fields=summary,status,assignee,reporter,resolutiondate,created,updated,duedate"
|
||||||
|
if next_page_token:
|
||||||
|
url += f"&nextPageToken={requests.utils.quote(next_page_token)}"
|
||||||
|
print(f" → URL: {url[:200]}")
|
||||||
|
res = requests.get(url, headers=headers)
|
||||||
|
|
||||||
|
print(f" → HTTP {res.status_code} — respuesta: {res.text[:200]}")
|
||||||
|
|
||||||
|
if not res.ok:
|
||||||
|
raise Exception(f"JIRA API {res.status_code}: {res.text[:200]}")
|
||||||
|
|
||||||
|
data = res.json()
|
||||||
|
items = data.get('issues', [])
|
||||||
|
all_issues.extend(items)
|
||||||
|
next_page_token = data.get('nextPageToken')
|
||||||
|
|
||||||
|
print(f" → {len(items)} tickets en esta página | Total acumulado: {len(all_issues)}")
|
||||||
|
|
||||||
|
if not items or not next_page_token:
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"✅ Total tickets obtenidos de Jira: {len(all_issues)}")
|
||||||
|
return {'issues': all_issues, 'isTruncated': bool(next_page_token)}
|
||||||
|
|
||||||
|
# ─── Procesamiento de Issues ───────────────────────────────────────────────
|
||||||
|
def process_issues(issues, period='90d'):
|
||||||
|
f = config['fields']
|
||||||
|
assignee_data = {}
|
||||||
|
monthly_done = {}
|
||||||
|
issues_list = []
|
||||||
|
warnings = []
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
cutoff_date = None
|
||||||
|
if period != 'all':
|
||||||
|
period_days = 1 if period == '1d' else int(period.replace('d', ''))
|
||||||
|
if period == '1d':
|
||||||
|
cutoff_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
else:
|
||||||
|
cutoff_date = datetime.fromtimestamp(time.time() - period_days * 86400, tz=timezone.utc)
|
||||||
|
|
||||||
|
qa_data = {
|
||||||
|
'tickets': 0, 'ticketsReturned': 0, 'totalTimeQa': 0, 'totalTimeRtqa': 0,
|
||||||
|
'monthlyTotalQa': {}, 'monthlyCountQa': {},
|
||||||
|
'monthlyTotalRtqa': {}, 'monthlyCountRtqa': {},
|
||||||
|
'issues': [], 'timesQa': [], 'timesRtqa': []
|
||||||
|
}
|
||||||
|
|
||||||
|
tickets_created = 0 # Contador de tickets creados en el período
|
||||||
|
reporter_data = {} # Tickets solicitados por persona
|
||||||
|
|
||||||
|
delivery_data = {
|
||||||
|
'totalLeadTimeDays': 0, 'totalCycleTimeDays': 0,
|
||||||
|
'doneTicketsLead': 0, 'doneTicketsCycle': 0,
|
||||||
|
'doneSpOriginal': 0, 'doneSpFinal': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
cr_data = {
|
||||||
|
'tickets': 0, 'totalTimeCr': 0, 'issues': [],
|
||||||
|
'monthlyTotalCr': {}, 'monthlyCountCr': {},
|
||||||
|
'stalledIssues': [], 'times': []
|
||||||
|
}
|
||||||
|
|
||||||
|
current_states = {
|
||||||
|
'codeReview': {'count': 0, 'sp': 0},
|
||||||
|
'readyToQa': {'count': 0, 'sp': 0},
|
||||||
|
'inQa': {'count': 0, 'sp': 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno', 'Josefina Vera Ocampo', 'Isidro Emir Pelli']
|
||||||
|
|
||||||
|
# Normalizar nombres de usuario a nombre real
|
||||||
|
NAME_MAP = {
|
||||||
|
'facundo.devoto': 'Devoto Facundo',
|
||||||
|
}
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
try:
|
||||||
|
fields = issue['fields']
|
||||||
|
|
||||||
|
# Devs
|
||||||
|
devs = []
|
||||||
|
if fields.get(f['developer']):
|
||||||
|
devs = [d['displayName'] for d in fields[f['developer']]]
|
||||||
|
elif fields.get('assignee'):
|
||||||
|
devs = [fields['assignee']['displayName']]
|
||||||
|
else:
|
||||||
|
devs = ['Sin asignar']
|
||||||
|
|
||||||
|
devs = [NAME_MAP.get(d, d) for d in devs]
|
||||||
|
devs = [d for d in devs if d not in INACTIVE_DEVS]
|
||||||
|
if not devs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if USE_TICKET_COUNT:
|
||||||
|
sp = 1
|
||||||
|
else:
|
||||||
|
sp = (fields.get(f['spFinal']) if fields.get(f['spFinal']) is not None
|
||||||
|
else fields.get(f['spEstimate'])) or 0
|
||||||
|
|
||||||
|
status_key = (fields.get('status') or {}).get('statusCategory', {}).get('key', 'new')
|
||||||
|
status_name = ((fields.get('status') or {}).get('name') or '').lower()
|
||||||
|
is_done = status_key == 'done'
|
||||||
|
is_in_progress = status_key == 'indeterminate'
|
||||||
|
|
||||||
|
# Contar tickets creados dentro del período + reporter
|
||||||
|
if fields.get('created'):
|
||||||
|
created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00'))
|
||||||
|
if cutoff_date is None or created_dt >= cutoff_date:
|
||||||
|
tickets_created += 1
|
||||||
|
reporter = (fields.get('reporter') or {}).get('displayName', 'Sin asignar')
|
||||||
|
reporter_data[reporter] = reporter_data.get(reporter, 0) + 1
|
||||||
|
|
||||||
|
# Filtrar done tickets fuera del período
|
||||||
|
if is_done and fields.get('resolutiondate') and cutoff_date:
|
||||||
|
res_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00'))
|
||||||
|
if res_date < cutoff_date:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Estado actual (colas)
|
||||||
|
if not is_done:
|
||||||
|
if 'code review' in status_name or status_name == 'cr':
|
||||||
|
current_states['codeReview']['count'] += 1
|
||||||
|
current_states['codeReview']['sp'] += sp
|
||||||
|
elif 'ready to qa' in status_name or status_name == 'listo para qa':
|
||||||
|
current_states['readyToQa']['count'] += 1
|
||||||
|
current_states['readyToQa']['sp'] += sp
|
||||||
|
elif status_name == 'qa' or 'in qa' in status_name or status_name == 'en qa':
|
||||||
|
current_states['inQa']['count'] += 1
|
||||||
|
current_states['inQa']['sp'] += sp
|
||||||
|
|
||||||
|
res_month = fields['resolutiondate'][:7] if fields.get('resolutiondate') else None
|
||||||
|
cre_month = fields['created'][:7] if fields.get('created') else None
|
||||||
|
|
||||||
|
for dev in devs:
|
||||||
|
if dev not in assignee_data:
|
||||||
|
assignee_data[dev] = {'done': 0, 'in_progress': 0, 'todo': 0, 'pending_count': 0}
|
||||||
|
if is_done:
|
||||||
|
assignee_data[dev]['done'] += sp
|
||||||
|
elif is_in_progress:
|
||||||
|
assignee_data[dev]['in_progress'] += sp
|
||||||
|
assignee_data[dev]['pending_count'] += 1
|
||||||
|
else:
|
||||||
|
assignee_data[dev]['todo'] += sp
|
||||||
|
assignee_data[dev]['pending_count'] += 1
|
||||||
|
|
||||||
|
if is_done:
|
||||||
|
month = res_month or cre_month
|
||||||
|
if month:
|
||||||
|
if month not in monthly_done:
|
||||||
|
monthly_done[month] = {}
|
||||||
|
for dev in devs:
|
||||||
|
monthly_done[month][dev] = monthly_done[month].get(dev, 0) + sp
|
||||||
|
|
||||||
|
# Delivery metrics
|
||||||
|
created_date = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None
|
||||||
|
resolved_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00')) if fields.get('resolutiondate') else None
|
||||||
|
en_curso_date = datetime.fromisoformat(fields[f['enCursoEntry']].replace('Z', '+00:00')) if fields.get(f['enCursoEntry']) else None
|
||||||
|
sp_original = fields.get(f['spEstimate']) or 0
|
||||||
|
sp_real = fields.get(f['spFinalReal']) if fields.get(f['spFinalReal']) is not None else sp_original
|
||||||
|
|
||||||
|
if resolved_date:
|
||||||
|
if created_date:
|
||||||
|
delivery_data['totalLeadTimeDays'] += (resolved_date - created_date).total_seconds() / 86400
|
||||||
|
delivery_data['doneTicketsLead'] += 1
|
||||||
|
if en_curso_date:
|
||||||
|
delivery_data['totalCycleTimeDays'] += (resolved_date - en_curso_date).total_seconds() / 86400
|
||||||
|
delivery_data['doneTicketsCycle'] += 1
|
||||||
|
delivery_data['doneSpOriginal'] += sp_original
|
||||||
|
delivery_data['doneSpFinal'] += sp_real
|
||||||
|
|
||||||
|
# Warnings
|
||||||
|
issue_warnings = []
|
||||||
|
|
||||||
|
if is_in_progress and fields.get('updated'):
|
||||||
|
updated = datetime.fromisoformat(fields['updated'].replace('Z', '+00:00'))
|
||||||
|
days = (now - updated).total_seconds() / 86400
|
||||||
|
if days > 14:
|
||||||
|
w = {'type': 'stalled', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': round(days)}
|
||||||
|
warnings.append(w)
|
||||||
|
issue_warnings.append(w)
|
||||||
|
|
||||||
|
if not is_done and fields.get('duedate'):
|
||||||
|
due_date = datetime.fromisoformat(fields['duedate'] + 'T00:00:00+00:00')
|
||||||
|
if due_date < now:
|
||||||
|
days_overdue = (now - due_date).days
|
||||||
|
w = {'type': 'expired', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': days_overdue}
|
||||||
|
warnings.append(w)
|
||||||
|
issue_warnings.append(w)
|
||||||
|
|
||||||
|
if is_done and sp == 0:
|
||||||
|
w = {'type': 'done_no_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': 0}
|
||||||
|
warnings.append(w)
|
||||||
|
issue_warnings.append(w)
|
||||||
|
|
||||||
|
if not is_done and 'Sin asignar' in devs and sp > 0:
|
||||||
|
w = {'type': 'unassigned_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp}
|
||||||
|
warnings.append(w)
|
||||||
|
issue_warnings.append(w)
|
||||||
|
|
||||||
|
issues_list.append({
|
||||||
|
'id': issue['id'], 'key': issue['key'],
|
||||||
|
'summary': fields['summary'], 'status': status_key,
|
||||||
|
'sp': sp, 'devs': devs, 'creMonth': cre_month,
|
||||||
|
'warnings': issue_warnings
|
||||||
|
})
|
||||||
|
|
||||||
|
# QA & CR
|
||||||
|
time_qa_min = fields.get(f['qaTotalTimeQa']) or 0
|
||||||
|
time_rtqa_min = fields.get(f['qaTotalTimeRtqa']) or 0
|
||||||
|
time_cr_min = fields.get(f['crTotalTime']) or 0
|
||||||
|
time_dev_min = fields.get(f['enCursoTotalTime']) or 0
|
||||||
|
times_ret = fields.get(f['timesReturned']) or 0
|
||||||
|
qa_month = res_month or cre_month
|
||||||
|
|
||||||
|
# Fix datos sucios anteriores al 7-Apr-2026
|
||||||
|
automation_fix_date = datetime(2026, 4, 7, tzinfo=timezone.utc)
|
||||||
|
issue_created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None
|
||||||
|
if issue_created_dt and issue_created_dt < automation_fix_date:
|
||||||
|
times_ret = 1
|
||||||
|
|
||||||
|
if time_qa_min > 0:
|
||||||
|
qa_data['tickets'] += 1
|
||||||
|
qa_data['totalTimeQa'] += time_qa_min
|
||||||
|
qa_data['timesQa'].append(time_qa_min)
|
||||||
|
if times_ret > 1:
|
||||||
|
qa_data['ticketsReturned'] += 1
|
||||||
|
if qa_month:
|
||||||
|
qa_data['monthlyTotalQa'][qa_month] = qa_data['monthlyTotalQa'].get(qa_month, 0) + time_qa_min
|
||||||
|
qa_data['monthlyCountQa'][qa_month] = qa_data['monthlyCountQa'].get(qa_month, 0) + 1
|
||||||
|
qa_data['issues'].append({
|
||||||
|
'key': issue['key'], 'summary': fields['summary'],
|
||||||
|
'timeQa': mins_to_hrs(time_qa_min), 'timeRtqa': mins_to_hrs(time_rtqa_min),
|
||||||
|
'timesReturned': times_ret, 'status': status_key, 'resMonth': qa_month,
|
||||||
|
'timeCr': mins_to_hrs(time_cr_min), 'timeDev': mins_to_hrs(time_dev_min)
|
||||||
|
})
|
||||||
|
|
||||||
|
if time_rtqa_min > 0:
|
||||||
|
qa_data['timesRtqa'].append(time_rtqa_min)
|
||||||
|
if qa_month:
|
||||||
|
qa_data['totalTimeRtqa'] += time_rtqa_min
|
||||||
|
qa_data['monthlyTotalRtqa'][qa_month] = qa_data['monthlyTotalRtqa'].get(qa_month, 0) + time_rtqa_min
|
||||||
|
qa_data['monthlyCountRtqa'][qa_month] = qa_data['monthlyCountRtqa'].get(qa_month, 0) + 1
|
||||||
|
|
||||||
|
if time_cr_min > 0:
|
||||||
|
cr_data['tickets'] += 1
|
||||||
|
cr_data['totalTimeCr'] += time_cr_min
|
||||||
|
cr_data['times'].append(time_cr_min)
|
||||||
|
if qa_month:
|
||||||
|
cr_data['monthlyTotalCr'][qa_month] = cr_data['monthlyTotalCr'].get(qa_month, 0) + time_cr_min
|
||||||
|
cr_data['monthlyCountCr'][qa_month] = cr_data['monthlyCountCr'].get(qa_month, 0) + 1
|
||||||
|
cr_data['issues'].append({'key': issue['key'], 'summary': fields['summary'], 'timeCr': mins_to_hrs(time_cr_min), 'status': status_key})
|
||||||
|
|
||||||
|
cr_entry_date = fields.get(f['crEntry'])
|
||||||
|
if cr_entry_date and not is_done and not fields.get(f['qaEntryRtqa']):
|
||||||
|
cr_entry_dt = datetime.fromisoformat(cr_entry_date.replace('Z', '+00:00'))
|
||||||
|
days_in_cr = (now - cr_entry_dt).total_seconds() / 86400
|
||||||
|
if days_in_cr > 2:
|
||||||
|
cr_data['stalledIssues'].append({'key': issue['key'], 'summary': fields['summary'], 'days': round(days_in_cr * 10) / 10})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Error procesando {issue.get('key', '?')}: {e}")
|
||||||
|
|
||||||
|
# Alertas de devs libres
|
||||||
|
EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca']
|
||||||
|
for dev, ad in assignee_data.items():
|
||||||
|
if dev not in EXCLUDED_FROM_ALERTS and ad['pending_count'] == 0:
|
||||||
|
warnings.append({'type': 'free_dev', 'summary': f'{dev} no tiene tickets pendientes ni en curso', 'devs': [dev]})
|
||||||
|
|
||||||
|
# Promedios mensuales QA/RTQA/CR
|
||||||
|
monthly_avg_qa = {m: mins_to_hrs(qa_data['monthlyTotalQa'][m] / qa_data['monthlyCountQa'][m]) for m in qa_data['monthlyTotalQa']}
|
||||||
|
monthly_avg_rtqa = {m: mins_to_hrs(qa_data['monthlyTotalRtqa'][m] / qa_data['monthlyCountRtqa'][m]) for m in qa_data['monthlyTotalRtqa']}
|
||||||
|
monthly_avg_cr = {m: mins_to_hrs(cr_data['monthlyTotalCr'][m] / cr_data['monthlyCountCr'][m]) for m in cr_data['monthlyTotalCr']}
|
||||||
|
|
||||||
|
# Percentiles
|
||||||
|
cr_sorted = sorted(cr_data['times'])
|
||||||
|
qa_sorted = sorted(qa_data['timesQa'])
|
||||||
|
rtqa_sorted = sorted(qa_data['timesRtqa'])
|
||||||
|
|
||||||
|
avg_lead = round(delivery_data['totalLeadTimeDays'] / delivery_data['doneTicketsLead']) if delivery_data['doneTicketsLead'] else 0
|
||||||
|
avg_cycle = round(delivery_data['totalCycleTimeDays'] / delivery_data['doneTicketsCycle']) if delivery_data['doneTicketsCycle'] else 0
|
||||||
|
sp_orig = delivery_data['doneSpOriginal']
|
||||||
|
sp_fin = delivery_data['doneSpFinal']
|
||||||
|
sp_dev_pct = round((sp_fin - sp_orig) / sp_orig * 100, 1) if sp_orig else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'lastUpdated': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'assigneeData': assignee_data,
|
||||||
|
'monthlyDone': monthly_done,
|
||||||
|
'issuesList': issues_list,
|
||||||
|
'warnings': warnings,
|
||||||
|
'currentStates': current_states,
|
||||||
|
'ticketsCreated': tickets_created,
|
||||||
|
'reporterData': reporter_data,
|
||||||
|
'deliveryData': {
|
||||||
|
'avgLeadTime': avg_lead,
|
||||||
|
'avgCycleTime': avg_cycle,
|
||||||
|
'spOriginal': sp_orig,
|
||||||
|
'spFinal': sp_fin,
|
||||||
|
'spDeviationPct': sp_dev_pct,
|
||||||
|
},
|
||||||
|
'crData': {
|
||||||
|
**cr_data,
|
||||||
|
'monthlyAvgCr': monthly_avg_cr,
|
||||||
|
'p50Cr': mins_to_hrs(get_percentile(cr_sorted, 0.5)),
|
||||||
|
'p90Cr': mins_to_hrs(get_percentile(cr_sorted, 0.9)),
|
||||||
|
'p99Cr': mins_to_hrs(get_percentile(cr_sorted, 0.99)),
|
||||||
|
'avgTimeCr': mins_to_hrs(cr_data['totalTimeCr'] / cr_data['tickets']) if cr_data['tickets'] else 0,
|
||||||
|
},
|
||||||
|
'qaData': {
|
||||||
|
**qa_data,
|
||||||
|
'monthlyAvgQa': monthly_avg_qa,
|
||||||
|
'monthlyAvgRtqa': monthly_avg_rtqa,
|
||||||
|
'p50Qa': mins_to_hrs(get_percentile(qa_sorted, 0.5)),
|
||||||
|
'p90Qa': mins_to_hrs(get_percentile(qa_sorted, 0.9)),
|
||||||
|
'p99Qa': mins_to_hrs(get_percentile(qa_sorted, 0.99)),
|
||||||
|
'p50Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.5)),
|
||||||
|
'p90Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.9)),
|
||||||
|
'p99Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.99)),
|
||||||
|
'avgTimeQa': mins_to_hrs(qa_data['totalTimeQa'] / qa_data['tickets']) if qa_data['tickets'] else 0,
|
||||||
|
'avgTimeRtqa': mins_to_hrs(qa_data['totalTimeRtqa'] / qa_data['tickets']) if qa_data['tickets'] else 0,
|
||||||
|
},
|
||||||
|
'config': {'host': config['jira']['host'], 'useTicketCount': USE_TICKET_COUNT},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Rutas Flask ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.route('/api/data')
|
||||||
|
def api_data():
|
||||||
|
try:
|
||||||
|
period = request.args.get('period', '90d')
|
||||||
|
force_refresh = request.args.get('refresh') == '1'
|
||||||
|
|
||||||
|
cache_valid = (
|
||||||
|
not force_refresh
|
||||||
|
and period in _cache
|
||||||
|
and (time.time() * 1000 - _cache_time.get(period, 0)) < config['cacheMsec']
|
||||||
|
)
|
||||||
|
|
||||||
|
if cache_valid:
|
||||||
|
return jsonify({**_cache[period], 'fromCache': True})
|
||||||
|
|
||||||
|
if not config['jira']['email'] or not config['jira']['token']:
|
||||||
|
return jsonify({'error': 'Faltan credenciales JIRA en el archivo .env'}), 500
|
||||||
|
|
||||||
|
jira_result = fetch_jira_issues(period)
|
||||||
|
data = process_issues(jira_result['issues'], period)
|
||||||
|
|
||||||
|
data['isTruncated'] = jira_result['isTruncated']
|
||||||
|
data['jiraTotal'] = '1500+' if jira_result['isTruncated'] else len(jira_result['issues'])
|
||||||
|
data['period'] = period
|
||||||
|
data['fromCache'] = False
|
||||||
|
|
||||||
|
_cache[period] = data
|
||||||
|
_cache_time[period] = time.time() * 1000
|
||||||
|
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# Servir archivos estáticos (CSS, JS)
|
||||||
|
@app.route('/css/<path:filename>')
|
||||||
|
def css(filename):
|
||||||
|
return send_from_directory('css', filename)
|
||||||
|
|
||||||
|
@app.route('/js/<path:filename>')
|
||||||
|
def js(filename):
|
||||||
|
return send_from_directory('js', filename)
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return send_from_directory('.', 'index.html')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("🚀 Dashboard corriendo en http://localhost:8888")
|
||||||
|
app.run(host='0.0.0.0', port=8888, debug=True)
|
||||||
@@ -5,6 +5,14 @@
|
|||||||
"src": "/api/(.*)",
|
"src": "/api/(.*)",
|
||||||
"dest": "/api/$1"
|
"dest": "/api/$1"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"src": "/css/(.*)",
|
||||||
|
"dest": "/css/$1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/js/(.*)",
|
||||||
|
"dest": "/js/$1"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"src": "/(.*)",
|
"src": "/(.*)",
|
||||||
"dest": "/index.html"
|
"dest": "/index.html"
|
||||||
|
|||||||
Reference in New Issue
Block a user