nuevo dash

This commit is contained in:
kvilches1410
2026-03-31 16:57:26 -03:00
parent b4a0dfae24
commit 5467427a08
9 changed files with 820 additions and 732 deletions
+110 -289
View File
@@ -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.
+117 -60
View File
@@ -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}`;
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);
// 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}`;
}
}
const data = await res.json();
console.log(`✅ Se obtuvieron ${data.issues.length} issues (total en JIRA: ${data.total})`);
console.log(`📡 Consultando JIRA: ${config.jira.project} (${maxResultsWanted} resultados max, periodo: ${period})`);
console.log(`📋 JQL: ${dynamicJql}`);
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; // No more pages
}
}
// Si frenamos la paginación porque superamos el maxResultsWanted pero aún hay nextPageToken, está truncado
const isTruncated = !!nextPageToken;
return data;
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) });
}
}
// 2. Done sin SP
if (isDone && sp === 0) {
warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 });
}
if (isDone) {
epicMap[epicName].done += sp;
} else if (isInProgress) {
epicMap[epicName].in_progress += sp;
} else {
epicMap[epicName].todo += sp;
// 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é.`,
});
+33
View File
@@ -0,0 +1,33 @@
import { clearCache } from './data.js';
export default async (req, res) => {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST');
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Método no permitido. Usa POST.' });
}
// ── Validación de Secret ──────────────────────────────────────────────
const expectedSecret = process.env.WEBHOOK_SECRET;
const providedSecret = req.query.secret;
// Si hay un secret configurado en .env, exigimos que coincida
if (expectedSecret && providedSecret !== expectedSecret) {
console.warn('⚠️ Intento de Webhook con secret inválido.');
return res.status(401).json({ error: 'No autorizado / Secret inválido.' });
}
try {
console.log('🔔 Webhook recibido desde Jira!');
// Invocamos la limpieza de caché del módulo data.js
clearCache();
return res.status(200).json({ success: true, message: 'Caché invalidada exitosamente.' });
} catch (error) {
console.error('❌ Error manejando webhook:', error);
return res.status(500).json({ error: 'Error interno invalidando caché.' });
}
};
+126
View File
@@ -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
View File
@@ -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>
+31
View File
@@ -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;
}
}
};
+282
View File
@@ -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} } });
}
+47
View File
@@ -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,
}
});
}
};
+8
View File
@@ -5,6 +5,14 @@
"src": "/api/(.*)",
"dest": "/api/$1"
},
{
"src": "/css/(.*)",
"dest": "/css/$1"
},
{
"src": "/js/(.*)",
"dest": "/js/$1"
},
{
"src": "/(.*)",
"dest": "/index.html"