From 695401f237ac1bc85995aa20060dc412240aca2d Mon Sep 17 00:00:00 2001 From: "juan.beldomenico" Date: Mon, 27 Apr 2026 15:09:04 -0300 Subject: [PATCH] =?UTF-8?q?Migraci=C3=B3n=20a=20Python/Flask=20y=20adaptac?= =?UTF-8?q?i=C3=B3n=20para=20proyecto=20Infra?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reemplaza Netlify Functions con servidor Python/Flask (server.py) - Adapta dashboard para contar tickets en lugar de Story Points - Conecta al proyecto IO (Infra) de Jira - Elimina secciones de QA y Code Review - Agrega mΓ©tricas de solicitudes semanales y reporters - Excluye devs inactivos y unifica nombres duplicados Co-Authored-By: Claude Sonnet 4.6 --- debug_fields.py | 50 ++++ debug_output.json | 62 +++++ index.html | 233 ++---------------- js/app.js | 246 ++++--------------- netlify/functions/data.js | 50 ++-- requirements.txt | 3 + server.py | 494 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 711 insertions(+), 427 deletions(-) create mode 100644 debug_fields.py create mode 100644 debug_output.json create mode 100644 requirements.txt create mode 100644 server.py diff --git a/debug_fields.py b/debug_fields.py new file mode 100644 index 0000000..50bf308 --- /dev/null +++ b/debug_fields.py @@ -0,0 +1,50 @@ +""" +debug_fields.py β€” Lista todas las personas asignadas en el proyecto +""" + +import requests +import base64 +import os +from dotenv import load_dotenv + +load_dotenv() + +email = os.getenv('JIRA_EMAIL') +token = os.getenv('JIRA_TOKEN') +host = os.getenv('JIRA_HOST', 'invera.atlassian.net') +project = os.getenv('JIRA_PROJECT', 'Infra') + +credentials = base64.b64encode(f"{email}:{token}".encode()).decode() +headers = { 'Authorization': f'Basic {credentials}', 'Accept': 'application/json' } + +print(f"πŸ” Conectando a {host} β€” Proyecto: {project}\n") + +jql = f'project = {project} AND issuetype not in (Epic, "Sub-task", Subtask) AND created >= "2026-01-01" ORDER BY updated DESC' +url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=500&fields=summary,status,assignee" +res = requests.get(url, headers=headers) + +if not res.ok: + print(f"❌ Error {res.status_code}: {res.text[:300]}") + exit(1) + +issues = res.json().get('issues', []) +print(f" β†’ {len(issues)} tickets obtenidos\n") + +assignees = {} +for issue in issues: + f = issue['fields'] + name = (f.get('assignee') or {}).get('displayName', 'Sin asignar') + status_cat = (f.get('status') or {}).get('statusCategory', {}).get('key', '') + if name not in assignees: + assignees[name] = {'done': 0, 'pending': 0} + if status_cat == 'done': + assignees[name]['done'] += 1 + else: + assignees[name]['pending'] += 1 + +print(f"{'─'*60}") +print(f"πŸ‘₯ TODAS LAS PERSONAS β€” {project} (desde 2026-01-01):") +print(f"{'─'*60}") +for name, counts in sorted(assignees.items()): + print(f" {name:35s} β†’ {counts['done']:2} finalizados | {counts['pending']:2} pendientes") +print(f"{'─'*60}") diff --git a/debug_output.json b/debug_output.json new file mode 100644 index 0000000..bb56bdc --- /dev/null +++ b/debug_output.json @@ -0,0 +1,62 @@ +[ + { + "key": "IO-167", + "summary": "Pipeline - Configurar Bitbucket para ProducciΓ³n", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-166", + "summary": "ArgoCD - Actualizar ApplicationSet", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-165", + "summary": "Kubernetes & GitOps - Configurar prd-values.yaml", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-161", + "summary": "Documentar infraestructura y proceso de deployment", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-160", + "summary": "Ejecutar migraciones y deployment inicial", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-159", + "summary": "Configurar Ingress y DNS para dominio", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-158", + "summary": "Crear namespace y configurar RBAC en EKS", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-157", + "summary": "Configurar usuario readonly en RDS InvWallet", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-156", + "summary": " Crear ArgoCD Application para Dashboard", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + }, + { + "key": "IO-155", + "summary": "Configurar Bitbucket Pipeline en repositorio de la app", + "assignee": "Josefina Vera Ocampo", + "reporter": "Nelson ArgaΓ±araz" + } +] \ No newline at end of file diff --git a/index.html b/index.html index 0def4ba..b561031 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ -Dashboard LABS β€” Story Points +Dashboard Infra β€” Tickets @@ -23,9 +23,9 @@
-

πŸ“Š Dashboard LABS β€” Story Points

+

πŸ“Š Dashboard Infra

- Proyecto: LABS Β· invera.atlassian.net + Proyecto: Infra Β· invera.atlassian.net
@@ -59,14 +59,14 @@
-
β€”
SP En Progreso
Trabajo en curso
-
β€”
SP Por Hacer
Backlog del periodo
-
β€”
Devs con Carga
Con tickets pendientes
+
β€”
Tickets En Progreso
Trabajo en curso
+
β€”
Tickets Por Hacer
Backlog del periodo
+
β€”
DevOps
Con tickets pendientes
-

πŸ”΅ Pendientes por Desarrollador

+

πŸ”΅ Tkt pendientes

Story Points con trabajo pendiente
@@ -96,7 +96,7 @@

Detalle de Carga de Trabajo

- +
DesarrolladorπŸ”΅ En Progreso⏳ Por HacerTotal PendienteCarga Visual
DesarrolladorπŸ”΅ En Progreso⏳ Por HacerTotal PendienteDistribuciΓ³n
@@ -114,25 +114,20 @@
-
β€”
SP Completados
en el perΓ­odo
-
β€”
Desarrolladores
que completaron SP
-
β€”
SP promedio / dev
en el perΓ­odo
-
-
-
β€”
Promedio Lead Time
DΓ­as (CreaciΓ³n a Fin)
-
β€”
Promedio Cycle Time
DΓ­as (En Curso a Fin)
-
β€”
DesviaciΓ³n SP
Original vs Final
+
β€”
Tickets Completados
en el perΓ­odo
+
β€”
Promedio tkts / semana
tickets por semana
+
β€”
Tkts solicitados / semana
tickets por semana
-

