Nuevos graficos
This commit is contained in:
-392
@@ -1,392 +0,0 @@
|
|||||||
/**
|
|
||||||
* api/data.js — Vercel Serverless Function (Refactorizado)
|
|
||||||
*
|
|
||||||
* Consulta JIRA en tiempo real y devuelve datos procesados del dashboard.
|
|
||||||
*
|
|
||||||
* Usa: api/config.js para centralizar variables de entorno
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { config } from './config.js';
|
|
||||||
|
|
||||||
// ─── Cache en memoria ──────────────────────────────────────────────
|
|
||||||
let _cache = {};
|
|
||||||
let _cacheTime = {};
|
|
||||||
|
|
||||||
export function clearCache() {
|
|
||||||
_cache = {};
|
|
||||||
_cacheTime = {};
|
|
||||||
console.log('🧹 Caché en memoria limpiada');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── JIRA Fetch ────────────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Consulta JIRA API con Basic Auth
|
|
||||||
* @param {string} email - Email JIRA
|
|
||||||
* @param {string} token - API Token
|
|
||||||
* @returns {Promise<{issues: Array, total: number}>}
|
|
||||||
*/
|
|
||||||
async function fetchJiraIssues(email, token, 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;
|
|
||||||
|
|
||||||
// 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} (${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;
|
|
||||||
|
|
||||||
console.log(`✅ Se obtuvieron ${allIssues.length} issues (Truncado: ${isTruncated})`);
|
|
||||||
|
|
||||||
return { issues: allIssues, isTruncated: isTruncated };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Build Fields List ─────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Construye lista de campos a traer de JIRA
|
|
||||||
*/
|
|
||||||
function buildFieldsList() {
|
|
||||||
return [
|
|
||||||
'summary',
|
|
||||||
'status',
|
|
||||||
'assignee',
|
|
||||||
config.fields.spEstimate, // Story points estimate
|
|
||||||
config.fields.spFinal, // Story points finales
|
|
||||||
config.fields.developer, // Desarrollador (multi-user)
|
|
||||||
'resolutiondate',
|
|
||||||
'created',
|
|
||||||
'updated',
|
|
||||||
// ── QA Fields ──────────────────────────────────────
|
|
||||||
config.fields.qaAssignee, // QA asignado
|
|
||||||
config.fields.qaEntryRtqa, // Fecha entrada READY TO QA
|
|
||||||
config.fields.qaTotalTimeRtqa, // Tiempo total en READY TO QA (min)
|
|
||||||
config.fields.qaLastDurRtqa, // Última duración en READY TO QA (min)
|
|
||||||
config.fields.qaEntryQa, // Fecha entrada QA
|
|
||||||
config.fields.qaTotalTimeQa, // Tiempo total en QA (min)
|
|
||||||
config.fields.qaLastDurQa, // Última duración en QA (min)
|
|
||||||
config.fields.timesReturned, // Veces que volvió a En Curso
|
|
||||||
].join(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Process Issues ────────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Procesa issues de JIRA y agrupa los datos
|
|
||||||
* @param {Array} issues - Array de issues de JIRA
|
|
||||||
* @returns {Object} Datos procesados
|
|
||||||
*/
|
|
||||||
// Helper: convierte minutos a horas redondeadas a 1 decimal
|
|
||||||
function minsToHrs(mins) {
|
|
||||||
if (mins == null || mins === 0) return 0;
|
|
||||||
return Math.round((mins / 60) * 10) / 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
function processIssues(issues) {
|
|
||||||
const assigneeData = {};
|
|
||||||
const monthlyDone = {};
|
|
||||||
const issuesList = [];
|
|
||||||
const warnings = [];
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// ── QA Aggregation ──────────────────────────────────────────────────
|
|
||||||
const qaData = {
|
|
||||||
tickets: 0,
|
|
||||||
ticketsReturned: 0,
|
|
||||||
totalTimeQa: 0, // minutos
|
|
||||||
totalTimeRtqa: 0, // minutos
|
|
||||||
monthlyTotalQa: {}, // { 'YYYY-MM': totalMin }
|
|
||||||
monthlyCountQa: {}, // { 'YYYY-MM': count }
|
|
||||||
monthlyTotalRtqa: {},
|
|
||||||
monthlyCountRtqa: {},
|
|
||||||
issues: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
let processedCount = 0;
|
|
||||||
let skippedCount = 0;
|
|
||||||
|
|
||||||
for (const issue of issues) {
|
|
||||||
try {
|
|
||||||
const f = issue.fields;
|
|
||||||
|
|
||||||
// ── Extraer desarrolladores ────────────────────────────────
|
|
||||||
// Prioridad: customfield_10622 (multi) → assignee (single)
|
|
||||||
let devs = [];
|
|
||||||
|
|
||||||
if (f[config.fields.developer]?.length) {
|
|
||||||
// Multi-usuario
|
|
||||||
devs = f[config.fields.developer].map(d => d.displayName);
|
|
||||||
} else if (f.assignee) {
|
|
||||||
// Single usuario
|
|
||||||
devs = [f.assignee.displayName];
|
|
||||||
} else {
|
|
||||||
// Sin asignar
|
|
||||||
devs = ['Sin asignar'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Extraer Story Points ───────────────────────────────────
|
|
||||||
// Prioridad: SP Finales → SP Estimate
|
|
||||||
const sp = (f[config.fields.spFinal] != null
|
|
||||||
? f[config.fields.spFinal]
|
|
||||||
: f[config.fields.spEstimate]) ?? 0;
|
|
||||||
|
|
||||||
// ── Extraer Status ─────────────────────────────────────────
|
|
||||||
const statusKey = f.status?.statusCategory?.key ?? 'new';
|
|
||||||
const isDone = statusKey === 'done';
|
|
||||||
const isInProgress = statusKey === 'indeterminate';
|
|
||||||
|
|
||||||
// ── Extraer Fechas (YYYY-MM) ──────────────────────────────
|
|
||||||
const resMonth = f.resolutiondate
|
|
||||||
? f.resolutiondate.slice(0, 7)
|
|
||||||
: null;
|
|
||||||
const creMonth = f.created
|
|
||||||
? f.created.slice(0, 7)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// ── Acumular por Desarrollador ────────────────────────────
|
|
||||||
for (const dev of devs) {
|
|
||||||
if (!assigneeData[dev]) {
|
|
||||||
assigneeData[dev] = { done: 0, in_progress: 0, todo: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDone) {
|
|
||||||
assigneeData[dev].done += sp;
|
|
||||||
} else if (isInProgress) {
|
|
||||||
assigneeData[dev].in_progress += sp;
|
|
||||||
} else {
|
|
||||||
assigneeData[dev].todo += sp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Acumular completados por mes ───────────────────────────
|
|
||||||
if (isDone) {
|
|
||||||
const month = resMonth || creMonth;
|
|
||||||
if (month) {
|
|
||||||
if (!monthlyDone[month]) monthlyDone[month] = {};
|
|
||||||
for (const dev of devs) {
|
|
||||||
monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Generar issuesList para Filtros ───────────────────────
|
|
||||||
issuesList.push({
|
|
||||||
id: issue.id,
|
|
||||||
key: issue.key,
|
|
||||||
summary: f.summary,
|
|
||||||
status: statusKey,
|
|
||||||
sp: sp,
|
|
||||||
devs: devs,
|
|
||||||
creMonth
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Acumular datos de QA ────────────────────────────────────
|
|
||||||
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
|
|
||||||
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
|
|
||||||
const lastDurQa = f[config.fields.qaLastDurQa] ?? 0;
|
|
||||||
const timesRet = f[config.fields.timesReturned] ?? 0;
|
|
||||||
const qaMonth = resMonth || creMonth;
|
|
||||||
|
|
||||||
if (timeQaMin > 0) {
|
|
||||||
qaData.tickets++;
|
|
||||||
qaData.totalTimeQa += timeQaMin;
|
|
||||||
if (timesRet > 1) qaData.ticketsReturned++;
|
|
||||||
|
|
||||||
if (qaMonth) {
|
|
||||||
qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin;
|
|
||||||
qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
qaData.issues.push({
|
|
||||||
key: issue.key,
|
|
||||||
summary: f.summary,
|
|
||||||
timeQa: minsToHrs(timeQaMin),
|
|
||||||
timeRtqa: minsToHrs(timeRtqaMin),
|
|
||||||
lastDurQa: minsToHrs(lastDurQa),
|
|
||||||
timesReturned: timesRet,
|
|
||||||
status: statusKey,
|
|
||||||
resMonth: qaMonth,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeRtqaMin > 0 && qaMonth) {
|
|
||||||
qaData.totalTimeRtqa += timeRtqaMin;
|
|
||||||
qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin;
|
|
||||||
qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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++;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`⚠️ Error procesando issue ${issue.key}: ${error.message}`);
|
|
||||||
skippedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📊 Procesados: ${processedCount} | Saltados: ${skippedCount} | Warnings: ${warnings.length}`);
|
|
||||||
|
|
||||||
// ── Calcular promedios mensuales QA ─────────────────────────────────
|
|
||||||
const monthlyAvgQa = {};
|
|
||||||
for (const m of Object.keys(qaData.monthlyTotalQa)) {
|
|
||||||
monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]);
|
|
||||||
}
|
|
||||||
const monthlyAvgRtqa = {};
|
|
||||||
for (const m of Object.keys(qaData.monthlyTotalRtqa)) {
|
|
||||||
monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]);
|
|
||||||
}
|
|
||||||
qaData.monthlyAvgQa = monthlyAvgQa;
|
|
||||||
qaData.monthlyAvgRtqa = monthlyAvgRtqa;
|
|
||||||
qaData.avgTimeQa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0;
|
|
||||||
qaData.avgTimeRtqa = qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0;
|
|
||||||
|
|
||||||
// Limpiar campos internos de acumulación
|
|
||||||
delete qaData.monthlyTotalQa;
|
|
||||||
delete qaData.monthlyCountQa;
|
|
||||||
delete qaData.monthlyTotalRtqa;
|
|
||||||
delete qaData.monthlyCountRtqa;
|
|
||||||
|
|
||||||
console.log(`🧪 QA: ${qaData.tickets} tickets | ${qaData.ticketsReturned} devueltos | Avg QA: ${qaData.avgTimeQa}hs`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
totalIssues: processedCount,
|
|
||||||
skippedIssues: skippedCount,
|
|
||||||
assigneeData,
|
|
||||||
monthlyDone,
|
|
||||||
issuesList,
|
|
||||||
warnings,
|
|
||||||
qaData,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Handler ──────────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* Vercel Serverless Function Handler
|
|
||||||
*/
|
|
||||||
export default async (req, res) => {
|
|
||||||
// ── CORS ───────────────────────────────────────────────────────
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET');
|
|
||||||
|
|
||||||
// ── Caching ────────────────────────────────────────────────────
|
|
||||||
// CDN cache: 24hs fresco, sirve stale mientras recarga
|
|
||||||
res.setHeader('Cache-Control', 's-maxage=86400, stale-while-revalidate=3600');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Force refresh y Periodo temporal
|
|
||||||
const forceRefresh = req.query?.refresh === '1';
|
|
||||||
const period = req.query?.period || '90d';
|
|
||||||
|
|
||||||
// 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, period);
|
|
||||||
const data = processIssues(jiraData.issues);
|
|
||||||
|
|
||||||
// Agregar metadata
|
|
||||||
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: 1500,
|
|
||||||
cacheMinutes: Math.round(config.api.cacheMsec / 1000 / 60),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Guardar en caché segmentado
|
|
||||||
_cache[period] = data;
|
|
||||||
_cacheTime[period] = Date.now();
|
|
||||||
|
|
||||||
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é 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[period],
|
|
||||||
fromCache: true,
|
|
||||||
error: `Error al consultar JIRA: ${err.message}. Sirviendo datos en caché.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si no hay caché, retornar error
|
|
||||||
return res.status(500).json({
|
|
||||||
error: err.message,
|
|
||||||
hint: 'Verifica JIRA_EMAIL y JIRA_TOKEN en variables de entorno',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* js/api.js
|
* js/api.js
|
||||||
* Centraliza la comunicación con el Backend (Vercel Serverless Function)
|
* Centraliza la comunicación con el Backend (Netlify Functions)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -11,6 +11,7 @@ export const api = {
|
|||||||
* @param {boolean} forceRefresh - Forzar bypassing de la caché del backend
|
* @param {boolean} forceRefresh - Forzar bypassing de la caché del backend
|
||||||
*/
|
*/
|
||||||
async fetchDashboardData(forceRefresh = false, period = '90d') {
|
async fetchDashboardData(forceRefresh = false, period = '90d') {
|
||||||
|
// Para Netlify Functions + Redirección en netlify.toml
|
||||||
let url = `/api/data?period=${period}`;
|
let url = `/api/data?period=${period}`;
|
||||||
if (forceRefresh) url += '&refresh=1';
|
if (forceRefresh) url += '&refresh=1';
|
||||||
|
|
||||||
@@ -28,4 +29,4 @@ export const api = {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -125,11 +125,12 @@ function renderHealthWarnings(warnings = []) {
|
|||||||
list.innerHTML = warnings.map(w => {
|
list.innerHTML = warnings.map(w => {
|
||||||
let text = '';
|
let text = '';
|
||||||
let badge = w.type;
|
let badge = w.type;
|
||||||
const link = `<a href="${getJiraUrl(w.key)}" target="_blank" class="jira-link">${w.key}</a>`;
|
const link = w.key ? `<a href="${getJiraUrl(w.key)}" target="_blank" class="jira-link">${w.key}</a>` : '';
|
||||||
|
|
||||||
if(w.type === 'stalled') { badge = 'ESTANCADO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.devs.join(', ')})`; }
|
if(w.type === '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 === 'done_no_sp') { badge = 'SIN SP'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (Completado sin estimación)`; }
|
||||||
if(w.type === 'unassigned_sp') { badge = 'HUÉRFANO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.sp} SP sin dueño)`; }
|
if(w.type === 'unassigned_sp') { badge = 'HUÉRFANO'; text = `🎟️ [${link}] ${w.summary.slice(0,50)}... (${w.sp} SP sin dueño)`; }
|
||||||
|
if(w.type === 'free_dev') { badge = 'DEV LIBRE'; text = `👤 ${w.summary}`; }
|
||||||
|
|
||||||
return `<li class="warning-item" title="${w.summary}"><span class="badge" style="width:90px;text-align:center;">${badge}</span> <span class="title" style="font-size:11px;">${text}</span></li>`;
|
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('');
|
}).join('');
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Configuración de Netlify para Dashboard JIRA
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# Comando para compilar el proyecto (si usas un build tool)
|
||||||
|
# command = "npm run build"
|
||||||
|
# publish = "dist"
|
||||||
|
|
||||||
|
# Si NO tienes build tool, comenta lo anterior
|
||||||
|
|
||||||
|
# Redireccionar /api/* a las funciones Netlify
|
||||||
|
[[redirects]]
|
||||||
|
from = "/api/*"
|
||||||
|
to = "/.netlify/functions/:splat"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
# Redireccionar todas las demás rutas a index.html (SPA)
|
||||||
|
[[redirects]]
|
||||||
|
from = "/*"
|
||||||
|
to = "/index.html"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
# Configuración de funciones Netlify
|
||||||
|
[functions]
|
||||||
|
# Ruta donde están las funciones
|
||||||
|
directory = "netlify/functions"
|
||||||
|
# Bundler para Node.js
|
||||||
|
node_bundler = "esbuild"
|
||||||
|
|
||||||
|
# Headers para CORS y cache
|
||||||
|
[[headers]]
|
||||||
|
for = "/.netlify/functions/*"
|
||||||
|
[headers.values]
|
||||||
|
Access-Control-Allow-Origin = "*"
|
||||||
|
Access-Control-Allow-Methods = "GET, POST, OPTIONS"
|
||||||
|
Access-Control-Allow-Headers = "Content-Type"
|
||||||
|
Cache-Control = "s-maxage=3600, stale-while-revalidate=86400"
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* netlify/functions/data.js — Netlify Function (Convertido de Vercel)
|
||||||
|
*
|
||||||
|
* Consulta JIRA en tiempo real y devuelve datos procesados del dashboard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Configuración desde variables de entorno
|
||||||
|
const config = {
|
||||||
|
jira: {
|
||||||
|
host: process.env.JIRA_HOST || 'invera.atlassian.net',
|
||||||
|
project: process.env.JIRA_PROJECT || 'LABS',
|
||||||
|
email: process.env.JIRA_EMAIL,
|
||||||
|
token: process.env.JIRA_TOKEN,
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
spEstimate: process.env.JIRA_FIELD_SP_ESTIMATE || 'customfield_10016',
|
||||||
|
spFinal: process.env.JIRA_FIELD_SP_FINAL || 'customfield_11019',
|
||||||
|
developer: process.env.JIRA_FIELD_DEVELOPER || 'customfield_10622',
|
||||||
|
qaAssignee: 'customfield_10632',
|
||||||
|
qaEntryRtqa: 'customfield_10821',
|
||||||
|
qaTotalTimeRtqa: 'customfield_10822',
|
||||||
|
qaLastDurRtqa: 'customfield_10823',
|
||||||
|
qaEntryQa: 'customfield_10824',
|
||||||
|
qaTotalTimeQa: 'customfield_10825',
|
||||||
|
qaLastDurQa: 'customfield_10826',
|
||||||
|
timesReturned: 'customfield_11217',
|
||||||
|
},
|
||||||
|
jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND ${FIELD_SP_ESTIMATE} is not EMPTY ORDER BY updated DESC',
|
||||||
|
api: {
|
||||||
|
cacheMsec: parseInt(process.env.CACHE_MS || '3600000'), // 1 hora por defecto
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Cache en memoria (Netlify Functions pueden persistir entre llamadas calientes) ───
|
||||||
|
let _cache = {};
|
||||||
|
let _cacheTime = {};
|
||||||
|
|
||||||
|
// ─── JIRA Fetch ────────────────────────────────────────────────────
|
||||||
|
async function fetchJiraIssues(email, token, period) {
|
||||||
|
const auth = Buffer.from(`${email}:${token}`).toString('base64');
|
||||||
|
const maxResultsWanted = 1500;
|
||||||
|
let allIssues = [];
|
||||||
|
let startAt = 0;
|
||||||
|
|
||||||
|
let dynamicJql = config.jql
|
||||||
|
.replace('${PROJECT}', config.jira.project)
|
||||||
|
.replace('${FIELD_SP_ESTIMATE}', config.fields.spEstimate);
|
||||||
|
|
||||||
|
if (period !== 'all') {
|
||||||
|
const timeFilter = period === '1d' ? 'startOfDay()' : `-${period}`;
|
||||||
|
if (dynamicJql.includes(' ORDER BY ')) {
|
||||||
|
dynamicJql = dynamicJql.replace(' ORDER BY ', ` AND updated >= ${timeFilter} ORDER BY `);
|
||||||
|
} else {
|
||||||
|
dynamicJql += ` AND updated >= ${timeFilter}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📡 Consultando JIRA: ${config.jira.project} (periodo: ${period})`);
|
||||||
|
|
||||||
|
let nextPageToken = null;
|
||||||
|
const fieldsParam = encodeURIComponent(buildFieldsList());
|
||||||
|
const jqlParam = encodeURIComponent(dynamicJql);
|
||||||
|
|
||||||
|
while (allIssues.length < maxResultsWanted) {
|
||||||
|
let searchUrl = `https://${config.jira.host}/rest/api/3/search/jql?jql=${jqlParam}&fields=${fieldsParam}&maxResults=100`;
|
||||||
|
if (nextPageToken) {
|
||||||
|
searchUrl += `&nextPageToken=${encodeURIComponent(nextPageToken)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(searchUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${auth}`,
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`JIRA API ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const items = data.issues || [];
|
||||||
|
allIssues = allIssues.concat(items);
|
||||||
|
nextPageToken = data.nextPageToken;
|
||||||
|
|
||||||
|
if (!nextPageToken || items.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { issues: allIssues, isTruncated: !!nextPageToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldsList() {
|
||||||
|
return [
|
||||||
|
'summary', 'status', 'assignee', 'resolutiondate', 'created', 'updated',
|
||||||
|
config.fields.spEstimate, config.fields.spFinal, config.fields.developer,
|
||||||
|
config.fields.qaAssignee, config.fields.qaEntryRtqa, config.fields.qaTotalTimeRtqa,
|
||||||
|
config.fields.qaLastDurRtqa, config.fields.qaEntryQa, config.fields.qaTotalTimeQa,
|
||||||
|
config.fields.qaLastDurQa, config.fields.timesReturned,
|
||||||
|
].join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
function minsToHrs(mins) {
|
||||||
|
if (mins == null || mins === 0) return 0;
|
||||||
|
return Math.round((mins / 60) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processIssues(issues) {
|
||||||
|
const assigneeData = {};
|
||||||
|
const monthlyDone = {};
|
||||||
|
const issuesList = [];
|
||||||
|
const warnings = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const qaData = {
|
||||||
|
tickets: 0,
|
||||||
|
ticketsReturned: 0,
|
||||||
|
totalTimeQa: 0,
|
||||||
|
totalTimeRtqa: 0,
|
||||||
|
monthlyTotalQa: {},
|
||||||
|
monthlyCountQa: {},
|
||||||
|
monthlyTotalRtqa: {},
|
||||||
|
monthlyCountRtqa: {},
|
||||||
|
issues: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const issue of issues) {
|
||||||
|
try {
|
||||||
|
const f = issue.fields;
|
||||||
|
let devs = [];
|
||||||
|
if (f[config.fields.developer]?.length) {
|
||||||
|
devs = f[config.fields.developer].map(d => d.displayName);
|
||||||
|
} else if (f.assignee) {
|
||||||
|
devs = [f.assignee.displayName];
|
||||||
|
} else {
|
||||||
|
devs = ['Sin asignar'];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sp = (f[config.fields.spFinal] != null ? f[config.fields.spFinal] : f[config.fields.spEstimate]) ?? 0;
|
||||||
|
const statusKey = f.status?.statusCategory?.key ?? 'new';
|
||||||
|
const isDone = statusKey === 'done';
|
||||||
|
const isInProgress = statusKey === 'indeterminate';
|
||||||
|
const resMonth = f.resolutiondate ? f.resolutiondate.slice(0, 7) : null;
|
||||||
|
const creMonth = f.created ? f.created.slice(0, 7) : null;
|
||||||
|
|
||||||
|
for (const dev of devs) {
|
||||||
|
if (!assigneeData[dev]) assigneeData[dev] = { done: 0, in_progress: 0, todo: 0, pending_count: 0 };
|
||||||
|
if (isDone) {
|
||||||
|
assigneeData[dev].done += sp;
|
||||||
|
} else if (isInProgress) {
|
||||||
|
assigneeData[dev].in_progress += sp;
|
||||||
|
assigneeData[dev].pending_count++;
|
||||||
|
} else {
|
||||||
|
assigneeData[dev].todo += sp;
|
||||||
|
assigneeData[dev].pending_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDone) {
|
||||||
|
const month = resMonth || creMonth;
|
||||||
|
if (month) {
|
||||||
|
if (!monthlyDone[month]) monthlyDone[month] = {};
|
||||||
|
for (const dev of devs) {
|
||||||
|
monthlyDone[month][dev] = (monthlyDone[month][dev] ?? 0) + sp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp, devs, creMonth });
|
||||||
|
|
||||||
|
const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0;
|
||||||
|
const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0;
|
||||||
|
const timesRet = f[config.fields.timesReturned] ?? 0;
|
||||||
|
const qaMonth = resMonth || creMonth;
|
||||||
|
|
||||||
|
if (timeQaMin > 0) {
|
||||||
|
qaData.tickets++;
|
||||||
|
qaData.totalTimeQa += timeQaMin;
|
||||||
|
if (timesRet > 1) qaData.ticketsReturned++;
|
||||||
|
if (qaMonth) {
|
||||||
|
qaData.monthlyTotalQa[qaMonth] = (qaData.monthlyTotalQa[qaMonth] || 0) + timeQaMin;
|
||||||
|
qaData.monthlyCountQa[qaMonth] = (qaData.monthlyCountQa[qaMonth] || 0) + 1;
|
||||||
|
}
|
||||||
|
qaData.issues.push({
|
||||||
|
key: issue.key, summary: f.summary, timeQa: minsToHrs(timeQaMin), timeRtqa: minsToHrs(timeRtqaMin),
|
||||||
|
timesReturned: timesRet, status: statusKey, resMonth: qaMonth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeRtqaMin > 0 && qaMonth) {
|
||||||
|
qaData.totalTimeRtqa += timeRtqaMin;
|
||||||
|
qaData.monthlyTotalRtqa[qaMonth] = (qaData.monthlyTotalRtqa[qaMonth] || 0) + timeRtqaMin;
|
||||||
|
qaData.monthlyCountRtqa[qaMonth] = (qaData.monthlyCountRtqa[qaMonth] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && sp === 0) warnings.push({ type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 });
|
||||||
|
if (!isDone && devs.includes('Sin asignar') && sp > 0) warnings.push({ type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp });
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error procesando issue ${issue.key}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthlyAvgQa = {};
|
||||||
|
for (const m of Object.keys(qaData.monthlyTotalQa)) monthlyAvgQa[m] = minsToHrs(qaData.monthlyTotalQa[m] / qaData.monthlyCountQa[m]);
|
||||||
|
const monthlyAvgRtqa = {};
|
||||||
|
for (const m of Object.keys(qaData.monthlyTotalRtqa)) monthlyAvgRtqa[m] = minsToHrs(qaData.monthlyTotalRtqa[m] / qaData.monthlyCountRtqa[m]);
|
||||||
|
|
||||||
|
const EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca'];
|
||||||
|
|
||||||
|
for (const dev of Object.keys(assigneeData)) {
|
||||||
|
if (!EXCLUDED_FROM_ALERTS.includes(dev) && assigneeData[dev].pending_count === 0) {
|
||||||
|
warnings.push({ type: 'free_dev', summary: `${dev} no tiene tickets pendientes ni en curso`, devs: [dev] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
assigneeData, monthlyDone, issuesList, warnings,
|
||||||
|
qaData: {
|
||||||
|
...qaData, monthlyAvgQa, monthlyAvgRtqa,
|
||||||
|
avgTimeQa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeQa / qaData.tickets) : 0,
|
||||||
|
avgTimeRtqa: qaData.tickets > 0 ? minsToHrs(qaData.totalTimeRtqa / qaData.tickets) : 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Handler Netlify ───
|
||||||
|
exports.handler = async (event) => {
|
||||||
|
try {
|
||||||
|
const queryParams = event.queryStringParameters || {};
|
||||||
|
const forceRefresh = queryParams.refresh === '1';
|
||||||
|
const period = queryParams.period || '90d';
|
||||||
|
|
||||||
|
if (!forceRefresh && _cache[period] && (Date.now() - _cacheTime[period]) < config.api.cacheMsec) {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ..._cache[period], fromCache: true })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.jira.email || !config.jira.token) throw new Error('Faltan credenciales JIRA en variables de entorno');
|
||||||
|
|
||||||
|
const jiraData = await fetchJiraIssues(config.jira.email, config.jira.token, period);
|
||||||
|
const data = processIssues(jiraData.issues);
|
||||||
|
|
||||||
|
data.isTruncated = jiraData.isTruncated;
|
||||||
|
data.jiraTotal = jiraData.isTruncated ? "1500+" : jiraData.issues.length;
|
||||||
|
data.period = period;
|
||||||
|
|
||||||
|
_cache[period] = data;
|
||||||
|
_cacheTime[period] = Date.now();
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...data, fromCache: false })
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ error: err.message })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user