diff --git a/api/data.js b/api/data.js deleted file mode 100644 index 3d89aa9..0000000 --- a/api/data.js +++ /dev/null @@ -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(), - }); - } -}; diff --git a/js/api.js b/js/api.js index 64fe272..3f40c38 100644 --- a/js/api.js +++ b/js/api.js @@ -1,6 +1,6 @@ /** * 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 = { @@ -11,6 +11,7 @@ export const api = { * @param {boolean} forceRefresh - Forzar bypassing de la caché del backend */ async fetchDashboardData(forceRefresh = false, period = '90d') { + // Para Netlify Functions + Redirección en netlify.toml let url = `/api/data?period=${period}`; if (forceRefresh) url += '&refresh=1'; @@ -28,4 +29,4 @@ export const api = { throw e; } } -}; +}; \ No newline at end of file diff --git a/js/app.js b/js/app.js index 1e013ad..1ce4068 100644 --- a/js/app.js +++ b/js/app.js @@ -125,11 +125,12 @@ function renderHealthWarnings(warnings = []) { list.innerHTML = warnings.map(w => { let text = ''; let badge = w.type; - const link = `${w.key}`; + const link = w.key ? `${w.key}` : ''; 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)`; } + if(w.type === 'free_dev') { badge = 'DEV LIBRE'; text = `👤 ${w.summary}`; } return `
  • ${badge} ${text}
  • `; }).join(''); diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..610fc2d --- /dev/null +++ b/netlify.toml @@ -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" diff --git a/netlify/functions/data.js b/netlify/functions/data.js new file mode 100644 index 0000000..0c01c0d --- /dev/null +++ b/netlify/functions/data.js @@ -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 }) + }; + } +};