βœ… SP Completados β€” por Desarrollador

+

βœ… Tickets Completados

PerΓ­odo seleccionado
-

πŸ“ˆ Velocidad HistΓ³rica

-
SP completados mes a mes
+

πŸ“₯ Solicitudes de Tickets

+
Tickets creados por persona en el perΓ­odo
@@ -143,7 +138,7 @@ - +
Desarrolladorβœ… SP Total🎟️ TicketsπŸ“‰ Media SP/Ticket% del impacto total vs Equipo
Desarrolladorβœ… Tickets TotalπŸ“‰ % del equipo
@@ -153,207 +148,9 @@ -
-
-

RevisiΓ³n de CΓ³digo (Code Review)

- MΓ©tricas de revisiΓ³n entre pares antes de QA -
- -
-
- -
-
-
β€”
-
En Cola Actual CR
-
β€” SP en espera
-
-
-
β€”
-
Tickets Atascados CR
-
Demorados actualmente (mΓ‘s de 2 dΓ­as)
-
-
- - -

MΓ©tricas del perΓ­odo seleccionado

-
-
-
β€”
-
Tickets en CR
-
Procesados en el perΓ­odo
-
-
-
β€”
-
-
Tiempo P50 CR -
-
Percentil 50 (Mediana)
El 50% de las revisiones de cΓ³digo se completan en esta cantidad de horas o menos.

A diferencia del promedio tradicional, el P50 muestra un valor "habitual" sin ser distorsionado por tickets que queden atascados durante meses.
-
-
-
-
Horas (Mediana)
-
-
-
β€”
-
-
Tiempos P90 y P99 -
-
MΓ©tricas de Cuello de Botella
Muestran los "peores escenarios" posibles.

P90: El 90% de los tickets logra pasar en este tiempo. El 10% restante demorΓ³ aΓΊn mΓ‘s.
P99: El 99% pasa en este tiempo. Ideal para medir los bloqueos mΓ‘ximos.
-
-
-
-
P99: β€” hs
-
-
- -
-
-

πŸ“ˆ Tiempo Promedio en Code Review β€” Mes a Mes

-
EvoluciΓ³n de la velocidad de revisiΓ³n de pares (horas)
-
-
-
-
-
- -
-
-

Control de Calidad (QA)

- MΓ©tricas del proceso de QA Β· Tiempos, colas y devoluciones -
- -
-
- - -
-
-
β€”
-
En Cola Actual RTQA
-
β€” SP esperando
-
-
-
β€”
-
En Cola Actual QA
-
β€” SP en prueba
-
-
- - -

MΓ©tricas del perΓ­odo seleccionado

-
-
-
β€”
-
Tickets QA
-
Procesados en el perΓ­odo
-
-
-
β€”
-
-
Tiempo P50 QA -
-
Percentil 50 (Mediana)
El 50% de las pruebas se completan en esta cantidad de horas netas o menos.
-
-
-
-
Horas en prueba
-
-
-
β€”
-
-
Tiempos P90 y P99 QA -
-
Cuellos de Botella QA
P90: El 90% logran probarse en este tiempo.
P99: El 99% logra terminar en este tiempo.
-
-
-
-
P99: β€” hs
-
-
- -
-
-
β€”
-
Tickets con Retrabajo
-
Sufrieron rechazos
-
-
-
β€”
-
-
Tiempo P50 RTQA -
-
Percentil 50 (Mediana)
El 50% de los tickets espera esta cantidad de horas (o menos) en la cola antes de ser tomado por QA.
-
-
-
-
Horas en cola
-
-
-
β€”
-
-
Tiempos P90 y P99 Cola -
-
Cuellos de Botella RTQA
Casos atascados sin tester asignado.

P90: El 90% de los tickets sale de la cola en este tiempo.
P99: Bloqueos mΓ‘ximos en la cola.
-
-
-
-
P99: β€” hs
-
-
- - -
-
-

πŸ“Š DistribuciΓ³n de Tiempos en QA

-
Rangos: <30min Β· 30min–2hs Β· 2–8hs Β· >8hs
-
-
-
-

πŸ”„ Flujo Directo vs Retrabajo

-
Tickets con 0 rechazos vs con devoluciones (CR/QA)
-
-
-
- - -
-
-

πŸ“ˆ Tiempo Promedio en QA β€” Mes a Mes

-
EvoluciΓ³n de la velocidad de revisiΓ³n (horas)
-
-
-
-

⏳ Tiempo en Cola RTQA β€” Mes a Mes

-
Horas promedio esperando ser tomado por QA
-
-
-
- - -
-

πŸ” Detalle de Tickets β€” QA

