nuevo dash
This commit is contained in:
@@ -1,300 +1,121 @@
|
||||
📊 Análisis del Dashboard JIRA - DashLabs
|
||||
🏗️ Arquitectura del Proyecto
|
||||
Stack Tecnológico
|
||||
# 📊 Dashboard LABS — JIRA Story Points
|
||||
|
||||
Frontend: HTML5 + Vanilla JavaScript + Chart.js 4.4.1
|
||||
Backend: Node.js Serverless (Vercel Function)
|
||||
API: JIRA REST API v3
|
||||
Autenticación: Basic Auth (Email + Token JIRA)
|
||||
Caching: En memoria (24hs) + CDN (Vercel)
|
||||
Un dashboard en tiempo real para visualizar métricas, progreso de Story Points (SP), y la velocidad del equipo utilizando datos extraídos directamente de la API de Jira.
|
||||
|
||||
Estructura de Archivos
|
||||
dashlabs-master/
|
||||
├── index.html (25 KB - Frontend completo)
|
||||
Este proyecto está diseñado con un frontend ligero sin frameworks pesados y un backend *Serverless* preparado para ser desplegado en plataformas como Vercel o funcionar de manera local.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Stack Tecnológico
|
||||
|
||||
- **Frontend:** HTML5 + Vanilla TypeScript/JavaScript + [Chart.js (v4.4.1)](https://www.chartjs.org/)
|
||||
- **Backend:** Node.js Serverless Function
|
||||
- **API:** Integración directa con Jira REST API v3
|
||||
- **Autenticación:** Basic Auth mediante Email y API Token de Jira
|
||||
- **Caching:** Caché en memoria (24hs) + CDN-level Cache con "stale-while-revalidate"
|
||||
|
||||
## ✨ Características Principales
|
||||
|
||||
1. **Estado Actual del Equipo:**
|
||||
- Visualización de KPIS clave: SP en Progreso, SP por Hacer, SP Completados y Desarrolladores Activos.
|
||||
- Gráficos detallados del progreso por desarrollador.
|
||||
- Tabla que resume el porcentaje de carga de trabajo de cada uno.
|
||||
|
||||
2. **Histórico por Período:**
|
||||
- Resumen mensual de los Story Points completados.
|
||||
- Velocidad histórica del equipo a lo largo del tiempo.
|
||||
- Gráfico de top Épicas y distribución de SP.
|
||||
|
||||
3. **Performance Optimizado:**
|
||||
- Consulta eficiente con parámetros JQL directos.
|
||||
- Sistema de caché dual (Node memory + Vercel Edge) para evitar excesos de *Rate Limiting* en Jira y cargar instantáneamente.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Instalación y Configuración Local
|
||||
|
||||
### 1. Clonar el repositorio
|
||||
Cloná este repositorio y accedé a la carpeta del proyecto:
|
||||
```bash
|
||||
git clone https://github.com/kvilches1410/dashlabs.git
|
||||
cd dashlabs
|
||||
```
|
||||
|
||||
### 2. Instalar el CLI de Vercel
|
||||
Para correr las funciones *serverless* del backend (`api/data.js` de Vercel) localmente, necesitas instalar Vercel CLI de forma global:
|
||||
```bash
|
||||
npm install -g vercel
|
||||
```
|
||||
|
||||
### 3. Configurar Variables de Entorno
|
||||
Copiá el archivo de entorno de ejemplo como `.env` en la raíz del proyecto.
|
||||
```bash
|
||||
cp ex.env .env
|
||||
```
|
||||
Luego, editá el `.env` e ingresá tus credenciales de Jira:
|
||||
```env
|
||||
# Ejemplo de .env
|
||||
JIRA_EMAIL=tu-email@tudominio.com
|
||||
JIRA_TOKEN=tu-jira-api-token
|
||||
```
|
||||
> **Nota:** Podés generar un API Token de Jira en tu la configuración de seguridad de tu cuenta de Atlassian.
|
||||
|
||||
### 4. Entorno de Desarrollo
|
||||
Para levantar el proyecto y la API simulando el entorno de Vercel, ejecutá:
|
||||
```bash
|
||||
vercel dev
|
||||
```
|
||||
Luego, abrí `http://localhost:3000` en tu navegador.
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuración del Modelo de Datos de JIRA
|
||||
|
||||
Los campos personalizados (Custom Fields) varían según cada entorno de Jira. Actualmente, el mapeo de campos está de la siguiente manera:
|
||||
|
||||
| Campo / Tipo | Código (Custom Field) | Descripción |
|
||||
| :--- | :--- | :--- |
|
||||
| **Story Points Estimate** | `customfield_10016` | La estimación de SP original del ticket |
|
||||
| **Story Points Finales** | `customfield_11217` | SP finales acordados (Suele tener prioridad) |
|
||||
| **Desarrollador** | `customfield_10628` | Campo Multi-usuario para asignación de tareas |
|
||||
|
||||
> ⚠️ **Importante**: Verificá en tu instancia de Jira (`https://<tu-instancia>.atlassian.net/rest/api/3/fields`) que los IDs de estos campos correspondan a los utilizados en la API del dashboard. Si implementaste la nueva arquitectura, vas a pordelo modificar directamente en `api/config.js`.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Estructura del Proyecto
|
||||
|
||||
```text
|
||||
dashlabs/
|
||||
├── index.html # Frontend central. Contiene todo el layout, CSS y JS con Chart.js
|
||||
├── package.json # Dependencias y scripts
|
||||
├── vercel.json # Configuración para despliegue en Vercel
|
||||
├── .gitignore
|
||||
├── ex.env # Variables de entorno de ejemplo (TEMPLATE)
|
||||
└── api/
|
||||
└── data.js (5.5 KB - Backend serverless)
|
||||
├── config.js # [Opcional] Configuración modular para el Request de JIRA
|
||||
└── data.js # Vercel Serverless Function: Maneja el fetch, transformación de datos y caché
|
||||
```
|
||||
|
||||
📋 Componentes Principales
|
||||
Frontend (index.html)
|
||||
Elementos Visuales
|
||||
---
|
||||
|
||||
Header
|
||||
## 🌐 Despliegue en Producción (Vercel)
|
||||
|
||||
Título y metadata del proyecto
|
||||
Botón de actualización manual
|
||||
Badge de timestamp y estado de caché
|
||||
El proyecto está diseñado para funcionar nativamente dentro de [Vercel](https://vercel.com/).
|
||||
|
||||
1. Conectá el repositorio en el panel de Vercel.
|
||||
2. Agregá en Vercel **Environment Variables**:
|
||||
- `JIRA_EMAIL`
|
||||
- `JIRA_TOKEN`
|
||||
3. Dale a **Deploy**.
|
||||
|
||||
Sección 1: Estado Actual del Equipo
|
||||
La carpeta `/api` será parseada de manera automática y transformada en endpoints `/api/data` para que el `index.html` los consuma.
|
||||
|
||||
4 KPIs principales:
|
||||
---
|
||||
|
||||
SP En Progreso (color azul)
|
||||
SP Por Hacer (color naranja)
|
||||
SP Completados Total (color verde)
|
||||
Devs Activos (color púrpura)
|
||||
## 🤝 Contribuciones y Futuras Mejoras
|
||||
|
||||
|
||||
2 Gráficos:
|
||||
|
||||
SP en Progreso por Desarrollador (barras)
|
||||
Estado Actual por Desarrollador (barras agrupadas)
|
||||
|
||||
|
||||
Tabla Detallada:
|
||||
|
||||
Muestra por cada dev: Completados, En Progreso, Por Hacer
|
||||
Barra de progreso visual
|
||||
|
||||
|
||||
|
||||
|
||||
Sección 2: Completados por Período
|
||||
|
||||
Filtro por mes (dropdown)
|
||||
3 KPIs dinámicos:
|
||||
|
||||
SP Completados en el período
|
||||
Devs que completaron
|
||||
Promedio SP por dev
|
||||
|
||||
|
||||
3 Gráficos:
|
||||
|
||||
Velocity por mes (barras apiladas)
|
||||
Top 15 Épicas
|
||||
SP Completados por Desarrollador (período filtrado)
|
||||
|
||||
|
||||
Tabla de Historial:
|
||||
|
||||
SP completados con % de participación
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Backend (api/data.js)
|
||||
Configuración JIRA
|
||||
javascriptJIRA_HOST = 'invera.atlassian.net'
|
||||
JQL = 'project = LABS AND cf[10016] is not EMPTY ORDER BY updated DESC'
|
||||
Campos Customizados Mapeados
|
||||
CampoCodeDescripciónStory Points Estimatecustomfield_10016Story points estimadoStory Points Finalescustomfield_11019SP final (prioridad sobre estimate)Desarrollador (Multi)customfield_10622Campo multi-usuario de asignaciónParentparentÉpicaResolution DateresolutiondateFecha de completado
|
||||
Flujo de Datos
|
||||
1. fetchJiraIssues()
|
||||
├── Autentica con Basic Auth (JIRA_EMAIL + JIRA_TOKEN)
|
||||
├── Ejecuta JQL query contra JIRA API
|
||||
└── Retorna issues con campos específicos
|
||||
|
||||
2. processIssues()
|
||||
├── Mapea developers (customfield_10622 → displayName)
|
||||
├── Extrae SP (prioriza customfield_11019)
|
||||
├── Categoriza por status (done/indeterminate/todo)
|
||||
├── Agrupa:
|
||||
│ ├── Por desarrollador (assigneeData)
|
||||
│ ├── Por mes (monthlyDone)
|
||||
│ └── Por épica (epicData)
|
||||
└── Retorna DATA object estructurado
|
||||
|
||||
3. Caching
|
||||
├── En memoria: 24 horas
|
||||
├── CDN (Vercel): s-maxage=86400
|
||||
└── Parámetro ?refresh=1 fuerza actualización
|
||||
|
||||
🔑 Características Principales
|
||||
✅ Fortalezas Actuales
|
||||
|
||||
✨ Diseño limpio - UI coherente con color scheme profesional
|
||||
📊 Múltiples perspectivas - Estado actual + Histórico
|
||||
🎯 Enfoque en Story Points - Métrica clara de trabajo
|
||||
⚡ Rendimiento - Arquitectura serverless + caché eficiente
|
||||
🔄 Auto-refresh - Actualización automática cada 24hs
|
||||
📱 Responsive - Funciona en móviles (media queries)
|
||||
🎨 Charts.js - Gráficos interactivos y claros
|
||||
|
||||
⚠️ Áreas de Mejora
|
||||
1. Hardcoding de Configuración
|
||||
javascript// Actualmente:
|
||||
const JIRA_HOST = 'invera.atlassian.net';
|
||||
const JQL = 'project = LABS AND cf[10016] is not EMPTY...';
|
||||
|
||||
// Debería ser:
|
||||
// Variables de entorno / archivo config
|
||||
2. Limitación de Resultados
|
||||
javascriptconst MAX_RESULTS = 100; // ⚠️ Solo trae primeros 100 tickets
|
||||
Si LABS tiene >100 tickets, faltan datos en el dashboard.
|
||||
3. Sin Control de Errores Robusto
|
||||
|
||||
El error banner es genérico
|
||||
No hay retry automático
|
||||
La caché nunca se invalida si hay error
|
||||
|
||||
4. Sin API REST moderna
|
||||
|
||||
El data.js es una Vercel Function directa
|
||||
No hay validación de parámetros
|
||||
Sin autenticación en el frontend
|
||||
|
||||
5. Estado Global con DATA
|
||||
javascript// En window.DATA - manejo de estado débil
|
||||
// Mejor: Usar módulo o estado centralizado
|
||||
|
||||
📈 Propuestas de Mejora
|
||||
Fase 1: Críticas (Deberías hacer ya)
|
||||
|
||||
Aumentar MAX_RESULTS a 500 (o implementar paginación)
|
||||
|
||||
javascript const MAX_RESULTS = 500;
|
||||
// O mejor: implementar loop hasta total
|
||||
|
||||
Validar tokens y secrets en variables de entorno
|
||||
|
||||
javascript if (!email || !token) throw new Error('...');
|
||||
// ✅ Ya está, pero log más claro
|
||||
|
||||
Separar configuración de código
|
||||
|
||||
javascript // config.js
|
||||
export const CONFIG = {
|
||||
jiraHost: process.env.JIRA_HOST || 'invera.atlassian.net',
|
||||
jiraProject: process.env.JIRA_PROJECT || 'LABS',
|
||||
customFields: { /* ... */ }
|
||||
};
|
||||
Fase 2: Mejoras UX (Agregar valor)
|
||||
|
||||
Filtros interactivos en "Estado Actual"
|
||||
|
||||
Por desarrollador
|
||||
Por status
|
||||
Por épica
|
||||
Por rango de SP
|
||||
|
||||
|
||||
Tabla detallada de tickets
|
||||
|
||||
Mostrar tickets abiertos con links a JIRA
|
||||
Filtros por dev, status, épica
|
||||
Buscar por summary
|
||||
|
||||
|
||||
Comparativas entre períodos
|
||||
|
||||
Velocidad mes a mes
|
||||
Trending
|
||||
Predictibilidad
|
||||
|
||||
|
||||
Alertas/Warnings
|
||||
|
||||
Tickets en progreso > X días
|
||||
SP sin asignar
|
||||
Devs con carga desequilibrada
|
||||
|
||||
|
||||
|
||||
Fase 3: Arquitectura (Scaling)
|
||||
|
||||
API REST con autenticación
|
||||
|
||||
javascript // api/issues - GET listar con filtros
|
||||
// api/devs - GET listar desarrolladores
|
||||
// api/epics - GET listar épicas
|
||||
// api/stats - POST calcular período custom
|
||||
|
||||
Frontend modular
|
||||
|
||||
Separar en componentes
|
||||
State management (Zustand / Pinia)
|
||||
Mejorar mantenibilidad
|
||||
|
||||
|
||||
Database (opcional)
|
||||
|
||||
Guardar snapshots diarios
|
||||
Querys históricas más rápidas
|
||||
Trend analysis
|
||||
|
||||
|
||||
|
||||
|
||||
🔧 Guía de Configuración
|
||||
Variables de Entorno Requeridas (Vercel)
|
||||
JIRA_EMAIL=tu-email@example.com
|
||||
JIRA_TOKEN=abc123xyz... # Generar en atlassian.net/account
|
||||
Campos Customizados a Verificar
|
||||
Cada Jira tiene IDs diferentes. Verificar en:
|
||||
|
||||
Settings → Custom fields
|
||||
O ejecutar: curl -u email:token https://invera.atlassian.net/rest/api/3/fields
|
||||
|
||||
Para este proyecto:
|
||||
cf[10016] → Story points estimate
|
||||
cf[11019] → Story Points Finales
|
||||
cf[10622] → Desarrollador (multi-user)
|
||||
⚠️ IMPORTANTE: Estos IDs pueden cambiar entre instancias JIRA.
|
||||
|
||||
📊 Ejemplos de Datos Procesados
|
||||
assigneeData
|
||||
javascript{
|
||||
"Juan López": {
|
||||
done: 34,
|
||||
in_progress: 8,
|
||||
todo: 5
|
||||
},
|
||||
"María García": {
|
||||
done: 28,
|
||||
in_progress: 13,
|
||||
todo: 2
|
||||
}
|
||||
}
|
||||
monthlyDone
|
||||
javascript{
|
||||
"2024-12": { "Juan López": 15, "María García": 12 },
|
||||
"2025-01": { "Juan López": 12, "María García": 18 },
|
||||
"2025-02": { "Juan López": 7, "María García": -2 }
|
||||
}
|
||||
epicData
|
||||
javascript[
|
||||
{
|
||||
name: "Refactor Backend API",
|
||||
done: 45,
|
||||
in_progress: 21,
|
||||
todo: 8
|
||||
}
|
||||
]
|
||||
|
||||
🚀 Próximos Pasos Recomendados
|
||||
|
||||
Inmediato:
|
||||
|
||||
Verificar campos customizados en tu instancia JIRA
|
||||
Aumentar MAX_RESULTS
|
||||
Revisar que caché funciona (badge de timestamp)
|
||||
|
||||
|
||||
Corto plazo (1-2 semanas):
|
||||
|
||||
Agregar tabla de tickets con detalles
|
||||
Implementar filtros en "Estado Actual"
|
||||
Mejorar manejo de errores
|
||||
|
||||
|
||||
Mediano plazo (1 mes):
|
||||
|
||||
API REST modularizada
|
||||
Database para históricos
|
||||
Alertas y predicciones
|
||||
|
||||
|
||||
|
||||
|
||||
📝 Notas Finales
|
||||
El proyecto es sólido y bien estructurado para un dashboard rápido. La arquitectura serverless de Vercel es ideal para consultas ocasionales. Si la demanda crece (100s de consultas/día) o necesitas más features, considera:
|
||||
|
||||
Agregar backend Node.js con Express
|
||||
Database (PostgreSQL/MongoDB)
|
||||
State management moderno
|
||||
Testing automatizado
|
||||
|
||||
¿Necesitas ayuda implementando alguna de estas mejoras?
|
||||
Algunos puntos clave (propuestos) a iterar en las siguientes versiones:
|
||||
- Paginación o control de loop dinámico para extraer más allá de `MAX_RESULTS` sin quemar límites.
|
||||
- Implementar Zustand o algún state-manager si el frontend de "Vanilla JS" crece en complejidad.
|
||||
- Posibilidad de tener filtros aplicados mediante *Query Params* o selectores en la UI antes de ejecutar cálculos.
|
||||
|
||||
+108
-51
@@ -9,8 +9,14 @@
|
||||
import { config } from './config.js';
|
||||
|
||||
// ─── Cache en memoria ──────────────────────────────────────────────
|
||||
let _cache = null;
|
||||
let _cacheTime = 0;
|
||||
let _cache = {};
|
||||
let _cacheTime = {};
|
||||
|
||||
export function clearCache() {
|
||||
_cache = {};
|
||||
_cacheTime = {};
|
||||
console.log('🧹 Caché en memoria limpiada');
|
||||
}
|
||||
|
||||
// ─── JIRA Fetch ────────────────────────────────────────────────────
|
||||
/**
|
||||
@@ -19,35 +25,67 @@ let _cacheTime = 0;
|
||||
* @param {string} token - API Token
|
||||
* @returns {Promise<{issues: Array, total: number}>}
|
||||
*/
|
||||
async function fetchJiraIssues(email, token) {
|
||||
async function fetchJiraIssues(email, token, period) {
|
||||
const auth = Buffer.from(`${email}:${token}`).toString('base64');
|
||||
const maxResultsWanted = 1500; // Límite seguro por usuario
|
||||
let allIssues = [];
|
||||
let totalInJira = 0;
|
||||
let startAt = 0;
|
||||
|
||||
const url = `https://${config.jira.host}/rest/api/3/search/jql`
|
||||
+ `?jql=${encodeURIComponent(config.jql)}`
|
||||
+ `&fields=${encodeURIComponent(buildFieldsList())}`
|
||||
+ `&maxResults=${config.api.maxResults}`;
|
||||
// Inyectar el filtro de fecha en el JQL base
|
||||
let dynamicJql = config.jql;
|
||||
if (period !== 'all') {
|
||||
const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`;
|
||||
// Intentamos insertarlo antes del ORDER BY
|
||||
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} (${config.api.maxResults} resultados max)`);
|
||||
console.log(`📋 JQL: ${config.jql}`);
|
||||
console.log(`📡 Consultando JIRA: ${config.jira.project} (${maxResultsWanted} resultados max, periodo: ${period})`);
|
||||
console.log(`📋 JQL: ${dynamicJql}`);
|
||||
|
||||
const res = await fetch(url, {
|
||||
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',
|
||||
},
|
||||
'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);
|
||||
throw new Error(`JIRA API ${res.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`);
|
||||
const items = data.issues || [];
|
||||
allIssues = allIssues.concat(items);
|
||||
nextPageToken = data.nextPageToken;
|
||||
|
||||
return data;
|
||||
if (!nextPageToken || items.length === 0) {
|
||||
break; // No more pages
|
||||
}
|
||||
}
|
||||
|
||||
// Si frenamos la paginación porque superamos el maxResultsWanted pero aún hay nextPageToken, está truncado
|
||||
const isTruncated = !!nextPageToken;
|
||||
|
||||
console.log(`✅ Se obtuvieron ${allIssues.length} issues (Truncado: ${isTruncated})`);
|
||||
|
||||
return { issues: allIssues, isTruncated: isTruncated };
|
||||
}
|
||||
|
||||
// ─── Build Fields List ─────────────────────────────────────────────
|
||||
@@ -62,9 +100,9 @@ function buildFieldsList() {
|
||||
config.fields.spEstimate, // Story points estimate
|
||||
config.fields.spFinal, // Story points finales
|
||||
config.fields.developer, // Desarrollador (multi-user)
|
||||
'parent',
|
||||
'resolutiondate',
|
||||
'created',
|
||||
'updated',
|
||||
].join(',');
|
||||
}
|
||||
|
||||
@@ -77,7 +115,10 @@ function buildFieldsList() {
|
||||
function processIssues(issues) {
|
||||
const assigneeData = {};
|
||||
const monthlyDone = {};
|
||||
const epicMap = {};
|
||||
const issuesList = [];
|
||||
const warnings = [];
|
||||
const now = new Date();
|
||||
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
@@ -119,9 +160,6 @@ function processIssues(issues) {
|
||||
? 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]) {
|
||||
@@ -148,17 +186,34 @@ function processIssues(issues) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Acumular por Épica ─────────────────────────────────────
|
||||
if (!epicMap[epicName]) {
|
||||
epicMap[epicName] = { name: epicName, done: 0, in_progress: 0, todo: 0 };
|
||||
// ── Generar issuesList para Filtros ───────────────────────
|
||||
issuesList.push({
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
summary: f.summary,
|
||||
status: statusKey,
|
||||
sp: sp,
|
||||
devs: devs,
|
||||
creMonth
|
||||
});
|
||||
|
||||
// ── Detectar Warnings ───────────────────────────────────────
|
||||
// 1. Tickets > 14 días in-progress
|
||||
if (isInProgress && f.updated) {
|
||||
const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24);
|
||||
if (days > 14) {
|
||||
warnings.push({ type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) });
|
||||
}
|
||||
}
|
||||
|
||||
if (isDone) {
|
||||
epicMap[epicName].done += sp;
|
||||
} else if (isInProgress) {
|
||||
epicMap[epicName].in_progress += sp;
|
||||
} else {
|
||||
epicMap[epicName].todo += sp;
|
||||
// 2. Done sin SP
|
||||
if (isDone && sp === 0) {
|
||||
warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 });
|
||||
}
|
||||
|
||||
// 3. SP sin asignar (todo/in-progress sin dev)
|
||||
if (!isDone && devs.includes('Sin asignar') && sp > 0) {
|
||||
warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp });
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
@@ -168,7 +223,7 @@ function processIssues(issues) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount}`);
|
||||
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`);
|
||||
|
||||
return {
|
||||
lastUpdated: new Date().toISOString(),
|
||||
@@ -176,10 +231,8 @@ function processIssues(issues) {
|
||||
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)
|
||||
),
|
||||
issuesList,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,43 +250,47 @@ export default async (req, res) => {
|
||||
res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600');
|
||||
|
||||
try {
|
||||
// Force refresh con parámetro ?refresh=1
|
||||
// Force refresh y Periodo temporal
|
||||
const forceRefresh = req.query?.refresh === '1';
|
||||
const period = req.query?.period || '90d';
|
||||
|
||||
// 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 });
|
||||
// Servir desde caché segmentado por período si está fresco
|
||||
if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) {
|
||||
console.log(`📦 Sirviendo desde caché para periodo: ${period}`);
|
||||
return res.status(200).json({ ..._cache[period], fromCache: true });
|
||||
}
|
||||
|
||||
// ── Consultar JIRA ─────────────────────────────────────────
|
||||
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token);
|
||||
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period);
|
||||
const data = processIssues(jiraData.issues);
|
||||
|
||||
// Agregar metadata
|
||||
data.jiraTotal = jiraData.total; // Total real en JIRA (puede ser > maxResults)
|
||||
data.isTruncated = jiraData.isTruncated; // Si hay más paginación pendiente del límite
|
||||
data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length;
|
||||
data.period = period;
|
||||
data.config = {
|
||||
project: config.jira.project,
|
||||
host: config.jira.host,
|
||||
maxResults: config.api.maxResults,
|
||||
maxResults: 1500,
|
||||
cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60),
|
||||
};
|
||||
|
||||
// Guardar en caché en memoria
|
||||
_cache = data;
|
||||
_cacheTime = Date.now();
|
||||
// Guardar en caché segmentado
|
||||
_cache[period] = data;
|
||||
_cacheTime[period] = Date.now();
|
||||
|
||||
console.log('✅ Datos frescos desde JIRA almacenados en caché');
|
||||
console.log(`✅ Datos frescos almacenados en caché para periodo: ${period}`);
|
||||
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) {
|
||||
// Intentar servir desde caché global si hay error
|
||||
const period = req.query?.period || '90d';
|
||||
if (_cache[period]) {
|
||||
console.log('⚠️ Sirviendo caché anterior debido a error');
|
||||
return res.status(200).json({
|
||||
..._cache,
|
||||
..._cache[period],
|
||||
fromCache: true,
|
||||
error: `Error al consultar JIRA: ${err.message}. Sirviendo datos en caché.`,
|
||||
});
|
||||
|
||||
@@ -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é.' });
|
||||
}
|
||||
};
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
* { 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-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-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 */
|
||||
.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; overflow-x: auto; }
|
||||
.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; }
|
||||
|
||||
/* 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; }
|
||||
|
||||
.jira-link-small { color: #5e6c84; text-decoration: none; font-size: 10px; background: #f4f5f7; padding: 1px 4px; border-radius: 3px; border: 1px solid #dfe1e6; transition: all .2s; margin-right: 4px; display: inline-block; margin-top: 4px; }
|
||||
.jira-link-small:hover { background: #ebecf0; color: #172b4d; border-color: #c1c7d0; text-decoration: none; }
|
||||
|
||||
.ticket-list-inline { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 2px; }
|
||||
|
||||
/* Tooltip improvements (native title) */
|
||||
[title] { cursor: help; }
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
+66
-383
@@ -4,453 +4,136 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard LABS — Story Points</title>
|
||||
<link rel="stylesheet" href="./css/styles.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #172b4d; }
|
||||
header { background: #0052cc; color: white; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; }
|
||||
header h1 { font-size: 22px; font-weight: 700; }
|
||||
.header-meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.header-meta span { font-size: 12px; opacity: 0.75; }
|
||||
.refresh-btn { background: rgba(255,255,255,.15); border: 1px solid rgba(255,255,255,.3); color: white; padding: 6px 14px; border-radius: 5px; cursor: pointer; font-size: 13px; transition: background .15s; }
|
||||
.refresh-btn:hover { background: rgba(255,255,255,.25); }
|
||||
.refresh-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.cache-badge { font-size: 11px; opacity: .7; }
|
||||
/* Loading */
|
||||
.loading-overlay { position: fixed; inset: 0; background: rgba(240,242,245,.9); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 100; gap: 16px; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid #dfe1e6; border-top-color: #0052cc; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading-overlay p { color: #5e6c84; font-size: 14px; }
|
||||
/* Error */
|
||||
.error-banner { background: #ffebe6; border: 1px solid #ff5630; color: #bf2600; padding: 14px 32px; font-size: 14px; display: none; }
|
||||
.section-header { padding: 14px 32px; display: flex; align-items: center; gap: 12px; border-bottom: 2px solid #dfe1e6; background: white; flex-wrap: wrap; }
|
||||
.section-header.current { border-color: #0065ff; }
|
||||
.section-header.history { border-color: #36b37e; }
|
||||
.section-header h2 { font-size: 14px; font-weight: 700; }
|
||||
.section-header .dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.section-header.current .dot { background: #0065ff; }
|
||||
.section-header.history .dot { background: #36b37e; }
|
||||
.section-header label { font-size: 13px; font-weight: 600; color: #5e6c84; margin-left: auto; }
|
||||
.section-header select { padding: 6px 12px; border: 1px solid #dfe1e6; border-radius: 4px; font-size: 14px; color: #172b4d; background: #fafbfc; cursor: pointer; outline: none; }
|
||||
.section-header select:focus { border-color: #0052cc; }
|
||||
.badge { background: #e3f2fd; color: #0052cc; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||
.main { padding: 24px 32px; max-width: 1400px; margin: 0 auto; }
|
||||
.section-block { margin-bottom: 36px; }
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.kpi-row-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||
.kpi { background: white; border-radius: 8px; padding: 20px 24px; border-left: 4px solid #dfe1e6; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||||
.kpi.done { border-color: #36b37e; }
|
||||
.kpi.progress{ border-color: #0065ff; }
|
||||
.kpi.todo { border-color: #ff8b00; }
|
||||
.kpi.members { border-color: #6554c0; }
|
||||
.kpi-value { font-size: 36px; font-weight: 700; line-height: 1; }
|
||||
.kpi.done .kpi-value { color: #36b37e; }
|
||||
.kpi.progress .kpi-value { color: #0065ff; }
|
||||
.kpi.todo .kpi-value { color: #ff8b00; }
|
||||
.kpi.members .kpi-value { color: #6554c0; }
|
||||
.kpi-label { font-size: 12px; color: #5e6c84; margin-top: 6px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.kpi-sub { font-size: 11px; color: #97a0af; margin-top: 4px; }
|
||||
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px; }
|
||||
.chart-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }
|
||||
.chart-card.full { grid-column: 1 / -1; }
|
||||
.chart-card h3 { font-size: 14px; font-weight: 700; color: #172b4d; margin-bottom: 4px; }
|
||||
.chart-card .sub { font-size: 12px; color: #5e6c84; margin-bottom: 20px; }
|
||||
.table-card { background: white; border-radius: 8px; padding: 24px; box-shadow: 0 1px 3px rgba(0,0,0,.06); margin-bottom: 24px; }
|
||||
.table-card h3 { font-size: 14px; font-weight: 700; margin-bottom: 16px; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; padding: 10px 14px; background: #f4f5f7; color: #5e6c84; font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid #dfe1e6; }
|
||||
td { padding: 12px 14px; border-bottom: 1px solid #f4f5f7; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8f9fa; }
|
||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||
.pill.done { background: #e3fcef; color: #006644; }
|
||||
.pill.progress { background: #deebff; color: #0052cc; }
|
||||
.pill.todo { background: #fffae6; color: #7a4b00; }
|
||||
.pbar-wrap { background: #f4f5f7; border-radius: 4px; height: 8px; width: 140px; overflow: hidden; display: flex; }
|
||||
.pbar-done { background: #36b37e; height: 100%; }
|
||||
.pbar-ip { background: #0065ff; height: 100%; }
|
||||
.pbar-todo { background: #ff8b00; height: 100%; }
|
||||
.avatar { width: 28px; height: 28px; border-radius: 50%; color: white; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; flex-shrink: 0; }
|
||||
.name-cell { display: flex; align-items: center; }
|
||||
.no-data { text-align: center; color: #97a0af; padding: 40px; font-size: 14px; }
|
||||
footer { text-align: center; padding: 24px; font-size: 11px; color: #97a0af; }
|
||||
@media (max-width: 768px) {
|
||||
.kpi-row, .kpi-row-3 { grid-template-columns: 1fr 1fr; }
|
||||
.charts-grid { grid-template-columns: 1fr; }
|
||||
.chart-card.full { grid-column: 1; }
|
||||
.main { padding: 16px; }
|
||||
.section-header { gap: 8px; }
|
||||
.section-header label { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="./js/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="spinner"></div>
|
||||
<p>Consultando Jira en tiempo real…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error banner -->
|
||||
<div class="error-banner" id="errorBanner"></div>
|
||||
<div class="error-banner" id="limitBanner" style="background:#fff7d6; color:#a15900; border-color:#ffab00;"></div>
|
||||
|
||||
<!-- Header Principal -->
|
||||
<header>
|
||||
<div>
|
||||
<h1>📊 Dashboard LABS — Story Points</h1>
|
||||
<div style="font-size:13px;opacity:.7;margin-top:4px;">
|
||||
Proyecto: <strong>LABS</strong> · invera.atlassian.net · Campo: Desarrollador
|
||||
Proyecto: <strong>LABS</strong> · invera.atlassian.net
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<span id="lastUpdatedLabel">—</span>
|
||||
<span class="cache-badge" id="cacheBadge"></span>
|
||||
<button class="refresh-btn" id="refreshBtn" onclick="loadData(true)">↻ Actualizar</button>
|
||||
<label style="font-size:12px;opacity:0.8;color:white;margin-right:-6px;">Rango:</label>
|
||||
<select id="timeRangeFilter" style="padding:4px 8px; font-size:12px; border-radius:4px; max-width:120px;">
|
||||
<option value="1d">Hoy</option>
|
||||
<option value="7d">Últimos 7 días</option>
|
||||
<option value="30d">Último mes</option>
|
||||
<option value="90d" selected>Últimos 3 meses</option>
|
||||
<option value="365d">Último año</option>
|
||||
<option value="all">Histórico completo</option>
|
||||
</select>
|
||||
<button class="refresh-btn" id="refreshBtn">↻ Actualizar</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- ── SECCIÓN 1: Estado Actual ── -->
|
||||
|
||||
<!-- ── SECCIÓN 1: Carga de Trabajo (Pendientes) ── -->
|
||||
<div class="section-header current">
|
||||
<div class="dot"></div>
|
||||
<h2>Estado Actual del Equipo</h2>
|
||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Tickets activos en el tablero ahora mismo</span>
|
||||
<div class="dot" style="background:#0065ff;"></div>
|
||||
<h2>Carga de Trabajo Actual</h2>
|
||||
<span id="currentTicketsLabel" style="font-size:12px;color:#5e6c84;margin-left:8px;">Tickets pendientes en el tablero</span>
|
||||
|
||||
<div class="filter-group">
|
||||
<label>Filtrar por Dev:</label>
|
||||
<select id="devFilter"><option value="all">Cargando...</option></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="section-block">
|
||||
<div class="kpi-row">
|
||||
<div class="kpi progress"><div class="kpi-value" id="kpi-ip">—</div><div class="kpi-label">SP En Progreso</div><div class="kpi-sub">En curso · Code Review · QA</div></div>
|
||||
<div class="kpi todo"> <div class="kpi-value" id="kpi-todo">—</div><div class="kpi-label">SP Por Hacer</div><div class="kpi-sub">Backlog asignado</div></div>
|
||||
<div class="kpi done"> <div class="kpi-value" id="kpi-done-total">—</div><div class="kpi-label">SP Completados (total)</div><div class="kpi-sub">Histórico acumulado</div></div>
|
||||
<div class="kpi members"><div class="kpi-value" id="kpi-members">—</div><div class="kpi-label">Devs activos</div><div class="kpi-sub">Con SP asignados</div></div>
|
||||
<div class="kpi progress"><div class="kpi-value" id="kpi-ip">—</div><div class="kpi-label">SP En Progreso</div><div class="kpi-sub">Trabajo en curso</div></div>
|
||||
<div class="kpi todo"> <div class="kpi-value" id="kpi-todo">—</div><div class="kpi-label">SP Por Hacer</div><div class="kpi-sub">Backlog del periodo</div></div>
|
||||
<div class="kpi members"><div class="kpi-value" id="kpi-members">—</div><div class="kpi-label">Devs con Carga</div><div class="kpi-sub">Con tickets pendientes</div></div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>🔵 SP en Progreso — por Desarrollador</h3>
|
||||
<div class="sub">Estado actual · En curso / Code Review / QA</div>
|
||||
<div style="position:relative;height:260px"><canvas id="chartInProgress"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>📦 Estado actual por Desarrollador</h3>
|
||||
<div class="sub">Completados · En Progreso · Por Hacer (acumulado)</div>
|
||||
<h3>🔵 Pendientes por Desarrollador</h3>
|
||||
<div class="sub">Story Points con trabajo pendiente</div>
|
||||
<div style="position:relative;height:260px"><canvas id="chartCurrentState"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>📢 Alertas de Salud</h3>
|
||||
<div class="sub">Tickets estancados o sin estimación</div>
|
||||
<div id="warningsSection" style="display:none; overflow-y:auto; max-height:230px; margin-top:10px;">
|
||||
<ul class="warning-list" id="warningsList" style="padding:0; margin:0;"></ul>
|
||||
</div>
|
||||
<div id="noWarningsMsg" style="text-align:center; color:#36b37e; padding:40px 0; display:none;">
|
||||
✅ No hay alertas pendientes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h3>Detalle por Desarrollador — Estado Actual</h3>
|
||||
<h3>Detalle de Carga de Trabajo</h3>
|
||||
<table>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ Completados</th><th>🔵 En Progreso</th><th>⏳ Por Hacer</th><th>Total SP</th><th>Progreso</th></tr></thead>
|
||||
<thead><tr><th>Desarrollador</th><th>🔵 En Progreso</th><th>⏳ Por Hacer</th><th>Total Pendiente</th><th>Carga Visual</th></tr></thead>
|
||||
<tbody id="tableCurrentState"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── SECCIÓN 2: Completados por período ── -->
|
||||
|
||||
<!-- ── SECCIÓN 2: Rendimiento & Resultados ── -->
|
||||
<div class="section-header history">
|
||||
<div class="dot"></div>
|
||||
<h2>Completados por Período</h2>
|
||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Filtrar por mes de resolución</span>
|
||||
<label>Ver mes:</label>
|
||||
<select id="monthFilter" onchange="updateHistory()"></select>
|
||||
<span class="badge" id="filterBadge">Todos los meses</span>
|
||||
<div class="dot" style="background:#36b37e;"></div>
|
||||
<h2>Resultados del Período</h2>
|
||||
<span style="font-size:12px;color:#5e6c84;margin-left:8px;">Story Points finalizados y velocidad</span>
|
||||
<span class="badge" id="filterBadge" style="display:none;"></span>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="section-block">
|
||||
<div class="kpi-row-3">
|
||||
<div class="kpi done"> <div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">SP Completados</div><div class="kpi-sub" id="kpi-done-period-sub">en el período</div></div>
|
||||
<div class="kpi done"> <div class="kpi-value" id="kpi-done-period">—</div><div class="kpi-label">SP Completados</div><div class="kpi-sub">en el período</div></div>
|
||||
<div class="kpi members"><div class="kpi-value" id="kpi-devs-period">—</div><div class="kpi-label">Desarrolladores</div><div class="kpi-sub">que completaron SP</div></div>
|
||||
<div class="kpi progress"><div class="kpi-value" id="kpi-avg-period">—</div><div class="kpi-label">SP promedio / dev</div><div class="kpi-sub">en el período</div></div>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card">
|
||||
<h3>✅ SP Completados — por Desarrollador</h3>
|
||||
<div class="sub" id="subtitle-history-dev">Período seleccionado</div>
|
||||
<div class="sub">Período seleccionado</div>
|
||||
<div style="position:relative;height:260px"><canvas id="chartDoneDev"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<h3>📈 Velocidad del Equipo</h3>
|
||||
<div class="sub">SP completados por mes · cada color = un dev</div>
|
||||
<h3>📈 Velocidad Histórica</h3>
|
||||
<div class="sub">SP completados mes a mes</div>
|
||||
<div style="position:relative;height:260px"><canvas id="chartVelocity"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card full">
|
||||
<h3>🗂 SP por Épica — Estado Actual</h3>
|
||||
<div class="sub">Completados · En Progreso · Por Hacer en cada épica</div>
|
||||
<div style="position:relative;height:220px"><canvas id="chartEpic"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<h3 id="table-history-title">SP Completados — Todos los meses</h3>
|
||||
<h3>SP Completados — Tabla Resumen</h3>
|
||||
<table>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>% del total del período</th></tr></thead>
|
||||
<thead><tr><th>Desarrollador</th><th>✅ SP Completados</th><th>% del impacto total</th></tr></thead>
|
||||
<tbody id="tableHistory"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Dashboard en tiempo real · Proyecto LABS · invera.atlassian.net ·
|
||||
Campo: <strong>Desarrollador</strong> (customfield_10622) + SP Finales (customfield_11019) ·
|
||||
Cache: 5 min · <span id="totalIssuesLabel"></span>
|
||||
Dashboard en tiempo real · Arquitectura Modular Serverless · Realizado para Vercel & Jira
|
||||
</footer>
|
||||
<script>
|
||||
// ── Colores ────────────────────────────────────────────────────────────────
|
||||
const COLORS = [
|
||||
'#0052cc','#36b37e','#ff5630','#6554c0','#ff8b00',
|
||||
'#00b8d9','#57d9a3','#f99cdb','#8777d9','#344563',
|
||||
'#403294','#00875a','#de350b','#505f79','#42526e',
|
||||
];
|
||||
const MONTH_LABELS_MAP = {
|
||||
'2025-01':'Ene 2025','2025-02':'Feb 2025','2025-03':'Mar 2025',
|
||||
'2025-04':'Abr 2025','2025-05':'May 2025','2025-06':'Jun 2025',
|
||||
'2025-07':'Jul 2025','2025-08':'Ago 2025','2025-09':'Sep 2025',
|
||||
'2025-10':'Oct 2025','2025-11':'Nov 2025','2025-12':'Dic 2025',
|
||||
'2026-01':'Ene 2026','2026-02':'Feb 2026','2026-03':'Mar 2026',
|
||||
'2026-04':'Abr 2026','2026-05':'May 2026','2026-06':'Jun 2026',
|
||||
};
|
||||
function monthLabel(m) { return MONTH_LABELS_MAP[m] || m; }
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
let _peopleOrder = [];
|
||||
function personColor(name) {
|
||||
let idx = _peopleOrder.indexOf(name);
|
||||
if (idx < 0) { _peopleOrder.push(name); idx = _peopleOrder.length - 1; }
|
||||
return COLORS[idx % COLORS.length];
|
||||
}
|
||||
function initials(name) { return name.split(' ').map(w=>w[0]).join('').slice(0,2).toUpperCase(); }
|
||||
function shortName(name) { const p = name.split(' '); return p[0] + (p[1] ? ' ' + p[1] : ''); }
|
||||
// ── Chart instances ────────────────────────────────────────────────────────
|
||||
let charts = {};
|
||||
function destroyChart(id) { if (charts[id]) { charts[id].destroy(); delete charts[id]; } }
|
||||
// ── Global data ────────────────────────────────────────────────────────────
|
||||
let DATA = null;
|
||||
// ── Load data from API ─────────────────────────────────────────────────────
|
||||
async function loadData(forceRefresh = false) {
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
document.getElementById('errorBanner').style.display = 'none';
|
||||
document.getElementById('refreshBtn').disabled = true;
|
||||
try {
|
||||
const url = '/api/data' + (forceRefresh ? '?refresh=1' : '');
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || `HTTP ${res.status}`);
|
||||
}
|
||||
DATA = await res.json();
|
||||
_peopleOrder = Object.keys(DATA.assigneeData);
|
||||
buildMonthSelector();
|
||||
renderCurrentSection();
|
||||
renderHistorySection();
|
||||
// Last updated label
|
||||
const d = new Date(DATA.lastUpdated);
|
||||
document.getElementById('lastUpdatedLabel').textContent =
|
||||
'Actualizado: ' + d.toLocaleString('es-AR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||
document.getElementById('cacheBadge').textContent = DATA.fromCache ? '(desde caché)' : '(CACHE)';
|
||||
if (DATA.jiraTotal > DATA.totalIssues) {
|
||||
document.getElementById('totalIssuesLabel').textContent =
|
||||
`Mostrando ${DATA.totalIssues} de ${DATA.jiraTotal} issues totales con SP`;
|
||||
}
|
||||
} catch(e) {
|
||||
const banner = document.getElementById('errorBanner');
|
||||
banner.style.display = 'block';
|
||||
banner.textContent = '⚠️ Error al cargar datos: ' + e.message;
|
||||
console.error(e);
|
||||
} finally {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
document.getElementById('refreshBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
// ── Month selector ─────────────────────────────────────────────────────────
|
||||
function buildMonthSelector() {
|
||||
const sel = document.getElementById('monthFilter');
|
||||
const months = Object.keys(DATA.monthlyDone).sort();
|
||||
sel.innerHTML = '<option value="all">Todos los meses</option>'
|
||||
+ months.map(m => `<option value="${m}">${monthLabel(m)}</option>`).join('');
|
||||
// Default: mostrar el mes más reciente
|
||||
if (months.length > 0) sel.value = months[months.length - 1];
|
||||
}
|
||||
// ── Sección 1: Estado Actual ───────────────────────────────────────────────
|
||||
function renderCurrentSection() {
|
||||
const ad = DATA.assigneeData;
|
||||
const people = Object.keys(ad).filter(p => ad[p].done + ad[p].in_progress + ad[p].todo > 0);
|
||||
// KPIs
|
||||
let ip = 0, todo = 0, done = 0, devs = 0;
|
||||
people.forEach(p => {
|
||||
ip += ad[p].in_progress;
|
||||
todo += ad[p].todo;
|
||||
done += ad[p].done;
|
||||
devs++;
|
||||
});
|
||||
document.getElementById('kpi-ip').textContent = ip;
|
||||
document.getElementById('kpi-todo').textContent = todo;
|
||||
document.getElementById('kpi-done-total').textContent = done;
|
||||
document.getElementById('kpi-members').textContent = devs;
|
||||
// Chart: solo en progreso
|
||||
{
|
||||
destroyChart('inProgress');
|
||||
const rows = people.filter(p => ad[p].in_progress > 0).sort((a,b) => ad[b].in_progress - ad[a].in_progress);
|
||||
charts['inProgress'] = new Chart(document.getElementById('chartInProgress'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: rows.map(shortName),
|
||||
datasets: [{ label: 'SP en progreso', data: rows.map(p => ad[p].in_progress),
|
||||
backgroundColor: rows.map(personColor), borderRadius: 5 }]
|
||||
},
|
||||
options: { responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ display:false } },
|
||||
scales:{ x:{ grid:{display:false}, ticks:{font:{size:11}} },
|
||||
y:{ beginAtZero:true, ticks:{font:{size:11}}, grid:{color:'#f0f2f5'} } } }
|
||||
});
|
||||
}
|
||||
// Chart: estado full
|
||||
{
|
||||
destroyChart('currentState');
|
||||
const rows = [...people].sort((a,b) => (ad[b].done+ad[b].in_progress)-(ad[a].done+ad[a].in_progress));
|
||||
charts['currentState'] = new Chart(document.getElementById('chartCurrentState'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: rows.map(shortName),
|
||||
datasets: [
|
||||
{ label:'Completados', data:rows.map(p=>ad[p].done), backgroundColor:'#36b37e', borderRadius:3 },
|
||||
{ label:'En Progreso', data:rows.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 },
|
||||
{ label:'Por Hacer', data:rows.map(p=>ad[p].todo), backgroundColor:'#ff8b00', borderRadius:3 },
|
||||
]
|
||||
},
|
||||
options:{ responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ position:'bottom', labels:{ font:{size:11} } } },
|
||||
scales:{ x:{stacked:true,grid:{display:false},ticks:{font:{size:11}}},
|
||||
y:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
|
||||
});
|
||||
}
|
||||
// Tabla estado actual
|
||||
const tbody = document.getElementById('tableCurrentState');
|
||||
tbody.innerHTML = '';
|
||||
const sorted = [...people].sort((a,b) => (ad[b].done+ad[b].in_progress)-(ad[a].done+ad[a].in_progress));
|
||||
sorted.forEach(p => {
|
||||
const d = ad[p], total = d.done + d.in_progress + d.todo;
|
||||
const dp = pct(d.done, total), ipp = pct(d.in_progress, total), tp = pct(d.todo, total);
|
||||
const c = personColor(p);
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
||||
<td><span class="pill done">${d.done} SP</span></td>
|
||||
<td><span class="pill progress">${d.in_progress} SP</span></td>
|
||||
<td><span class="pill todo">${d.todo} SP</span></td>
|
||||
<td><strong>${total}</strong></td>
|
||||
<td><div class="pbar-wrap"><div class="pbar-done" style="width:${dp}%"></div><div class="pbar-ip" style="width:${ipp}%"></div><div class="pbar-todo" style="width:${tp}%"></div></div></td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
// ── Sección 2: Historial ───────────────────────────────────────────────────
|
||||
function renderHistorySection() {
|
||||
buildVelocityChart();
|
||||
buildEpicChart();
|
||||
updateHistory();
|
||||
}
|
||||
function buildVelocityChart() {
|
||||
destroyChart('velocity');
|
||||
const months = Object.keys(DATA.monthlyDone).sort();
|
||||
const allDevs = [...new Set(months.flatMap(m => Object.keys(DATA.monthlyDone[m])))];
|
||||
charts['velocity'] = new Chart(document.getElementById('chartVelocity'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: months.map(monthLabel),
|
||||
datasets: allDevs.map((p, i) => ({
|
||||
label: shortName(p),
|
||||
data: months.map(m => DATA.monthlyDone[m][p] || 0),
|
||||
backgroundColor: COLORS[i % COLORS.length],
|
||||
borderRadius: 3,
|
||||
}))
|
||||
},
|
||||
options:{ responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ position:'bottom', labels:{ font:{size:10}, boxWidth:12 } } },
|
||||
scales:{ x:{stacked:true,grid:{display:false},ticks:{font:{size:11}}},
|
||||
y:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
|
||||
});
|
||||
}
|
||||
function buildEpicChart() {
|
||||
destroyChart('epic');
|
||||
const top = [...DATA.epicData]
|
||||
.sort((a,b) => (b.done+b.in_progress+b.todo)-(a.done+a.in_progress+a.todo))
|
||||
.slice(0, 15);
|
||||
charts['epic'] = new Chart(document.getElementById('chartEpic'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: top.map(e => e.name.length > 42 ? e.name.slice(0,40)+'…' : e.name),
|
||||
datasets: [
|
||||
{ label:'Completados', data:top.map(e=>e.done), backgroundColor:'#36b37e', borderRadius:3 },
|
||||
{ label:'En Progreso', data:top.map(e=>e.in_progress), backgroundColor:'#0065ff', borderRadius:3 },
|
||||
{ label:'Por Hacer', data:top.map(e=>e.todo), backgroundColor:'#ff8b00', borderRadius:3 },
|
||||
]
|
||||
},
|
||||
options:{ indexAxis:'y', responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ position:'bottom', labels:{ font:{size:11} } } },
|
||||
scales:{ x:{stacked:true,beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}},
|
||||
y:{stacked:true,grid:{display:false},ticks:{font:{size:11}}} } }
|
||||
});
|
||||
}
|
||||
function updateHistory() {
|
||||
if (!DATA) return;
|
||||
const month = document.getElementById('monthFilter').value;
|
||||
document.getElementById('filterBadge').textContent =
|
||||
month === 'all' ? 'Todos los meses' : monthLabel(month);
|
||||
// Aggregate
|
||||
let periodData = {};
|
||||
if (month === 'all') {
|
||||
Object.values(DATA.monthlyDone).forEach(md => {
|
||||
Object.entries(md).forEach(([p, sp]) => { periodData[p] = (periodData[p] || 0) + sp; });
|
||||
});
|
||||
} else {
|
||||
periodData = { ...(DATA.monthlyDone[month] || {}) };
|
||||
}
|
||||
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
|
||||
const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length;
|
||||
const avgSP = devCount > 0 ? Math.round(totalSP / devCount) : 0;
|
||||
document.getElementById('kpi-done-period').textContent = totalSP;
|
||||
document.getElementById('kpi-devs-period').textContent = devCount;
|
||||
document.getElementById('kpi-avg-period').textContent = avgSP;
|
||||
const lbl = month === 'all' ? 'todos los meses' : monthLabel(month);
|
||||
document.getElementById('kpi-done-period-sub').textContent = `SP completados · ${lbl}`;
|
||||
document.getElementById('subtitle-history-dev').textContent = `SP completados en ${lbl}`;
|
||||
document.getElementById('table-history-title').textContent =
|
||||
`SP Completados — ${month === 'all' ? 'Todos los meses' : monthLabel(month)}`;
|
||||
// Chart done by dev
|
||||
destroyChart('doneDev');
|
||||
const sorted = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
|
||||
charts['doneDev'] = new Chart(document.getElementById('chartDoneDev'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: sorted.map(([p]) => shortName(p)),
|
||||
datasets: [{ label:'SP completados', data:sorted.map(([,sp]) => sp),
|
||||
backgroundColor: sorted.map(([p]) => personColor(p)), borderRadius:5 }]
|
||||
},
|
||||
options:{ responsive:true, maintainAspectRatio:false,
|
||||
plugins:{ legend:{ display:false } },
|
||||
scales:{ x:{grid:{display:false},ticks:{font:{size:11}}},
|
||||
y:{beginAtZero:true,ticks:{font:{size:11}},grid:{color:'#f0f2f5'}} } }
|
||||
});
|
||||
// Tabla historial
|
||||
const tbody = document.getElementById('tableHistory');
|
||||
tbody.innerHTML = '';
|
||||
if (sorted.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="no-data">No hay datos para el período seleccionado</td></tr>';
|
||||
return;
|
||||
}
|
||||
sorted.forEach(([p, sp]) => {
|
||||
const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0;
|
||||
const c = personColor(p);
|
||||
tbody.innerHTML += `<tr>
|
||||
<td><div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div></td>
|
||||
<td><span class="pill done">${sp} SP</span></td>
|
||||
<td><div style="display:flex;align-items:center;gap:8px;">
|
||||
<div style="background:#f0f2f5;border-radius:4px;height:8px;width:120px;overflow:hidden;">
|
||||
<div style="background:#36b37e;height:100%;width:${p_}%"></div>
|
||||
</div>
|
||||
<span style="font-size:12px;color:#5e6c84;">${p_}%</span>
|
||||
</div></td>
|
||||
</tr>`;
|
||||
});
|
||||
}
|
||||
// ── Utils ──────────────────────────────────────────────────────────────────
|
||||
function pct(val, total) { return total > 0 ? (val / total * 100).toFixed(1) : 0; }
|
||||
// ── Auto-refresh cada 24 horas ────────────────────────────────────────────
|
||||
setInterval(() => loadData(false), 24 * 60 * 60 * 1000);
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
loadData();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* js/api.js
|
||||
* Centraliza la comunicación con el Backend (Vercel Serverless Function)
|
||||
*/
|
||||
|
||||
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') {
|
||||
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,282 @@
|
||||
/**
|
||||
* 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 = `<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 === '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)`; }
|
||||
|
||||
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)
|
||||
const people = Object.keys(ad).sort((a,b) => (ad[b].in_progress+ad[b].todo)-(ad[a].in_progress+ad[a].todo));
|
||||
chartsManager.drawBarChart('currentState', 'chartCurrentState', people.map(shortName), [
|
||||
{ label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 },
|
||||
{ label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:'#ff8b00', 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 = chartsManager.personColor(p);
|
||||
|
||||
const tooltipIP = d.issues_ip.map(i => `${i.key} (${i.sp} SP)`).join('\n');
|
||||
const tooltipTodo = d.issues_todo.map(i => `${i.key} (${i.sp} SP)`).join('\n');
|
||||
const allKeys = [...d.issues_ip, ...d.issues_todo].map(i =>
|
||||
`<a href="${getJiraUrl(i.key)}" target="_blank" class="jira-link-small" title="${i.summary}">${i.key}</a>`
|
||||
).join(' ');
|
||||
|
||||
tbody.innerHTML += `<tr>
|
||||
<td>
|
||||
<div class="name-cell"><div class="avatar" style="background:${c}">${initials(p)}</div>${p}</div>
|
||||
<div class="ticket-list-inline">${allKeys}</div>
|
||||
</td>
|
||||
<td><span class="pill progress" title="${tooltipIP}">${d.in_progress} SP</span></td>
|
||||
<td><span class="pill todo" title="${tooltipTodo}">${d.todo} SP</span></td>
|
||||
<td><strong>${total} SP</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 = {};
|
||||
Object.values(data.monthlyDone).forEach(md => {
|
||||
Object.entries(md).forEach(([p, sp]) => {
|
||||
if (state.selectedDev !== 'all' && p !== state.selectedDev) return;
|
||||
periodData[p] = (periodData[p] || 0) + sp;
|
||||
});
|
||||
});
|
||||
|
||||
const totalSP = Object.values(periodData).reduce((a,b) => a+b, 0);
|
||||
const devCount = Object.keys(periodData).filter(p => periodData[p] > 0).length;
|
||||
|
||||
document.getElementById('kpi-done-period').textContent = totalSP;
|
||||
document.getElementById('kpi-devs-period').textContent = devCount;
|
||||
document.getElementById('kpi-avg-period').textContent = devCount > 0 ? Math.round(totalSP / devCount) : 0;
|
||||
|
||||
// Gráfico de impacto personal (Terminados)
|
||||
const sorted = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]);
|
||||
chartsManager.drawBarChart('doneDev', 'chartDoneDev', sorted.map(([p]) => shortName(p)), [{
|
||||
label: 'SP entregados', data: sorted.map(([,sp]) => sp),
|
||||
backgroundColor: sorted.map(([p]) => chartsManager.personColor(p)), borderRadius: 5
|
||||
}], { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}} } });
|
||||
|
||||
// Tabla de Resultados
|
||||
const tbody = document.getElementById('tableHistory');
|
||||
tbody.innerHTML = '';
|
||||
if (sorted.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="no-data">No hay entregas en este período</td></tr>';
|
||||
} else {
|
||||
sorted.forEach(([p, sp]) => {
|
||||
const p_ = totalSP > 0 ? (sp / totalSP * 100).toFixed(1) : 0;
|
||||
const c = chartsManager.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 entregados</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>`;
|
||||
});
|
||||
}
|
||||
|
||||
// Gráfico de Velocidad Mensual
|
||||
const months = Object.keys(data.monthlyDone).sort();
|
||||
const allDevsInHistory = [...new Set(months.flatMap(m => Object.keys(data.monthlyDone[m])))];
|
||||
|
||||
// Si hay filtro de dev, solo mostrar ese dev en el gráfico de velocidad
|
||||
const visibleDevs = state.selectedDev === 'all' ? allDevsInHistory : [state.selectedDev];
|
||||
|
||||
chartsManager.drawBarChart('velocity', 'chartVelocity', months, visibleDevs.map(p => ({
|
||||
label: shortName(p),
|
||||
data: months.map(m => data.monthlyDone[m][p] || 0),
|
||||
backgroundColor: chartsManager.personColor(p), borderRadius: 3
|
||||
})), { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,6 +5,14 @@
|
||||
"src": "/api/(.*)",
|
||||
"dest": "/api/$1"
|
||||
},
|
||||
{
|
||||
"src": "/css/(.*)",
|
||||
"dest": "/css/$1"
|
||||
},
|
||||
{
|
||||
"src": "/js/(.*)",
|
||||
"dest": "/js/$1"
|
||||
},
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "/index.html"
|
||||
|
||||
Reference in New Issue
Block a user