- - - - - - - - - - - - - -
TicketResumen⏱️ Dev (hs)⏱️ CR (hs)⏱️ QA (hs)πŸ”„ Veces devueltoEstado
-
- -
-
Dashboard en tiempo real Β· Arquitectura Modular Serverless Β· Realizado para Vercel & Jira
diff --git a/js/app.js b/js/app.js index 9c93d47..4eb8d14 100644 --- a/js/app.js +++ b/js/app.js @@ -70,8 +70,6 @@ async function loadAndRender(forceRefresh = false) { renderCurrentSection(); renderHistorySection(); - renderCRSection(); - renderQASection(); } catch(e) { errorBanner.style.display = 'block'; @@ -188,10 +186,17 @@ function renderCurrentSection() { } // 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)); + // "Sin asignar" siempre va ΓΊltimo y con color gris + const people = Object.keys(ad).sort((a, b) => { + if (a === 'Sin asignar') return 1; + if (b === 'Sin asignar') return -1; + return (ad[b].in_progress + ad[b].todo) - (ad[a].in_progress + ad[a].todo); + }); + const colorIP = people.map(p => p === 'Sin asignar' ? '#97a0af' : '#0065ff'); + const colorTodo = people.map(p => p === 'Sin asignar' ? '#c1c7d0' : '#ff8b00'); chartsManager.drawBarChart('currentState', 'chartCurrentState', people.map(shortName), [ - { label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:'#0065ff', borderRadius:3 }, - { label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:'#ff8b00', borderRadius:3 }, + { label:'En Progreso', data:people.map(p=>ad[p].in_progress), backgroundColor:colorIP, borderRadius:3 }, + { label:'Por Hacer', data:people.map(p=>ad[p].todo), backgroundColor:colorTodo, borderRadius:3 }, ], { plugins:{ legend:{ position:'bottom'} }, scales:{ x:{stacked:true,grid:{display:false}}, y:{stacked:true} } }); // Tabla detallada (Solo pendientes) @@ -199,10 +204,20 @@ function renderCurrentSection() { tbody.innerHTML = ''; people.forEach(p => { const d = ad[p], total = d.in_progress + d.todo; - const c = chartsManager.personColor(p); + const c = p === 'Sin asignar' ? '#97a0af' : chartsManager.personColor(p); - const popoverIP = d.issues_ip.map(i => `
${i.key}: ${i.summary.slice(0,30)}... (${i.sp} SP)
`).join(''); - const popoverTodo = d.issues_todo.map(i => `
${i.key}: ${i.summary.slice(0,30)}... (${i.sp} SP)
`).join(''); + const hasIPAlert = d.issues_ip.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled')); + const hasTodoAlert = d.issues_todo.some(i => i.warnings && i.warnings.some(w => w.type === 'expired' || w.type === 'stalled')); + + const genPopover = (issues) => issues.map(i => { + let badge = ''; + if(i.warnings && i.warnings.some(w => w.type === 'expired')) badge = '(Vencido)'; + else if(i.warnings && i.warnings.some(w => w.type === 'stalled')) badge = '(Estancado)'; + return `
${i.key} ${i.summary} (${i.sp} SP)${badge}
`; + }).join(''); + + const popoverIP = genPopover(d.issues_ip); + const popoverTodo = genPopover(d.issues_todo); tbody.innerHTML += ` @@ -210,17 +225,17 @@ function renderCurrentSection() {
- ${d.in_progress} SP + ${d.in_progress} tkts${hasIPAlert ? ' ⚠️' : ''}
${popoverIP || 'No hay tickets'}
- ${d.todo} SP + ${d.todo} tkts${hasTodoAlert ? ' ⚠️' : ''}
${popoverTodo || 'No hay tickets'}
- ${total} SP + ${total} tkts
@@ -266,9 +281,23 @@ function renderHistorySection() { labelEl.style.display = 'none'; } + // Calcular semanas del perΓ­odo seleccionado + const periodDays = state.selectedPeriod === 'all' ? 365 + : state.selectedPeriod === '1d' ? 1 + : parseInt(state.selectedPeriod.replace('d', '')); + const periodWeeks = Math.max(periodDays / 7, 1); + const totalTickets = Object.values(periodTicketCount).reduce((a,b) => a+b, 0); + const avgPerWeek = (totalTickets / periodWeeks).toFixed(1); + document.getElementById('kpi-done-period').textContent = totalSP; - document.getElementById('kpi-devs-period').textContent = devsWithActiveTickets; - document.getElementById('kpi-avg-period').textContent = devsWithActiveTickets > 0 ? Math.round(totalSP / devsWithActiveTickets) : 0; + document.getElementById('kpi-avg-period').textContent = avgPerWeek; + + // Tickets solicitados (creados) por semana + const ticketsCreated = data.ticketsCreated || 0; + const avgCreatedPerWeek = (ticketsCreated / periodWeeks).toFixed(1); + document.getElementById('kpi-created-week').textContent = avgCreatedPerWeek; + const createdSubEl = document.getElementById('kpi-created-week-sub'); + if (createdSubEl) createdSubEl.textContent = `${ticketsCreated} tkts en el perΓ­odo`; // GrΓ‘fico de impacto personal (Terminados) (solo mayores a 0) const sortedGraph = Object.entries(periodData).filter(([,sp]) => sp > 0).sort((a,b) => b[1]-a[1]); @@ -303,195 +332,26 @@ function renderHistorySection() { tbody.innerHTML += `
${initials(p)}
${p}
- ${sp} SP - ${tickets} tkts - ${avgPerTicket} SP/tkt + ${tickets} tkts ${badgeHtml} `; }); } - // Delivery KPIs - if (data.deliveryData) { - const { avgLeadTime, avgCycleTime, spOriginal, spFinal, spDeviationPct } = data.deliveryData; - document.getElementById('kpi-lead-time').textContent = avgLeadTime; - document.getElementById('kpi-cycle-time').textContent = avgCycleTime; - - const deviationPrefix = spDeviationPct > 0 ? '+' : ''; - document.getElementById('kpi-sp-deviation').textContent = `${deviationPrefix}${spDeviationPct}%`; - document.getElementById('kpi-sp-deviation-sub').textContent = `${spOriginal} orig. β†’ ${spFinal} fin.`; - } - // 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} } }); -} - -// ── CODE REVIEW SECTION ──────────────────────────────────────────────────── -function renderCRSection() { - const data = api.data; - if (!data || !data.crData) return; - - const cr = data.crData; - const cur = data.currentStates || { codeReview: {count:0, sp:0} }; - - document.getElementById('kpi-cr-queue-now').textContent = cur.codeReview.count; - document.getElementById('kpi-cr-queue-sp').textContent = cur.codeReview.sp + ' SP en espera'; - - document.getElementById('kpi-cr-tickets').textContent = cr.tickets ?? 'β€”'; - document.getElementById('kpi-cr-p50').textContent = cr.p50Cr != null ? cr.p50Cr + 'hs' : 'β€”'; - document.getElementById('kpi-cr-p90').textContent = cr.p90Cr != null ? cr.p90Cr + 'hs' : 'β€”'; - document.getElementById('kpi-cr-p99-sub').textContent = cr.p99Cr != null ? 'P99: ' + cr.p99Cr + ' hs' : 'P99: β€” hs'; - document.getElementById('kpi-cr-stalled').textContent = (cr.stalledIssues?.length) || '0'; - - const crMonths = Object.keys(cr.monthlyAvgCr || {}).sort(); - chartsManager.drawLineChart( - 'crMonthly', 'chartCrMonthly', - crMonths, + // GrΓ‘fico de Solicitudes por persona (reporter) + const reporterData = data.reporterData || {}; + const sortedReporters = Object.entries(reporterData).sort((a, b) => b[1] - a[1]); + chartsManager.drawBarChart('velocity', 'chartVelocity', + sortedReporters.map(([p]) => shortName(p)), [{ - label: 'Prom. tiempo en CR (hs)', - data: crMonths.map(m => cr.monthlyAvgCr[m] || 0), - borderColor: '#00b8d9', - backgroundColor: 'rgba(0,184,217,.12)', - fill: true, - pointBackgroundColor: '#00b8d9', - borderWidth: 2, + label: 'Tickets solicitados', + data: sortedReporters.map(([, v]) => v), + backgroundColor: sortedReporters.map(([p]) => chartsManager.personColor(p)), + borderRadius: 5 }], - { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } } + { plugins:{ legend:{ display:false } }, scales:{ x:{ grid:{ display:false } } } } ); } -// ── QA SECTION ───────────────────────────────────────────────────────────── -function renderQASection() { - const data = api.data; - if (!data || !data.qaData) return; - const qa = data.qaData; - const cur = data.currentStates || { readyToQa: {count:0, sp:0}, inQa: {count:0, sp:0} }; - - // ── COLAS ACTUALES ───────────────────────────────────────────────────────── - document.getElementById('kpi-qa-queue-now').textContent = cur.readyToQa.count; - document.getElementById('kpi-qa-queue-now-sp').textContent = cur.readyToQa.sp + ' SP esperando'; - document.getElementById('kpi-qa-active-now').textContent = cur.inQa.count; - document.getElementById('kpi-qa-active-now-sp').textContent = cur.inQa.sp + ' SP en prueba'; - - // ── KPIs DEL PERÍODO ────────────────────────────────────────────────────── - document.getElementById('kpi-qa-tickets').textContent = qa.tickets ?? 'β€”'; - document.getElementById('kpi-qa-returned').textContent = qa.ticketsReturned ?? 'β€”'; - - document.getElementById('kpi-qa-p50').textContent = qa.p50Qa != null ? qa.p50Qa + 'hs' : 'β€”'; - document.getElementById('kpi-qa-p90').textContent = qa.p90Qa != null ? qa.p90Qa + 'hs' : 'β€”'; - document.getElementById('kpi-qa-p99-sub').textContent = qa.p99Qa != null ? 'P99: ' + qa.p99Qa + ' hs' : 'P99: β€” hs'; - - document.getElementById('kpi-qa-rtqa-p50').textContent = qa.p50Rtqa != null ? qa.p50Rtqa + 'hs' : 'β€”'; - document.getElementById('kpi-qa-rtqa-p90').textContent = qa.p90Rtqa != null ? qa.p90Rtqa + 'hs' : 'β€”'; - document.getElementById('kpi-qa-rtqa-p99-sub').textContent = qa.p99Rtqa != null ? 'P99: ' + qa.p99Rtqa + ' hs' : 'P99: β€” hs'; - - const pctRet = qa.tickets > 0 ? ((qa.ticketsReturned / qa.tickets) * 100).toFixed(1) : 0; - document.getElementById('kpi-qa-returned-pct').textContent = - `${pctRet}% del total sufriΓ³ al menos 1 rechazo`; - - // ── GRÁFICO 1: DistribuciΓ³n de tiempos en QA (doughnut) ─────────────────── - const buckets = { '<30min': 0, '30min–2hs': 0, '2–8hs': 0, '>8hs': 0 }; - (qa.issues || []).forEach(i => { - const hs = i.timeQa; - if (hs < 0.5) buckets['<30min']++; - else if (hs < 2) buckets['30min–2hs']++; - else if (hs < 8) buckets['2–8hs']++; - else buckets['>8hs']++; - }); - chartsManager.drawDoughnutChart( - 'qaDist', 'chartQaDist', - Object.keys(buckets), - Object.values(buckets), - ['#36b37e', '#00b8d9', '#ff8b00', '#ff5630'] - ); - - // ── GRÁFICO 2: Devueltos vs Aprobados (doughnut) ────────────────────────── - const aprobados = qa.tickets - qa.ticketsReturned; - chartsManager.drawDoughnutChart( - 'qaReturned', 'chartQaReturned', - ['βœ… Flujo directo (0 rechazos)', 'πŸ”„ Retrabajo (Rechazo en CR/QA)'], - [aprobados, qa.ticketsReturned], - ['#36b37e', '#ff5630'] - ); - - // ── GRÁFICO 3: Tiempo promedio QA mes a mes (line) ──────────────────────── - const qaMonths = Object.keys(qa.monthlyAvgQa || {}).sort(); - chartsManager.drawLineChart( - 'qaMonthly', 'chartQaMonthly', - qaMonths, - [{ - label: 'Prom. tiempo en QA (hs)', - data: qaMonths.map(m => qa.monthlyAvgQa[m] || 0), - borderColor: '#6554c0', - backgroundColor: 'rgba(101,84,192,.12)', - fill: true, - pointBackgroundColor: '#6554c0', - borderWidth: 2, - }], - { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } } - ); - - // ── GRÁFICO 4: Tiempo en cola RTQA mes a mes (bar) ──────────────────────── - const rtqaMonths = Object.keys(qa.monthlyAvgRtqa || {}).sort(); - chartsManager.drawBarChart( - 'rtqaMonthly', 'chartRtqaMonthly', - rtqaMonths, - [{ - label: 'Prom. tiempo en cola RTQA (hs)', - data: rtqaMonths.map(m => qa.monthlyAvgRtqa[m] || 0), - backgroundColor: '#00b8d9', - borderRadius: 4, - }], - { plugins:{ legend:{ display:false } }, scales:{ x:{grid:{display:false}}, y:{ beginAtZero:true } } } - ); - - // ── TABLA de tickets QA ─────────────────────────────────────────────────── - const host = api.data?.config?.host || 'invera.atlassian.net'; - const tbody = document.getElementById('tableQA'); - tbody.innerHTML = ''; - - const sorted = [...(qa.issues || [])].sort((a, b) => b.timeQa - a.timeQa); - - if (sorted.length === 0) { - tbody.innerHTML = 'No hay tickets con datos de QA en este perΓ­odo'; - return; - } - - const top5 = sorted.slice(0, 5); - top5.forEach(i => { - const isCritical = i.timeQa > 4; - const isReturned = i.timesReturned > 1; - const jiraUrl = `https://${host}/browse/${i.key}`; - - const statusLabel = i.status === 'done' - ? 'βœ… Done' - : i.status === 'indeterminate' - ? 'πŸ”΅ En curso' - : '⏳ Pendiente'; - - const returnBadge = isReturned - ? `πŸ”„ ${i.timesReturned}x` - : `1x`; - - tbody.innerHTML += ` - ${i.key} - ${i.summary} - ${i.timeDev > 0 ? i.timeDev + 'hs' : 'β€”'} - ${i.timeCr > 0 ? i.timeCr + 'hs' : 'β€”'} - ${i.timeQa}hs${isCritical ? ' ⚠️' : ''} - ${returnBadge} - ${statusLabel} - `; - }); -} diff --git a/netlify/functions/data.js b/netlify/functions/data.js index f49519e..829006c 100644 --- a/netlify/functions/data.js +++ b/netlify/functions/data.js @@ -8,7 +8,7 @@ const config = { jira: { host: process.env.JIRA_HOST || 'invera.atlassian.net', - project: process.env.JIRA_PROJECT || 'LABS', + project: process.env.JIRA_PROJECT || 'Infra', email: process.env.JIRA_EMAIL, token: process.env.JIRA_TOKEN, }, @@ -30,7 +30,7 @@ const config = { enCursoEntry: 'customfield_10815', enCursoTotalTime: 'customfield_10816', }, - jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND ${FIELD_SP_ESTIMATE} is not EMPTY ORDER BY updated DESC', + jql: process.env.JIRA_JQL || 'project = ${PROJECT} AND issuetype not in (Epic, "Sub-task", Subtask) ORDER BY updated DESC', api: { cacheMsec: parseInt(process.env.CACHE_MS || '3600000'), // 1 hora por defecto }, @@ -244,7 +244,37 @@ function processIssues(issues, period = '90d') { } } - issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp, devs, creMonth }); + const issueWarnings = []; + + if (isInProgress && f.updated) { + const days = (now - new Date(f.updated)) / (1000 * 60 * 60 * 24); + if (days > 14) { + const w = { type: 'stalled', key: issue.key, summary: f.summary, devs, sp, days: Math.round(days) }; + warnings.push(w); + issueWarnings.push(w); + } + } + if (!isDone && f.duedate) { + const dueDate = new Date(f.duedate); + if (dueDate < now) { + const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24)); + const w = { type: 'expired', key: issue.key, summary: f.summary, devs, sp, days: daysOverdue }; + warnings.push(w); + issueWarnings.push(w); + } + } + if (isDone && sp === 0) { + const w = { type: 'done_no_sp', key: issue.key, summary: f.summary, devs, sp: 0 }; + warnings.push(w); + issueWarnings.push(w); + } + if (!isDone && devs.includes('Sin asignar') && sp > 0) { + const w = { type: 'unassigned_sp', key: issue.key, summary: f.summary, devs, sp }; + warnings.push(w); + issueWarnings.push(w); + } + + issuesList.push({ id: issue.id, key: issue.key, summary: f.summary, status: statusKey, sp, devs, creMonth, warnings: issueWarnings }); const timeQaMin = f[config.fields.qaTotalTimeQa] ?? 0; const timeRtqaMin = f[config.fields.qaTotalTimeRtqa] ?? 0; @@ -305,19 +335,7 @@ function processIssues(issues, period = '90d') { } } - 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 && f.duedate) { - const dueDate = new Date(f.duedate); - if (dueDate < now) { - const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24)); - warnings.push({ type: 'expired', key: issue.key, summary: f.summary, devs, sp, days: daysOverdue }); - } - } - 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 }); + // Las alertas de salud se generan y empujan arriba (junto con issueWarnings) } catch (e) { console.warn(`Error procesando issue ${issue.key}: ${e.message}`); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..431d009 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask +requests +python-dotenv diff --git a/server.py b/server.py new file mode 100644 index 0000000..de323bc --- /dev/null +++ b/server.py @@ -0,0 +1,494 @@ +""" +server.py β€” Servidor Flask para Dashboard LABS/Infra β€” Story Points +Reemplaza las Netlify Functions. Sirve el frontend y el endpoint /api/data +""" + +from flask import Flask, request, jsonify, send_from_directory +import requests +import base64 +import os +import time +import math +from datetime import datetime, timezone +from dotenv import load_dotenv + +# Cargar variables de entorno desde .env +load_dotenv() + +app = Flask(__name__, static_folder='.') + +# ─── ConfiguraciΓ³n ───────────────────────────────────────────────────────── +USE_TICKET_COUNT = os.getenv('USE_TICKET_COUNT', 'false').lower() == 'true' + +config = { + 'jira': { + 'host': os.getenv('JIRA_HOST', 'invera.atlassian.net'), + 'project': os.getenv('JIRA_PROJECT', 'IO'), + 'email': os.getenv('JIRA_EMAIL'), + 'token': os.getenv('JIRA_TOKEN'), + }, + 'fields': { + 'spEstimate': os.getenv('JIRA_FIELD_SP_ESTIMATE', 'customfield_10016'), + 'spFinal': os.getenv('JIRA_FIELD_SP_FINAL', 'customfield_11019'), + 'developer': os.getenv('JIRA_FIELD_DEVELOPER', 'customfield_10622'), + 'qaAssignee': 'customfield_10632', + 'qaEntryRtqa': 'customfield_10821', + 'qaTotalTimeRtqa': 'customfield_10822', + 'qaLastDurRtqa': 'customfield_10823', + 'qaEntryQa': 'customfield_10824', + 'qaTotalTimeQa': 'customfield_10825', + 'qaLastDurQa': 'customfield_10826', + 'timesReturned': 'customfield_11217', + 'spFinalReal': 'customfield_11216', + 'crEntry': 'customfield_10818', + 'crTotalTime': 'customfield_10819', + 'enCursoEntry': 'customfield_10815', + 'enCursoTotalTime':'customfield_10816', + }, + 'cacheMsec': int(os.getenv('CACHE_MS', 3600000)), +} + +# ─── Cache en memoria ────────────────────────────────────────────────────── +_cache = {} +_cache_time = {} + +# ─── Helpers ─────────────────────────────────────────────────────────────── +def mins_to_hrs(mins): + if not mins: + return 0 + return round((mins / 60) * 10) / 10 + +def get_percentile(sorted_arr, p): + if not sorted_arr: + return 0 + if p <= 0: + return sorted_arr[0] + if p >= 1: + return sorted_arr[-1] + index = (len(sorted_arr) - 1) * p + lower = math.floor(index) + upper = lower + 1 + weight = index % 1 + if upper >= len(sorted_arr): + return sorted_arr[lower] + return sorted_arr[lower] * (1 - weight) + sorted_arr[upper] * weight + +def build_fields_list(): + f = config['fields'] + return ','.join([ + 'summary', 'status', 'assignee', 'reporter', 'resolutiondate', 'created', 'updated', 'duedate', + f['spEstimate'], f['spFinal'], f['developer'], + f['qaAssignee'], f['qaEntryRtqa'], f['qaTotalTimeRtqa'], f['qaLastDurRtqa'], + f['qaEntryQa'], f['qaTotalTimeQa'], f['qaLastDurQa'], + f['timesReturned'], f['spFinalReal'], f['crEntry'], f['crTotalTime'], + f['enCursoEntry'], f['enCursoTotalTime'], + ]) + +# ─── Fetch de Jira ───────────────────────────────────────────────────────── +def fetch_jira_issues(period): + email = config['jira']['email'] + token = config['jira']['token'] + host = config['jira']['host'] + project = config['jira']['project'] + + credentials = base64.b64encode(f"{email}:{token}".encode()).decode() + headers = { + 'Authorization': f'Basic {credentials}', + 'Accept': 'application/json' + } + + max_results_wanted = 1500 + all_issues = [] + + from datetime import timedelta + jql = f'project = {project} ORDER BY updated DESC' + + print(f" β†’ Email: {email}") + print(f" β†’ Token: {token[:20] if token else 'NO TOKEN'}...") + print(f" β†’ JQL: {jql}") + + print(f"πŸ“‘ Consultando JIRA: {project} (periodo: {period})") + + fields_param = build_fields_list() + next_page_token = None + + while len(all_issues) < max_results_wanted: + url = f"https://{host}/rest/api/3/search/jql?jql={requests.utils.quote(jql)}&maxResults=100&fields=summary,status,assignee,reporter,resolutiondate,created,updated,duedate" + if next_page_token: + url += f"&nextPageToken={requests.utils.quote(next_page_token)}" + print(f" β†’ URL: {url[:200]}") + res = requests.get(url, headers=headers) + + print(f" β†’ HTTP {res.status_code} β€” respuesta: {res.text[:200]}") + + if not res.ok: + raise Exception(f"JIRA API {res.status_code}: {res.text[:200]}") + + data = res.json() + items = data.get('issues', []) + all_issues.extend(items) + next_page_token = data.get('nextPageToken') + + print(f" β†’ {len(items)} tickets en esta pΓ‘gina | Total acumulado: {len(all_issues)}") + + if not items or not next_page_token: + break + + print(f"βœ… Total tickets obtenidos de Jira: {len(all_issues)}") + return {'issues': all_issues, 'isTruncated': bool(next_page_token)} + +# ─── Procesamiento de Issues ─────────────────────────────────────────────── +def process_issues(issues, period='90d'): + f = config['fields'] + assignee_data = {} + monthly_done = {} + issues_list = [] + warnings = [] + now = datetime.now(timezone.utc) + + cutoff_date = None + if period != 'all': + period_days = 1 if period == '1d' else int(period.replace('d', '')) + if period == '1d': + cutoff_date = now.replace(hour=0, minute=0, second=0, microsecond=0) + else: + cutoff_date = datetime.fromtimestamp(time.time() - period_days * 86400, tz=timezone.utc) + + qa_data = { + 'tickets': 0, 'ticketsReturned': 0, 'totalTimeQa': 0, 'totalTimeRtqa': 0, + 'monthlyTotalQa': {}, 'monthlyCountQa': {}, + 'monthlyTotalRtqa': {}, 'monthlyCountRtqa': {}, + 'issues': [], 'timesQa': [], 'timesRtqa': [] + } + + tickets_created = 0 # Contador de tickets creados en el perΓ­odo + reporter_data = {} # Tickets solicitados por persona + + delivery_data = { + 'totalLeadTimeDays': 0, 'totalCycleTimeDays': 0, + 'doneTicketsLead': 0, 'doneTicketsCycle': 0, + 'doneSpOriginal': 0, 'doneSpFinal': 0 + } + + cr_data = { + 'tickets': 0, 'totalTimeCr': 0, 'issues': [], + 'monthlyTotalCr': {}, 'monthlyCountCr': {}, + 'stalledIssues': [], 'times': [] + } + + current_states = { + 'codeReview': {'count': 0, 'sp': 0}, + 'readyToQa': {'count': 0, 'sp': 0}, + 'inQa': {'count': 0, 'sp': 0}, + } + + INACTIVE_DEVS = ['Felipe Esposito', 'heiner', 'Ciro Bruno', 'Josefina Vera Ocampo', 'Isidro Emir Pelli'] + + # Normalizar nombres de usuario a nombre real + NAME_MAP = { + 'facundo.devoto': 'Devoto Facundo', + } + + for issue in issues: + try: + fields = issue['fields'] + + # Devs + devs = [] + if fields.get(f['developer']): + devs = [d['displayName'] for d in fields[f['developer']]] + elif fields.get('assignee'): + devs = [fields['assignee']['displayName']] + else: + devs = ['Sin asignar'] + + devs = [NAME_MAP.get(d, d) for d in devs] + devs = [d for d in devs if d not in INACTIVE_DEVS] + if not devs: + continue + + if USE_TICKET_COUNT: + sp = 1 + else: + sp = (fields.get(f['spFinal']) if fields.get(f['spFinal']) is not None + else fields.get(f['spEstimate'])) or 0 + + status_key = (fields.get('status') or {}).get('statusCategory', {}).get('key', 'new') + status_name = ((fields.get('status') or {}).get('name') or '').lower() + is_done = status_key == 'done' + is_in_progress = status_key == 'indeterminate' + + # Contar tickets creados dentro del perΓ­odo + reporter + if fields.get('created'): + created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) + if cutoff_date is None or created_dt >= cutoff_date: + tickets_created += 1 + reporter = (fields.get('reporter') or {}).get('displayName', 'Sin asignar') + reporter_data[reporter] = reporter_data.get(reporter, 0) + 1 + + # Filtrar done tickets fuera del perΓ­odo + if is_done and fields.get('resolutiondate') and cutoff_date: + res_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00')) + if res_date < cutoff_date: + continue + + # Estado actual (colas) + if not is_done: + if 'code review' in status_name or status_name == 'cr': + current_states['codeReview']['count'] += 1 + current_states['codeReview']['sp'] += sp + elif 'ready to qa' in status_name or status_name == 'listo para qa': + current_states['readyToQa']['count'] += 1 + current_states['readyToQa']['sp'] += sp + elif status_name == 'qa' or 'in qa' in status_name or status_name == 'en qa': + current_states['inQa']['count'] += 1 + current_states['inQa']['sp'] += sp + + res_month = fields['resolutiondate'][:7] if fields.get('resolutiondate') else None + cre_month = fields['created'][:7] if fields.get('created') else None + + for dev in devs: + if dev not in assignee_data: + assignee_data[dev] = {'done': 0, 'in_progress': 0, 'todo': 0, 'pending_count': 0} + if is_done: + assignee_data[dev]['done'] += sp + elif is_in_progress: + assignee_data[dev]['in_progress'] += sp + assignee_data[dev]['pending_count'] += 1 + else: + assignee_data[dev]['todo'] += sp + assignee_data[dev]['pending_count'] += 1 + + if is_done: + month = res_month or cre_month + if month: + if month not in monthly_done: + monthly_done[month] = {} + for dev in devs: + monthly_done[month][dev] = monthly_done[month].get(dev, 0) + sp + + # Delivery metrics + created_date = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None + resolved_date = datetime.fromisoformat(fields['resolutiondate'].replace('Z', '+00:00')) if fields.get('resolutiondate') else None + en_curso_date = datetime.fromisoformat(fields[f['enCursoEntry']].replace('Z', '+00:00')) if fields.get(f['enCursoEntry']) else None + sp_original = fields.get(f['spEstimate']) or 0 + sp_real = fields.get(f['spFinalReal']) if fields.get(f['spFinalReal']) is not None else sp_original + + if resolved_date: + if created_date: + delivery_data['totalLeadTimeDays'] += (resolved_date - created_date).total_seconds() / 86400 + delivery_data['doneTicketsLead'] += 1 + if en_curso_date: + delivery_data['totalCycleTimeDays'] += (resolved_date - en_curso_date).total_seconds() / 86400 + delivery_data['doneTicketsCycle'] += 1 + delivery_data['doneSpOriginal'] += sp_original + delivery_data['doneSpFinal'] += sp_real + + # Warnings + issue_warnings = [] + + if is_in_progress and fields.get('updated'): + updated = datetime.fromisoformat(fields['updated'].replace('Z', '+00:00')) + days = (now - updated).total_seconds() / 86400 + if days > 14: + w = {'type': 'stalled', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': round(days)} + warnings.append(w) + issue_warnings.append(w) + + if not is_done and fields.get('duedate'): + due_date = datetime.fromisoformat(fields['duedate'] + 'T00:00:00+00:00') + if due_date < now: + days_overdue = (now - due_date).days + w = {'type': 'expired', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp, 'days': days_overdue} + warnings.append(w) + issue_warnings.append(w) + + if is_done and sp == 0: + w = {'type': 'done_no_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': 0} + warnings.append(w) + issue_warnings.append(w) + + if not is_done and 'Sin asignar' in devs and sp > 0: + w = {'type': 'unassigned_sp', 'key': issue['key'], 'summary': fields['summary'], 'devs': devs, 'sp': sp} + warnings.append(w) + issue_warnings.append(w) + + issues_list.append({ + 'id': issue['id'], 'key': issue['key'], + 'summary': fields['summary'], 'status': status_key, + 'sp': sp, 'devs': devs, 'creMonth': cre_month, + 'warnings': issue_warnings + }) + + # QA & CR + time_qa_min = fields.get(f['qaTotalTimeQa']) or 0 + time_rtqa_min = fields.get(f['qaTotalTimeRtqa']) or 0 + time_cr_min = fields.get(f['crTotalTime']) or 0 + time_dev_min = fields.get(f['enCursoTotalTime']) or 0 + times_ret = fields.get(f['timesReturned']) or 0 + qa_month = res_month or cre_month + + # Fix datos sucios anteriores al 7-Apr-2026 + automation_fix_date = datetime(2026, 4, 7, tzinfo=timezone.utc) + issue_created_dt = datetime.fromisoformat(fields['created'].replace('Z', '+00:00')) if fields.get('created') else None + if issue_created_dt and issue_created_dt < automation_fix_date: + times_ret = 1 + + if time_qa_min > 0: + qa_data['tickets'] += 1 + qa_data['totalTimeQa'] += time_qa_min + qa_data['timesQa'].append(time_qa_min) + if times_ret > 1: + qa_data['ticketsReturned'] += 1 + if qa_month: + qa_data['monthlyTotalQa'][qa_month] = qa_data['monthlyTotalQa'].get(qa_month, 0) + time_qa_min + qa_data['monthlyCountQa'][qa_month] = qa_data['monthlyCountQa'].get(qa_month, 0) + 1 + qa_data['issues'].append({ + 'key': issue['key'], 'summary': fields['summary'], + 'timeQa': mins_to_hrs(time_qa_min), 'timeRtqa': mins_to_hrs(time_rtqa_min), + 'timesReturned': times_ret, 'status': status_key, 'resMonth': qa_month, + 'timeCr': mins_to_hrs(time_cr_min), 'timeDev': mins_to_hrs(time_dev_min) + }) + + if time_rtqa_min > 0: + qa_data['timesRtqa'].append(time_rtqa_min) + if qa_month: + qa_data['totalTimeRtqa'] += time_rtqa_min + qa_data['monthlyTotalRtqa'][qa_month] = qa_data['monthlyTotalRtqa'].get(qa_month, 0) + time_rtqa_min + qa_data['monthlyCountRtqa'][qa_month] = qa_data['monthlyCountRtqa'].get(qa_month, 0) + 1 + + if time_cr_min > 0: + cr_data['tickets'] += 1 + cr_data['totalTimeCr'] += time_cr_min + cr_data['times'].append(time_cr_min) + if qa_month: + cr_data['monthlyTotalCr'][qa_month] = cr_data['monthlyTotalCr'].get(qa_month, 0) + time_cr_min + cr_data['monthlyCountCr'][qa_month] = cr_data['monthlyCountCr'].get(qa_month, 0) + 1 + cr_data['issues'].append({'key': issue['key'], 'summary': fields['summary'], 'timeCr': mins_to_hrs(time_cr_min), 'status': status_key}) + + cr_entry_date = fields.get(f['crEntry']) + if cr_entry_date and not is_done and not fields.get(f['qaEntryRtqa']): + cr_entry_dt = datetime.fromisoformat(cr_entry_date.replace('Z', '+00:00')) + days_in_cr = (now - cr_entry_dt).total_seconds() / 86400 + if days_in_cr > 2: + cr_data['stalledIssues'].append({'key': issue['key'], 'summary': fields['summary'], 'days': round(days_in_cr * 10) / 10}) + + except Exception as e: + print(f"⚠️ Error procesando {issue.get('key', '?')}: {e}") + + # Alertas de devs libres + EXCLUDED_FROM_ALERTS = ['Sin asignar', 'Juan Manuel Villablanca'] + for dev, ad in assignee_data.items(): + if dev not in EXCLUDED_FROM_ALERTS and ad['pending_count'] == 0: + warnings.append({'type': 'free_dev', 'summary': f'{dev} no tiene tickets pendientes ni en curso', 'devs': [dev]}) + + # Promedios mensuales QA/RTQA/CR + monthly_avg_qa = {m: mins_to_hrs(qa_data['monthlyTotalQa'][m] / qa_data['monthlyCountQa'][m]) for m in qa_data['monthlyTotalQa']} + monthly_avg_rtqa = {m: mins_to_hrs(qa_data['monthlyTotalRtqa'][m] / qa_data['monthlyCountRtqa'][m]) for m in qa_data['monthlyTotalRtqa']} + monthly_avg_cr = {m: mins_to_hrs(cr_data['monthlyTotalCr'][m] / cr_data['monthlyCountCr'][m]) for m in cr_data['monthlyTotalCr']} + + # Percentiles + cr_sorted = sorted(cr_data['times']) + qa_sorted = sorted(qa_data['timesQa']) + rtqa_sorted = sorted(qa_data['timesRtqa']) + + avg_lead = round(delivery_data['totalLeadTimeDays'] / delivery_data['doneTicketsLead']) if delivery_data['doneTicketsLead'] else 0 + avg_cycle = round(delivery_data['totalCycleTimeDays'] / delivery_data['doneTicketsCycle']) if delivery_data['doneTicketsCycle'] else 0 + sp_orig = delivery_data['doneSpOriginal'] + sp_fin = delivery_data['doneSpFinal'] + sp_dev_pct = round((sp_fin - sp_orig) / sp_orig * 100, 1) if sp_orig else 0 + + return { + 'lastUpdated': datetime.now(timezone.utc).isoformat(), + 'assigneeData': assignee_data, + 'monthlyDone': monthly_done, + 'issuesList': issues_list, + 'warnings': warnings, + 'currentStates': current_states, + 'ticketsCreated': tickets_created, + 'reporterData': reporter_data, + 'deliveryData': { + 'avgLeadTime': avg_lead, + 'avgCycleTime': avg_cycle, + 'spOriginal': sp_orig, + 'spFinal': sp_fin, + 'spDeviationPct': sp_dev_pct, + }, + 'crData': { + **cr_data, + 'monthlyAvgCr': monthly_avg_cr, + 'p50Cr': mins_to_hrs(get_percentile(cr_sorted, 0.5)), + 'p90Cr': mins_to_hrs(get_percentile(cr_sorted, 0.9)), + 'p99Cr': mins_to_hrs(get_percentile(cr_sorted, 0.99)), + 'avgTimeCr': mins_to_hrs(cr_data['totalTimeCr'] / cr_data['tickets']) if cr_data['tickets'] else 0, + }, + 'qaData': { + **qa_data, + 'monthlyAvgQa': monthly_avg_qa, + 'monthlyAvgRtqa': monthly_avg_rtqa, + 'p50Qa': mins_to_hrs(get_percentile(qa_sorted, 0.5)), + 'p90Qa': mins_to_hrs(get_percentile(qa_sorted, 0.9)), + 'p99Qa': mins_to_hrs(get_percentile(qa_sorted, 0.99)), + 'p50Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.5)), + 'p90Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.9)), + 'p99Rtqa': mins_to_hrs(get_percentile(rtqa_sorted, 0.99)), + 'avgTimeQa': mins_to_hrs(qa_data['totalTimeQa'] / qa_data['tickets']) if qa_data['tickets'] else 0, + 'avgTimeRtqa': mins_to_hrs(qa_data['totalTimeRtqa'] / qa_data['tickets']) if qa_data['tickets'] else 0, + }, + 'config': {'host': config['jira']['host'], 'useTicketCount': USE_TICKET_COUNT}, + } + +# ─── Rutas Flask ─────────────────────────────────────────────────────────── + +@app.route('/api/data') +def api_data(): + try: + period = request.args.get('period', '90d') + force_refresh = request.args.get('refresh') == '1' + + cache_valid = ( + not force_refresh + and period in _cache + and (time.time() * 1000 - _cache_time.get(period, 0)) < config['cacheMsec'] + ) + + if cache_valid: + return jsonify({**_cache[period], 'fromCache': True}) + + if not config['jira']['email'] or not config['jira']['token']: + return jsonify({'error': 'Faltan credenciales JIRA en el archivo .env'}), 500 + + jira_result = fetch_jira_issues(period) + data = process_issues(jira_result['issues'], period) + + data['isTruncated'] = jira_result['isTruncated'] + data['jiraTotal'] = '1500+' if jira_result['isTruncated'] else len(jira_result['issues']) + data['period'] = period + data['fromCache'] = False + + _cache[period] = data + _cache_time[period] = time.time() * 1000 + + return jsonify(data) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +# Servir archivos estΓ‘ticos (CSS, JS) +@app.route('/css/') +def css(filename): + return send_from_directory('css', filename) + +@app.route('/js/') +def js(filename): + return send_from_directory('js', filename) + +@app.route('/') +def index(): + return send_from_directory('.', 'index.html') + + +if __name__ == '__main__': + print("πŸš€ Dashboard corriendo en http://localhost:8888") + app.run(host='0.0.0.0', port=8888, debug=True)