Compare commits

...

10 Commits

Author SHA1 Message Date
ricardo bf81b8e04b Fix: categoría bloqueo pm renombrada a Lambda 2026-05-29 23:28:39 -05:00
ricardo ad715a9409 DashboardView: integrar SCurveChart con métricas y curvas planificada/real 2026-05-29 23:28:18 -05:00
ricardo f09a249caa S-Curve + Blocker tracking: curva planificada/real, bloqueos categorizados, proyección
- services/blocker-log.ts: registro de bloqueos con categorías (cliente/pm/tech/external)
  Cada bloqueo: descripción, SP afectados, horas perdidas, categoría
- services/s-curve.ts: cálculo de curva S planificada (distribución lineal SP entre fechas)
  + curva real (desde status + fechas) + proyección (velocidad semanal)
  + métricas: SPI, velocity, blocked hours, estimated end date
- db.ts: schema v10 con tabla blockers (++id, projectId, category)
- components/SCurveChart.vue: gráfico SVG con curvas planificada (azul) y real (verde)
  + tarjetas de métricas (SP total, velocidad, SPI, proyección)
  + resumen de horas bloqueadas imputables al cliente
2026-05-29 23:27:50 -05:00
ricardo d8a5917bad Prompts: regla 1 SP = 1 hora en analysis_transcription y project_gap FASE 2 2026-05-29 23:19:59 -05:00
ricardo 0958d52fa9 Fix: status 6 es QA-Client no bloqueado + isDone() ampliado
- isBlocked(): removido '6' (QA-Client) de la lista de bloqueados
- isDone(): nuevo helper que incluye 6, 7, qa-client, ready to deploy como completados
- doneHUs y epicProgress ahora usan isDone() consistente
2026-05-29 22:55:57 -05:00
ricardo bbd367a266 Fix: mapeo de prioridad en Alpha (3=Alta, 2=Media, 1=Baja) 2026-05-29 22:54:02 -05:00
ricardo cb0e8067b6 K-12 Priorizador diario: HUs vencidas, hoy, esta semana y bloqueadas
- PrioritizerCard.vue: nuevo componente con secciones de priorización
- Tarjetas resumen: total HUs, en progreso, vencidas, bloqueadas
- Listas agrupadas: vencidas (rojo), hoy (ámbar), esta semana, bloqueadas
- Cada item: código, título, prioridad, fecha
- KappaUserStory: agregados end_date, sprint, initial_date
- enrichHU: pasa end_date y sprint al objeto enriquecido
2026-05-29 22:50:38 -05:00
ricardo 2073d936e2 Epics table: barra de progreso (% HUs completadas) + removida columna Asignado
- epicProgress computed: agrupa HUs por _epicCode, calcula % completadas
- getEpicProgress(epic): resuelve progreso para cada épica
- Barra visual con colores: verde=100%, azul=>0%, gris=0%
2026-05-29 22:38:52 -05:00
ricardo 3035351e6f Sistema de códigos jerárquicos 2-niveles + asignación determinista post-IA
- hierarchy.ts: Spike (S) agregado, buildHierarchyPath genera [E01-F04] (2 niveles)
  Legacy [E05-F04-U01] preservado (regex opcional 3er segmento)
- hierarchy-generator.ts (nuevo): analyzeExisting() computa contadores por épica+tipo
  assignEpicCodes() asigna E{max+1} secuencial
  assignItemCodes() asigna {epic}-{tipo}{n+1} a cada HU dentro de su épica
- project-analyzer.ts: post-procesa épicas y HUs con generador de códigos
  saveEpicDrafts usa epicCode en metadata y título con [E01]
- prompts-db.ts: prompt FASE 2 instruye a la IA a no generar códigos
- workitems.ts: EnrichedEpic._epicCode, EnrichedUserStory._epicCode/_itemCode
- DashboardView: muestra códigos en drafts y tabla de épicas
2026-05-29 18:13:17 -05:00
ricardo 9cf12b482f Análisis IA en dos fases: épicas primero, luego HUs vinculadas con epic_development
- project-analyzer.ts: dividido en analyzeProjectEpics() y analyzeProjectHUs()
  Fase 1: genera solo épicas con linkedHuTitles
  Fase 2: genera HUs dentro de épicas con epicName
- saveEpicDrafts / saveHUDrafts separados para cada tipo
- DashboardView: dos botones '1. Generar Épicas' y '2. Generar HUs'
- pushDraft épica: al crear en KAPPA, actualiza metadata de HUs vinculadas con epicDevelopment
- pushDraft HU: envía epic_development + payload completo (feature, sprint, asignado_a, etc.)
- project_gap prompt: instrucciones separadas para FASE 1 (épicas) y FASE 2 (HUs)
2026-05-29 17:33:47 -05:00
12 changed files with 1357 additions and 135 deletions
+220
View File
@@ -0,0 +1,220 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkItemsStore, type EnrichedUserStory } from '@/stores/workitems'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertTriangle, Calendar, Clock, ListChecks } from 'lucide-vue-next'
const { t } = useI18n()
const workItems = useWorkItemsStore()
const today = new Date()
today.setHours(0, 0, 0, 0)
function daysUntil(dateStr: string | undefined): number | null {
if (!dateStr) return null
const d = new Date(dateStr)
d.setHours(0, 0, 0, 0)
return Math.ceil((d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
}
function isOverdue(dateStr: string | undefined): boolean {
const d = daysUntil(dateStr)
return d !== null && d < 0
}
function isToday(dateStr: string | undefined): boolean {
const d = daysUntil(dateStr)
return d === 0
}
function isThisWeek(dateStr: string | undefined): boolean {
const d = daysUntil(dateStr)
return d !== null && d > 0 && d <= 7
}
function isNextWeek(dateStr: string | undefined): boolean {
const d = daysUntil(dateStr)
return d !== null && d > 7 && d <= 14
}
function isBlocked(hu: EnrichedUserStory): boolean {
const s = String(hu.status ?? '').toLowerCase()
return ['blocked', 'bloqueado'].includes(s)
}
function isInProgress(hu: EnrichedUserStory): boolean {
const s = String(hu.status ?? '').toLowerCase()
return ['in_progress', 'doing', 'wip', 'active', 'in progress', 'en progreso', 'true', '2'].includes(s)
}
function isDone(hu: EnrichedUserStory): boolean {
const s = String(hu.status ?? '').toLowerCase()
return ['done', 'completed', 'closed', 'finalizado', '5', '6', '7', 'qa-client', 'ready to deploy'].includes(s)
}
// Agrupar HUs
const overdueHUs = computed(() =>
workItems.userStories.filter(hu => hu.end_date && isOverdue(hu.end_date))
)
const todayHUs = computed(() =>
workItems.userStories.filter(hu => hu.end_date && isToday(hu.end_date))
)
const thisWeekHUs = computed(() =>
workItems.userStories.filter(hu => hu.end_date && isThisWeek(hu.end_date))
)
const nextWeekHUs = computed(() =>
workItems.userStories.filter(hu => hu.end_date && isNextWeek(hu.end_date))
)
const blockedHUs = computed(() =>
workItems.userStories.filter(hu => isBlocked(hu))
)
const inProgressHUs = computed(() =>
workItems.userStories.filter(hu => isInProgress(hu))
)
function formatDate(dateStr: string | undefined): string {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('es-CO', { day: 'numeric', month: 'short' })
}
function priorityVariant(p: unknown) {
const s = String(p ?? '').toLowerCase().trim()
if (['alta', 'high', 'critical', 'urgente', '1'].includes(s)) return 'destructive'
if (['media', 'medium', 'normal', '2'].includes(s)) return 'default'
if (['baja', 'low', '3'].includes(s)) return 'secondary'
return 'outline'
}
const totalHUs = computed(() => workItems.userStories.length)
const doneHUs = computed(() =>
workItems.userStories.filter(hu => isDone(hu)).length
)
</script>
<template>
<div class="space-y-4">
<!-- Summary cards -->
<div class="grid gap-3 @xl:grid-cols-4">
<Card class="bg-gradient-to-t from-primary/5 to-card shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xs font-medium">{{ t('dashboard.hus') }}</CardTitle>
<ListChecks class="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-xl font-bold">{{ totalHUs }}</div>
<p class="text-[10px] text-muted-foreground">{{ doneHUs }} completadas ({{ totalHUs > 0 ? Math.round(doneHUs / totalHUs * 100) : 0 }}%)</p>
</CardContent>
</Card>
<Card class="bg-gradient-to-t from-amber-500/5 to-card shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xs font-medium">En progreso</CardTitle>
<Clock class="size-3.5 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-xl font-bold">{{ inProgressHUs.length }}</div>
<p class="text-[10px] text-muted-foreground">activas</p>
</CardContent>
</Card>
<Card class="bg-gradient-to-t from-red-500/5 to-card shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xs font-medium">Vencidas</CardTitle>
<AlertTriangle class="size-3.5 text-red-500" />
</CardHeader>
<CardContent>
<div class="text-xl font-bold text-red-500">{{ overdueHUs.length }}</div>
<p class="text-[10px] text-muted-foreground">requieren atención</p>
</CardContent>
</Card>
<Card class="bg-gradient-to-t from-purple-500/5 to-card shadow-xs">
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-xs font-medium">Bloqueadas</CardTitle>
<AlertTriangle class="size-3.5 text-purple-500" />
</CardHeader>
<CardContent>
<div class="text-xl font-bold text-purple-500">{{ blockedHUs.length }}</div>
<p class="text-[10px] text-muted-foreground">con impedimentos</p>
</CardContent>
</Card>
</div>
<!-- Overdue -->
<Card v-if="overdueHUs.length > 0" class="border-l-3 border-l-red-500">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium flex items-center gap-2 text-red-600">
<AlertTriangle class="size-3.5" />
Vencidas ({{ overdueHUs.length }})
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<div v-for="hu in overdueHUs" :key="hu.id" class="flex items-center gap-2 text-xs py-1 border-b last:border-0">
<span class="font-mono text-[10px] text-muted-foreground w-16">{{ hu.code || `#${hu.id}` }}</span>
<span class="flex-1 truncate">{{ hu._cleanTitle || hu.title }}</span>
<Badge :variant="priorityVariant(hu.priority)" class="text-[10px]">{{ hu.priority }}</Badge>
<span class="text-red-500 font-mono text-[10px] w-16 text-right">{{ formatDate(hu.end_date) }}</span>
</div>
</CardContent>
</Card>
<!-- Today -->
<Card v-if="todayHUs.length > 0" class="border-l-3 border-l-amber-500">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium flex items-center gap-2">
<Calendar class="size-3.5" />
Hoy ({{ todayHUs.length }})
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<div v-for="hu in todayHUs" :key="hu.id" class="flex items-center gap-2 text-xs py-1 border-b last:border-0">
<span class="font-mono text-[10px] text-muted-foreground w-16">{{ hu.code || `#${hu.id}` }}</span>
<span class="flex-1 truncate">{{ hu._cleanTitle || hu.title }}</span>
<Badge :variant="priorityVariant(hu.priority)" class="text-[10px]">{{ hu.priority }}</Badge>
<Badge variant="outline" class="text-[10px]">Hoy</Badge>
</div>
</CardContent>
</Card>
<!-- This week -->
<Card v-if="thisWeekHUs.length > 0">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium flex items-center gap-2">
<Calendar class="size-3.5" />
Esta semana ({{ thisWeekHUs.length }})
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<div v-for="hu in thisWeekHUs" :key="hu.id" class="flex items-center gap-2 text-xs py-1 border-b last:border-0">
<span class="font-mono text-[10px] text-muted-foreground w-16">{{ hu.code || `#${hu.id}` }}</span>
<span class="flex-1 truncate">{{ hu._cleanTitle || hu.title }}</span>
<Badge :variant="priorityVariant(hu.priority)" class="text-[10px]">{{ hu.priority }}</Badge>
<span class="font-mono text-[10px] text-muted-foreground w-16 text-right">{{ formatDate(hu.end_date) }}</span>
</div>
</CardContent>
</Card>
<!-- Blocked -->
<Card v-if="blockedHUs.length > 0" class="border-l-3 border-l-purple-500">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium flex items-center gap-2 text-purple-600">
<AlertTriangle class="size-3.5" />
Bloqueadas ({{ blockedHUs.length }})
</CardTitle>
</CardHeader>
<CardContent class="space-y-1">
<div v-for="hu in blockedHUs" :key="hu.id" class="flex items-center gap-2 text-xs py-1 border-b last:border-0">
<span class="font-mono text-[10px] text-muted-foreground w-16">{{ hu.code || `#${hu.id}` }}</span>
<span class="flex-1 truncate">{{ hu._cleanTitle || hu.title }}</span>
<Badge variant="destructive" class="text-[10px]">Bloqueada</Badge>
<Badge v-if="hu.has_impairment" variant="outline" class="text-[10px]">Impedimento</Badge>
</div>
</CardContent>
</Card>
</div>
</template>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useWorkItemsStore } from '@/stores/workitems'
import { calculateSCurve, type CurvePoint, type SCurveMetrics, type SCurveData } from '@/services/s-curve'
import { getBlockers, getBlockerStats } from '@/services/blocker-log'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { TrendingUp, AlertTriangle } from 'lucide-vue-next'
const props = defineProps<{ projectId: number }>()
const workItems = useWorkItemsStore()
const loading = ref(true)
const sCurve = ref<SCurveData | null>(null)
async function load() {
loading.value = true
try {
const blockers = await getBlockers(props.projectId)
sCurve.value = calculateSCurve(workItems.userStories, blockers)
} catch (e) {
console.error('[Alpha] S-Curve error:', e)
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => workItems.userStories.length, load)
const CHART_W = 600
const CHART_H = 250
const PAD = { top: 20, right: 20, bottom: 35, left: 50 }
function buildPath(points: CurvePoint[], maxSp: number, totalDays: number): string {
if (points.length === 0) return ''
const w = CHART_W - PAD.left - PAD.right
const h = CHART_H - PAD.top - PAD.bottom
const firstDate = points[0].date
return points.map((p, i) => {
const first = new Date(firstDate)
const cur = new Date(p.date)
const dx = Math.max(0, Math.ceil((cur.getTime() - first.getTime()) / (1000 * 60 * 60 * 24)))
const x = PAD.left + (dx / totalDays) * w
const y = PAD.top + h - (p.cumulative / maxSp) * h
return `${i === 0 ? 'M' : 'L'}${x},${y}`
}).join(' ')
}
const planned = computed(() => sCurve.value?.planned || [])
const actual = computed(() => sCurve.value?.actual || [])
const metrics = computed(() => sCurve.value?.metrics)
const maxSp = computed(() => {
const all = [...planned.value, ...actual.value]
return Math.max(...all.map(p => p.cumulative), 1)
})
const totalDays = computed(() => {
const all = [...planned.value, ...actual.value]
if (all.length < 2) return 30
const first = new Date(all[0].date)
const last = new Date(all[all.length - 1].date)
return Math.max(1, Math.ceil((last.getTime() - first.getTime()) / (1000 * 60 * 60 * 24)))
})
</script>
<template>
<Card id="scurve-chart">
<CardHeader class="pb-3">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<TrendingUp class="size-4" />
Curva S del proyecto
</CardTitle>
</CardHeader>
<CardContent>
<div v-if="loading" class="text-xs text-muted-foreground text-center py-8">Cargando...</div>
<div v-else-if="planned.length === 0" class="text-xs text-muted-foreground text-center py-8">
No hay suficientes datos. Se necesitan HUs con story points y fechas.
</div>
<div v-else class="space-y-4">
<!-- Metrics row -->
<div v-if="metrics" class="grid grid-cols-4 gap-2 text-xs">
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">SP total</span>
<p class="font-bold text-sm">{{ metrics.totalSpPlanned }}</p>
</div>
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">Velocidad/sem</span>
<p class="font-bold text-sm">{{ metrics.velocityPerWeek }} SP</p>
</div>
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">SPI</span>
<p class="font-bold text-sm" :class="metrics.spi >= 1 ? 'text-green-500' : 'text-red-500'">
{{ metrics.spi }}
</p>
</div>
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">Proyección</span>
<p class="font-bold text-sm">{{ metrics.estimatedEndDate || '—' }}</p>
</div>
</div>
<!-- Blockers summary -->
<div v-if="metrics && metrics.totalBlockedHours > 0" class="flex items-center gap-2 text-xs p-2 rounded-lg bg-orange-500/5 border border-orange-500/20">
<AlertTriangle class="size-3.5 text-orange-500 shrink-0" />
<span class="text-muted-foreground">
<strong class="text-orange-600">{{ metrics.totalBlockedHours }}h</strong> bloqueadas
(<strong class="text-orange-600">{{ metrics.clientBlockedHours }}h</strong> imputables al cliente)
</span>
</div>
<!-- SVG Chart -->
<svg :viewBox="`0 0 ${CHART_W} ${CHART_H}`" class="w-full h-auto">
<line v-for="i in 5" :key="i"
:x1="PAD.left" :y1="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom)"
:x2="CHART_W - PAD.right" :y2="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom)"
stroke="currentColor" stroke-opacity="0.1" stroke-dasharray="4,4"
/>
<path :d="buildPath(planned, maxSp, totalDays)" fill="none" stroke="#3b82f6" stroke-width="2" />
<path :d="buildPath(actual, maxSp, totalDays)" fill="none" stroke="#22c55e" stroke-width="2" />
<text v-for="i in 5" :key="'y'+i"
:x="PAD.left - 8"
:y="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom) + 4"
text-anchor="end" class="fill-muted-foreground text-[10px]"
>{{ Math.round(maxSp * (i-1) / 4) }}</text>
<text :x="PAD.left" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
{{ planned[0]?.date?.slice(5) || '' }}
</text>
<text :x="(CHART_W - PAD.right + PAD.left) / 2" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
{{ planned[Math.floor(planned.length/2)]?.date?.slice(5) || '' }}
</text>
<text :x="CHART_W - PAD.right" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
{{ planned[planned.length - 1]?.date?.slice(5) || '' }}
</text>
<line x1="20" y1="12" x2="40" y2="12" stroke="#3b82f6" stroke-width="2" />
<text x="45" y="16" class="fill-muted-foreground text-[9px]">Plan</text>
<line x1="90" y1="12" x2="110" y2="12" stroke="#22c55e" stroke-width="2" />
<text x="115" y="16" class="fill-muted-foreground text-[9px]">Real</text>
</svg>
</div>
</CardContent>
</Card>
</template>
+107
View File
@@ -0,0 +1,107 @@
import db from '@/services/db'
export interface BlockerRecord {
id?: number
projectId: number
huId?: number
huTitle?: string
date: string
category: string
description: string
spAffected: number
resolvedAt?: string
resolution?: string
timeLostHours: number
createdBy?: string
createdAt: string
}
export type BlockerCategory = 'client' | 'pm' | 'tech' | 'external' | 'other'
const CATEGORY_LABELS: Record<BlockerCategory, string> = {
client: 'Cliente',
pm: 'Lambda',
tech: 'Técnico',
external: 'Terceros',
other: 'Otro',
}
const CATEGORY_COLORS: Record<BlockerCategory, string> = {
client: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400',
pm: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400',
tech: 'text-red-600 bg-red-100 dark:bg-red-900/30 dark:text-red-400',
external: 'text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-400',
other: 'text-gray-600 bg-gray-100 dark:bg-gray-900/30 dark:text-gray-400',
}
export function getBlockerCategoryLabel(cat: BlockerCategory): string {
return CATEGORY_LABELS[cat] || cat
}
export function getBlockerCategoryColor(cat: BlockerCategory): string {
return CATEGORY_COLORS[cat] || ''
}
export const BLOCKER_CATEGORIES: BlockerCategory[] = ['client', 'pm', 'tech', 'external', 'other']
export async function saveBlocker(blocker: BlockerRecord): Promise<number> {
if (blocker.id) {
await (db as any).table('blockers').put(blocker)
return blocker.id
}
return (db as any).table('blockers').add(blocker)
}
export async function getBlockers(projectId: number): Promise<BlockerRecord[]> {
return (db as any).table('blockers').where('projectId').equals(projectId).toArray()
}
export async function getActiveBlockers(projectId: number): Promise<BlockerRecord[]> {
const all = await getBlockers(projectId)
return all.filter(b => !b.resolvedAt)
}
export async function getBlockersByCategory(projectId: number): Promise<Record<BlockerCategory, BlockerRecord[]>> {
const all = await getBlockers(projectId)
const grouped: Record<string, BlockerRecord[]> = {}
for (const b of all) {
const cat = b.category || 'other'
if (!grouped[cat]) grouped[cat] = []
grouped[cat].push(b)
}
return grouped as Record<BlockerCategory, BlockerRecord[]>
}
export async function resolveBlocker(id: number, resolution: string, resolvedAt: string): Promise<void> {
const blocker = await (db as any).table('blockers').get(id)
if (blocker) {
blocker.resolvedAt = resolvedAt
blocker.resolution = resolution
await (db as any).table('blockers').put(blocker)
}
}
export async function getBlockerStats(projectId: number): Promise<{
totalBlocks: number
activeBlocks: number
totalHoursLost: number
byCategory: Record<string, { count: number; hours: number }>
}> {
const all = await getBlockers(projectId)
const active = all.filter(b => !b.resolvedAt)
const byCategory: Record<string, { count: number; hours: number }> = {}
for (const b of all) {
const cat = b.category || 'other'
if (!byCategory[cat]) byCategory[cat] = { count: 0, hours: 0 }
byCategory[cat].count++
byCategory[cat].hours += b.timeLostHours || 0
}
return {
totalBlocks: all.length,
activeBlocks: active.length,
totalHoursLost: all.reduce((s, b) => s + (b.timeLostHours || 0), 0),
byCategory,
}
}
+19 -1
View File
@@ -135,6 +135,22 @@ export interface PromptRecord {
updatedAt: string
}
export interface DexieBlocker {
id?: number
projectId: number
huId?: number
huTitle?: string
date: string
category: string
description: string
spAffected: number
resolvedAt?: string
resolution?: string
timeLostHours: number
createdBy?: string
createdAt: string
}
const db = new Dexie('alpha-core') as Dexie & {
settings: Dexie.Table<SettingEntry, string>
project_docs: Dexie.Table<ProjectDocRecord, number>
@@ -149,9 +165,10 @@ const db = new Dexie('alpha-core') as Dexie & {
cell_members: Dexie.Table<CellMemberRecord, string>
user_stories: Dexie.Table<DexieUserStory, number>
prompts: Dexie.Table<PromptRecord, string>
blockers: Dexie.Table<DexieBlocker, number>
}
db.version(9).stores({
db.version(10).stores({
settings: '&key',
project_docs: '&projectId, projectName, updatedAt',
sessions: '++id, projectId, date',
@@ -165,6 +182,7 @@ db.version(9).stores({
cell_members: '[cellId+userId], cellId, userId',
user_stories: '&id, initiative_id',
prompts: '&key',
blockers: '++id, projectId, category',
})
export default db
+144
View File
@@ -0,0 +1,144 @@
import { parseHierarchy, buildFullTitle, buildHierarchyPath, type ItemType } from '@/services/hierarchy'
import type { AnalysisEpic, AnalysisHU } from '@/services/project-analyzer'
import type { EnrichedEpic, EnrichedUserStory } from '@/stores/workitems'
export interface MaxCounters {
epicMax: number
byEpic: Map<string, Record<string, number>>
}
const ITEM_TYPE_MAP: Record<string, ItemType> = {
feature: 'F',
task: 'T',
us: 'U',
bug: 'B',
spike: 'S',
}
/**
* Analiza épicas e ítems existentes para determinar el próximo número disponible
* para cada nivel jerárquico.
*/
export function analyzeExisting(
epics: EnrichedEpic[],
items: EnrichedUserStory[],
): MaxCounters {
let epicMax = 0
const byEpic = new Map<string, Record<string, number>>()
function updateCounter(epicCode: string, type: string, num: number) {
if (!byEpic.has(epicCode)) byEpic.set(epicCode, {})
const map = byEpic.get(epicCode)!
const current = map[type] || 0
if (num > current) map[type] = num
}
for (const epic of epics) {
const h = parseHierarchy(epic._cleanName || epic.name || epic.title || '')
if (!h) {
// Si no tiene código jerárquico, asumimos que usa el id numérico de KAPPA
const fakeH = parseHierarchy(buildFullTitle('', Math.max(epicMax, 1)))
if (fakeH) {
const code = fakeH.epicCode
if (code) {
const num = parseInt(code.slice(1), 10)
if (num > epicMax) epicMax = num
}
}
continue
}
const epicItem = h.items.find(i => i.type === 'E')
if (epicItem) {
const code = `E${String(epicItem.number).padStart(2, '0')}`
if (epicItem.number > epicMax) epicMax = epicItem.number
}
}
for (const item of items) {
const h = parseHierarchy(item._cleanTitle || item.title || '')
if (!h) continue
const epicItem = h.items.find(i => i.type === 'E')
if (!epicItem) continue
const epicCode = `E${String(epicItem.number).padStart(2, '0')}`
for (const seg of h.items) {
if (seg.type === 'E') continue
updateCounter(epicCode, seg.type, seg.number)
}
}
return { epicMax, byEpic }
}
/**
* Asigna códigos jerárquicos a épicas propuestas y devuelve el array
* enriquecido con epicCode en metadata.
*/
export function assignEpicCodes(
proposedEpics: AnalysisEpic[],
counters: MaxCounters,
): (AnalysisEpic & { epicCode: string })[] {
let nextEpicNum = counters.epicMax + 1
return proposedEpics.map(epic => {
const epicCode = `E${String(nextEpicNum).padStart(2, '0')}`
nextEpicNum++
return { ...epic, epicCode }
})
}
/**
* Asigna códigos jerárquicos a ítems propuestos dentro de épicas confirmadas.
* Requiere que cada HU tenga epicName (nombre de la épica) y type.
* Devuelve HUs con title y description enriquecidos con el código.
*/
export function assignItemCodes(
proposedHUs: AnalysisHU[],
confirmedEpics: { name: string; epicCode: string }[],
counters: MaxCounters,
): AnalysisHU[] {
const epicNameToCode = new Map<string, string>()
for (const e of confirmedEpics) {
epicNameToCode.set(e.name.toLowerCase().trim(), e.epicCode)
}
// Clonar counters para no mutar el original
const localCounters = new Map<string, Record<string, number>>()
for (const [key, val] of counters.byEpic) {
localCounters.set(key, { ...val })
}
return proposedHUs.map(hu => {
const epicName = (hu.epicName || '').toLowerCase().trim()
const epicCode = epicNameToCode.get(epicName) || ''
const epicNum = epicCode ? parseInt(epicCode.slice(1), 10) : 0
const itemType = ITEM_TYPE_MAP[(hu.type || 'feature').toLowerCase()] || 'F'
// Obtener próximo número para este tipo dentro de la épica
const epicMap = localCounters.get(epicCode) || {}
const nextNum = (epicMap[itemType] || 0) + 1
if (!localCounters.has(epicCode)) localCounters.set(epicCode, {})
localCounters.get(epicCode)![itemType] = nextNum
// Construir título y descripción con código
const fullTitle = buildFullTitle(hu.title, epicNum || undefined, itemType, nextNum)
const codeTag = buildHierarchyPath(epicNum || undefined, itemType, nextNum)
const typeLabel: Record<string, string> = {
feature: 'Feature', task: 'Tarea', us: 'HU', bug: 'Bug', spike: 'Spike',
}
const huType = (hu.type || 'feature').toLowerCase()
const descPrefix = `[Tipo: ${typeLabel[huType] || 'HU'} | Épica: ${epicCode} | Código: ${codeTag}]<br>`
const enrichedDesc = hu.description
? `${descPrefix}${hu.description}`
: descPrefix
return {
...hu,
title: fullTitle,
description: enrichedDesc,
}
})
}
+13 -15
View File
@@ -1,10 +1,9 @@
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B'
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B' | 'S'
export interface HierarchyInfo {
fullPath: string
items: { type: ItemType; number: number }[]
epicCode: string | null
featureCode: string | null
itemCode: string | null
}
@@ -14,6 +13,7 @@ const TYPE_LABELS: Record<ItemType, string> = {
U: 'HU',
T: 'Tarea',
B: 'Bug',
S: 'Spike',
}
const TYPE_COLORS: Record<ItemType, string> = {
@@ -22,6 +22,7 @@ const TYPE_COLORS: Record<ItemType, string> = {
U: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
T: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
B: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
S: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
}
const TYPE_ICONS: Record<ItemType, string> = {
@@ -30,6 +31,7 @@ const TYPE_ICONS: Record<ItemType, string> = {
U: '📋',
T: '⚙️',
B: '🐛',
S: '🔬',
}
export function getTypeLabel(type: ItemType): string {
@@ -44,7 +46,7 @@ export function getTypeIcon(type: ItemType): string {
return TYPE_ICONS[type] || '📄'
}
const HIERARCHY_REGEX = /\[(([EFUTB]\d+)(?:-([EFUTB]\d+))?(?:-([EFUTB]\d+))?)\]/i
const HIERARCHY_REGEX = /\[(([EFUTBS]\d+)(?:-([EFUTBS]\d+))?(?:-([EFUTBS]\d+))?)\]/i
export function parseHierarchy(title: string): HierarchyInfo | null {
const match = title.match(HIERARCHY_REGEX)
@@ -61,15 +63,12 @@ export function parseHierarchy(title: string): HierarchyInfo | null {
}))
const epic = items.find(i => i.type === 'E')
const feature = items.find(i => i.type === 'F')
const item = items[items.length - 1]
return {
fullPath,
items,
epicCode: epic ? `E${String(epic.number).padStart(2, '0')}` : null,
featureCode: feature ? `F${String(feature.number).padStart(2, '0')}` : null,
itemCode: item ? `${item.type}${String(item.number).padStart(2, '0')}` : null,
itemCode: items.length > 1 ? `${items[1].type}${String(items[1].number).padStart(2, '0')}` : null,
}
}
@@ -85,25 +84,24 @@ export function getItemType(title: string): ItemType {
export function buildHierarchyPath(
epicNum?: number,
featureNum?: number,
itemType?: ItemType,
itemNum?: number,
): string {
const parts: string[] = []
if (epicNum !== undefined) parts.push(`E${String(epicNum).padStart(2, '0')}`)
if (featureNum !== undefined) parts.push(`F${String(featureNum).padStart(2, '0')}`)
if (itemType && itemNum !== undefined) parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
return parts.length ? `[${parts.join('-')}]` : ''
if (epicNum === undefined) return ''
const parts: string[] = [`E${String(epicNum).padStart(2, '0')}`]
if (itemType && itemNum !== undefined) {
parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
}
return `[${parts.join('-')}]`
}
export function buildFullTitle(
name: string,
epicNum?: number,
featureNum?: number,
itemType?: ItemType,
itemNum?: number,
): string {
const path = buildHierarchyPath(epicNum, featureNum, itemType, itemNum)
const path = buildHierarchyPath(epicNum, itemType, itemNum)
return path ? `${path} ${name}` : name
}
+170 -45
View File
@@ -2,14 +2,19 @@ import { callAI } from '@/services/ai'
import { getPrompt } from '@/services/prompts-db'
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
import { generateAndSavePlan } from '@/services/qa-analyzer'
import type { EnrichedUserStory } from '@/stores/workitems'
import { analyzeExisting, assignEpicCodes, assignItemCodes } from '@/services/hierarchy-generator'
import type { EnrichedEpic, EnrichedUserStory } from '@/stores/workitems'
export interface AnalysisHU {
title: string
description: string
acceptance_criteria: string[]
priority: string
story_points?: number
type?: string
feature?: string
sprint?: number
epicName?: string // nombre de la épica a la que pertenece
}
export interface AnalysisEpic {
@@ -20,18 +25,18 @@ export interface AnalysisEpic {
estimatedEnd?: string
}
interface AnalysisResult {
hus: AnalysisHU[]
interface AnalysisEpicsResult {
epics: AnalysisEpic[]
summary: string
rationale: string // explicación de por qué estas épicas
}
interface AnalysisHUsResult {
hus: AnalysisHU[]
summary: string
}
export async function analyzeProject(
projectId: number,
projectName: string,
existingHUs: EnrichedUserStory[],
signal?: AbortSignal,
): Promise<AnalysisResult> {
async function buildProjectContext(projectId: number, projectName: string, existingHUs: EnrichedUserStory[]) {
const sessions = await getSessionsByProject(projectId)
const state = await getProjectState(projectId)
@@ -48,9 +53,9 @@ export async function analyzeProject(
})
}
const context = {
return {
projectName,
existingHUs: existingHUs.map(h => ({ t: h._cleanTitle || h.title, s: h.status, p: h.priority })),
existingHUs: existingHUs.map(h => ({ t: h._cleanTitle || h.title, s: h.status, p: h.priority, e: h._assignedName || '' })),
sessions: sessionsWithSummaries.slice(-10).reverse(),
projectState: state ? {
summary: state.summary?.slice(0, 500),
@@ -58,39 +63,172 @@ export async function analyzeProject(
tasks: (safeParse<{ status: string }[]>(state.tasks, [])).filter(t => t.status !== 'completada').slice(0, 30),
} : null,
}
}
const userContent = `Contexto completo del proyecto en JSON:\n${JSON.stringify(context, null, 0)}`
// ─── FASE 1: Generar solo Épicas ──────────────────────
console.log(`[Alpha] Project analysis — ${projectId}, ${existingHUs.length} HUs existentes, ${sessions.length} sesiones`)
export async function analyzeProjectEpics(
projectId: number,
projectName: string,
existingHUs: EnrichedUserStory[],
existingEpics: string[], // nombres de épicas que ya están en KAPPA
signal?: AbortSignal,
existingEpicItems?: EnrichedEpic[], // épicas enriquecidas para contar códigos
): Promise<AnalysisEpicsResult> {
const context = await buildProjectContext(projectId, projectName, existingHUs)
const userContent = `Contexto del proyecto:\n${JSON.stringify(context, null, 0)}\n\nÉpicas ya existentes en KAPPA: ${existingEpics.join(', ') || 'Ninguna'}`
console.log(`[Alpha] Phase 1 — Analyzing epics for ${projectName}, ${existingEpics.length} existentes`)
const systemPrompt = await getPrompt('project_gap')
// Pasamos una instrucción adicional para que solo genere épicas en esta fase
const phasePrompt = `[FASE 1: SOLO ÉPICAS]
Analizá TODO el contexto del proyecto.
Identificá las épicas necesarias. Una épica agrupa funcionalidades de un mismo tema (ej: "Módulo de Pagos", "Scrapers", "Dashboard").
Revisá si las épicas ya existentes en KAPPA cubren las necesidades.
Si una épica ya existe en KAPPA, NO la generes de nuevo.
Si el acta de inicio, sesiones o documentación mencionan funcionalidades que no están cubiertas por ninguna épica existente, proponé nuevas épicas.
Para cada épica, listá los títulos tentativos de las HUs que pertenecerían a ella (linkedHuTitles).
${systemPrompt}
IMPORTANTE: Respondé SOLO con épicas en esta fase. NO generes HUs aún.`
const content = await callAI(
[{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }],
[{ role: 'system', content: phasePrompt }, { role: 'user', content: userContent }],
0.3, 8192, signal,
)
try {
const jsonStr = extractJSON(content)
const result: AnalysisResult = JSON.parse(jsonStr)
console.log(`[Alpha] Analysis result: ${result.hus.length} HUs, ${result.epics?.length || 0} épicas`)
const parsed = JSON.parse(jsonStr)
const rawEpics: AnalysisEpic[] = parsed.epics || parsed.epic || []
// Asignar códigos jerárquicos (E06, E07...) a las épicas propuestas
const counters = analyzeExisting(existingEpicItems || [], existingHUs)
const epicsWithCodes = assignEpicCodes(rawEpics, counters)
const result: AnalysisEpicsResult = {
epics: epicsWithCodes,
summary: parsed.summary || '',
rationale: parsed.rationale || parsed.summary || '',
}
console.log(`[Alpha] Phase 1 complete: ${result.epics.length} épicas propuestas`)
return result
} catch (e) {
console.error('[Alpha] Failed to parse analysis. Raw:', content)
throw new Error('No se pudo procesar el análisis del proyecto')
console.error('[Alpha] Failed to parse epics analysis. Raw:', content)
throw new Error('No se pudieron generar las épicas')
}
}
export async function saveAsDrafts(
// ─── FASE 2: Generar HUs dentro de las épicas confirmadas ──
export async function analyzeProjectHUs(
projectId: number,
analysis: AnalysisResult,
projectName: string,
existingHUs: EnrichedUserStory[],
confirmedEpics: AnalysisEpic[], // épicas que el usuario aceptó y ya están o serán enviadas a KAPPA
signal?: AbortSignal,
): Promise<AnalysisHUsResult> {
const context = await buildProjectContext(projectId, projectName, existingHUs)
const epicsDetail = confirmedEpics.map(e =>
`- ${e.name}: ${e.description?.slice(0, 200)} (HUs sugeridas: ${e.linkedHuTitles.join(', ')})`
).join('\n')
const userContent = `Contexto del proyecto:\n${JSON.stringify(context, null, 0)}\n\nÉpicas confirmadas:\n${epicsDetail}`
console.log(`[Alpha] Phase 2 — Generating HUs for ${confirmedEpics.length} epics in ${projectName}`)
const systemPrompt = await getPrompt('project_gap')
const phasePrompt = `[FASE 2: GENERAR HUs]
Las siguientes épicas ya están definidas. Tu tarea es generar las HUs (feature, task, US, bug, spike, etc.) que pertenecen a cada épica.
Cada HU debe estar vinculada a UNA épica existente.
Usá el campo "epicName" para indicar a qué épica pertenece cada HU.
No generes HUs duplicadas con las que ya existen en KAPPA.
Incluí para cada HU: título, descripción, criterios de aceptación, prioridad, story points, tipo, feature, sprint estimado.
${systemPrompt}
IMPORTANTE: Respondé SOLO con HUs en esta fase. NO generes épicas nuevas.`
const content = await callAI(
[{ role: 'system', content: phasePrompt }, { role: 'user', content: userContent }],
0.3, 8192, signal,
)
try {
const jsonStr = extractJSON(content)
const parsed = JSON.parse(jsonStr)
const rawHUs: AnalysisHU[] = parsed.hus || []
// Asignar códigos jerárquicos (E01-F04, E01-T01...) a las HUs propuestas
const counters = analyzeExisting([], existingHUs)
const epicsWithCodes = confirmedEpics.map(e => ({
name: e.name,
epicCode: (e as any).epicCode || '',
}))
const husWithCodes = assignItemCodes(rawHUs, epicsWithCodes, counters)
const result: AnalysisHUsResult = {
hus: husWithCodes,
summary: parsed.summary || '',
}
console.log(`[Alpha] Phase 2 complete: ${result.hus.length} HUs generadas con códigos`)
return result
} catch (e) {
console.error('[Alpha] Failed to parse HUs analysis. Raw:', content)
throw new Error('No se pudieron generar las HUs')
}
}
// ─── Guardar drafts ───────────────────────────────────
export async function saveEpicDrafts(
projectId: number,
epics: AnalysisEpic[],
existingHUs: EnrichedUserStory[],
sourceSessionId?: number,
): Promise<{ saved: number; skipped: number }> {
let saved = 0
let skipped = 0
// Guardar HUs
for (const hu of analysis.hus || []) {
for (const epic of epics) {
const epicWithCode = epic as any
const epicCode = epicWithCode.epicCode || ''
const fullTitle = epicCode ? `[${epicCode}] ${epic.name}` : epic.name
const normalizedName = epic.name.toLowerCase().trim()
const isDuplicate = existingHUs.some(ex => (ex._cleanTitle || ex.title).toLowerCase().trim() === normalizedName)
if (isDuplicate) { skipped++; continue }
await saveDraft({
id: createDraftId(), projectId, title: fullTitle,
description: epic.description, acceptanceCriteria: '',
priority: 'Media', type: 'E',
metadata: JSON.stringify({
linkedHuTitles: epic.linkedHuTitles,
estimatedStart: epic.estimatedStart,
estimatedEnd: epic.estimatedEnd,
epicCode,
}),
syncStatus: 'draft', createdAt: new Date().toISOString(),
})
saved++
}
return { saved, skipped }
}
export async function saveHUDrafts(
projectId: number,
hus: AnalysisHU[],
existingHUs: EnrichedUserStory[],
): Promise<{ saved: number; skipped: number }> {
let saved = 0
let skipped = 0
for (const hu of hus) {
const normalizedTitle = hu.title.toLowerCase().trim()
const isDuplicate = existingHUs.some(ex => {
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
@@ -102,27 +240,14 @@ export async function saveAsDrafts(
await saveDraft({
id: draftId, projectId, title: hu.title,
description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
priority: hu.priority, type: 'U', metadata: '{}',
sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(),
})
// QA plan: fire-and-forget para no bloquear el guardado
generateAndSavePlan(projectId, draftId, hu.title, hu.description, hu.acceptance_criteria.join('\n'))
.catch(e => console.error(`[Alpha] QA auto-gen failed for ${hu.title}:`, e))
saved++
}
// Guardar épicas
for (const epic of analysis.epics || []) {
const normalizedName = epic.name.toLowerCase().trim()
const isDuplicate = existingHUs.some(ex => (ex._cleanTitle || ex.title).toLowerCase().trim() === normalizedName)
if (isDuplicate) { skipped++; continue }
await saveDraft({
id: createDraftId(), projectId, title: epic.name,
description: epic.description, acceptanceCriteria: '',
priority: 'Media', type: 'E',
metadata: JSON.stringify({ linkedHuTitles: epic.linkedHuTitles, estimatedStart: epic.estimatedStart, estimatedEnd: epic.estimatedEnd }),
sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(),
priority: hu.priority, type: hu.type === 'task' ? 'T' : hu.type === 'bug' ? 'B' : hu.type === 'feature' ? 'F' : 'U',
metadata: JSON.stringify({
epicName: hu.epicName,
feature: hu.feature,
sprint: hu.sprint,
storyPoints: hu.story_points,
}),
syncStatus: 'draft', createdAt: new Date().toISOString(),
})
saved++
}
+46 -15
View File
@@ -20,8 +20,9 @@ Reglas:
3. Los criterios de aceptación deben ser verificables (condiciones específicas)
4. Usa el formato "Como [rol] quiero [funcionalidad] para [beneficio]" cuando sea posible
5. Asigna prioridad (Alta/Media/Baja) basada en urgencia implícita
6. No inventes información que no esté en la transcripción
7. Si el texto no contiene información relevante para HUs, devuelve un arreglo vacío
6. Asigna story points donde 1 SP = 1 hora de trabajo estimado
7. No inventes información que no esté en la transcripción
8. Si el texto no contiene información relevante para HUs, devuelve un arreglo vacío
Responde SOLO con JSON válido en este formato:
{
@@ -40,38 +41,68 @@ Responde SOLO con JSON válido en este formato:
},
project_gap: {
label: 'Análisis de brechas del proyecto',
content: `Eres un analista funcional experto. Tu tarea es analizar TODO el contexto de un proyecto y generar las Épicas e Historias de Usuario (HUs) que faltan.
content: `Eres un analista funcional experto en metodologías ágiles.
Trabajás en DOS FASES separadas. Cada fase tiene sus propias instrucciones.
Reglas:
Reglas generales:
1. Analizá TODA la información disponible: sesiones, resúmenes, estado del proyecto, HUs existentes
2. Identificá requisitos, funcionalidades, mejoras o bugs que NO estén cubiertos
3. Agrupá HUs relacionadas en Épicas. Cada épica agrupa funcionalidades de un mismo tema
4. Cada HU debe tener: título claro, descripción, criterios de aceptación verificables
5. No generes duplicados. Compará con la lista existente
6. Priorizá según urgencia implícita (Alta/Media/Baja)
7. Si todo ya está cubierto, devolvé arreglos vacíos
8. Respondé SOLO con JSON válido
3. No generes duplicados. Compará con TODO lo que ya existe
4. Respondé SOLO con JSON válido
5. Si todo ya está cubierto, devolvé arreglos vacíos
Formato de respuesta:
--- FASE 1: Generar Épicas ---
Instrucciones específicas:
- Identificá las épicas necesarias agrupando funcionalidades por tema
- Revisá si las épicas ya existentes cubren las necesidades
- Si una épica ya existe en KAPPA, NO la generes de nuevo
- Para cada épica, listá los títulos tentativos de las HUs que pertenecerían a ella (linkedHuTitles)
- Incluí un campo "rationale" explicando por qué proponés cada épica
- NO generes HUs en esta fase
Formato de respuesta FASE 1:
{
"epics": [
{
"name": "Nombre de la Épica",
"name": "Nombre de la Épica (ej: Módulo de Pagos)",
"description": "Descripción de la épica",
"linkedHuTitles": ["Título HU 1", "Título HU 2"],
"estimatedStart": "YYYY-MM-DD",
"estimatedEnd": "YYYY-MM-DD"
}
],
"summary": "Resumen del análisis de épicas",
"rationale": "Explicación de por qué estas épicas y no otras"
}
--- FASE 2: Generar HUs dentro de Épicas ---
Instrucciones específicas:
- Las épicas ya están definidas. Generá las HUs que pertenecen a cada una.
- NO generes códigos jerárquicos en los títulos. El sistema los asigna después.
- Proponé solo el nombre del ítem sin prefijos como [E01-F04].
- Cada HU debe tener un campo "epicName" con el NOMBRE de la épica a la que pertenece.
- Tipos de HU: feature, task, US (historia de usuario), bug, spike
- Incluí: título, descripción, criterios de aceptación, prioridad, story points, tipo, feature, sprint estimado
- Story points: 1 SP = 1 hora de trabajo estimado. Una jornada laboral = 8 SP
- No generes HUs duplicadas con las existentes
- NO generes épicas en esta fase
Formato de respuesta FASE 2:
{
"hus": [
{
"title": "Título de la HU",
"title": "Nombre del ítem (sin código jerárquico)",
"description": "Descripción detallada",
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
"priority": "Alta|Media|Baja"
"priority": "Alta|Media|Baja",
"story_points": 3,
"type": "feature|task|bug|spike",
"feature": "Nombre de la feature",
"sprint": 12,
"epicName": "Nombre exacto de la épica a la que pertenece"
}
],
"summary": "Resumen del análisis"
"summary": "Resumen de las HUs generadas"
}`,
},
session: {
+237
View File
@@ -0,0 +1,237 @@
import type { EnrichedUserStory } from '@/stores/workitems'
import type { BlockerRecord } from '@/services/blocker-log'
export interface CurvePoint {
date: string // ISO date YYYY-MM-DD
cumulative: number // cumulative SP
}
export interface SCurveData {
planned: CurvePoint[]
actual: CurvePoint[]
projection: CurvePoint[]
metrics: SCurveMetrics
}
export interface SCurveMetrics {
totalSpPlanned: number
totalSpCompleted: number
velocityPerWeek: number // average SP completed per week
weeksElapsed: number
weeksRemaining: number // estimated weeks to completion
estimatedEndDate: string // projected completion date
spi: number // Schedule Performance Index
clientBlockedHours: number
totalBlockedHours: number
}
const WORKING_HOURS_PER_DAY = 8
const WORKING_DAYS_PER_WEEK = 5
const SP_PER_WEEK = WORKING_HOURS_PER_DAY * WORKING_DAYS_PER_WEEK // 40
/**
* Calcula la curva planificada: distribuye los SP de cada HU
* linealmente entre su start_date y end_date.
*/
export function calculatePlannedCurve(hus: EnrichedUserStory[]): CurvePoint[] {
const points: { date: string; sp: number }[] = []
for (const hu of hus) {
const sp = hu.story_points || 0
if (sp <= 0) continue
// Si no tiene fechas, asumimos que empieza hoy y dura 1 día por SP
const start = hu.initial_date || new Date().toISOString().split('T')[0]
const end = hu.end_date || calcEndDate(start, sp)
// Distribuir SP linealmente entre start y end
const startD = new Date(start)
const endD = new Date(end)
const totalDays = Math.max(1, Math.ceil((endD.getTime() - startD.getTime()) / (1000 * 60 * 60 * 24)) + 1)
const spPerDay = sp / totalDays
for (let i = 0; i < totalDays; i++) {
const d = new Date(startD)
d.setDate(d.getDate() + i)
// Skip weekends
if (d.getDay() === 0 || d.getDay() === 6) continue
const dateStr = d.toISOString().split('T')[0]
points.push({ date: dateStr, sp: spPerDay })
}
}
// Acumular por fecha
return accumulatePoints(points)
}
/**
* Calcula la curva real desde daily logs (datos de progreso real).
* Por ahora, usa el end_date + status para estimar progreso.
*/
export function calculateActualCurve(hus: EnrichedUserStory[]): CurvePoint[] {
const points: { date: string; sp: number }[] = []
for (const hu of hus) {
const sp = hu.story_points || 0
if (sp <= 0) continue
const s = String(hu.status || '').toLowerCase()
const isDone = ['done', 'completed', 'closed', 'finalizado', '5', '6', '7', 'qa-client', 'ready to deploy'].includes(s)
const isInProgress = ['in_progress', 'doing', 'wip', 'active', 'in progress', 'en progreso', 'true', '2'].includes(s)
if (isDone && hu.end_date) {
points.push({ date: hu.end_date, sp })
} else if (isInProgress && hu.end_date) {
// 50% completado (estimado)
const halfDate = hu.initial_date
? midpointDate(hu.initial_date, hu.end_date)
: hu.end_date
points.push({ date: halfDate, sp: Math.round(sp * 0.5) })
}
}
return accumulatePoints(points)
}
/**
* Calcula la proyección: extiende la curva real usando la velocidad actual.
*/
export function calculateProjection(
actual: CurvePoint[],
planned: CurvePoint[],
totalSpPlanned: number,
totalSpCompleted: number,
): { projection: CurvePoint[]; estimatedEndDate: string; weeksRemaining: number } {
if (actual.length === 0) {
return {
projection: [],
estimatedEndDate: planned[planned.length - 1]?.date || '',
weeksRemaining: 0,
}
}
// Calcular velocidad semanal
const firstActual = new Date(actual[0].date)
const lastActual = new Date(actual[actual.length - 1].date)
const daysElapsed = Math.max(1, Math.ceil((lastActual.getTime() - firstActual.getTime()) / (1000 * 60 * 60 * 24)))
const weeksElapsed = Math.max(1, daysElapsed / WORKING_DAYS_PER_WEEK)
const velocityPerWeek = totalSpCompleted / weeksElapsed
if (velocityPerWeek <= 0) {
return { projection: [], estimatedEndDate: '', weeksRemaining: 0 }
}
const remainingSp = totalSpPlanned - totalSpCompleted
const weeksRemaining = Math.ceil(remainingSp / velocityPerWeek)
const estimatedEnd = new Date(lastActual)
estimatedEnd.setDate(estimatedEnd.getDate() + weeksRemaining * WORKING_DAYS_PER_WEEK)
// Generar puntos de proyección
const projection: CurvePoint[] = []
const lastCumulative = actual[actual.length - 1]?.cumulative || 0
let projectedCumulative = lastCumulative
const projDate = new Date(lastActual)
for (let w = 0; w <= weeksRemaining; w++) {
projectedCumulative += velocityPerWeek * WORKING_DAYS_PER_WEEK / 7
if (projectedCumulative > totalSpPlanned) projectedCumulative = totalSpPlanned
projDate.setDate(projDate.getDate() + 7)
projection.push({
date: projDate.toISOString().split('T')[0],
cumulative: Math.round(projectedCumulative),
})
}
return {
projection,
estimatedEndDate: estimatedEnd.toISOString().split('T')[0],
weeksRemaining,
}
}
/**
* Calcula todas las métricas de la curva S.
*/
export function calculateSCurve(hus: EnrichedUserStory[], blockers: BlockerRecord[]): SCurveData {
const planned = calculatePlannedCurve(hus)
const actual = calculateActualCurve(hus)
const totalSpPlanned = hus.reduce((s, h) => s + (h.story_points || 0), 0)
const totalSpCompleted = actual.length > 0 ? actual[actual.length - 1].cumulative : 0
const firstActual = actual.length > 0 ? new Date(actual[0].date) : new Date()
const lastActual = actual.length > 0 ? new Date(actual[actual.length - 1].date) : new Date()
const daysElapsed = Math.max(1, Math.ceil((lastActual.getTime() - firstActual.getTime()) / (1000 * 60 * 60 * 24)))
const weeksElapsed = Math.max(0.5, daysElapsed / WORKING_DAYS_PER_WEEK)
const velocityPerWeek = weeksElapsed > 0 ? +(totalSpCompleted / weeksElapsed).toFixed(1) : 0
// SPI = SP completados / SP planeados en el mismo periodo
const plannedAtEnd = planned.filter(p => new Date(p.date) <= lastActual)
const plannedInPeriod = plannedAtEnd.length > 0 ? plannedAtEnd[plannedAtEnd.length - 1].cumulative : 0
const spi = plannedInPeriod > 0 ? +(totalSpCompleted / plannedInPeriod).toFixed(2) : 1
const { estimatedEndDate, weeksRemaining } = calculateProjection(actual, planned, totalSpPlanned, totalSpCompleted)
// Estadísticas de bloqueos
const clientBlockedHours = blockers
.filter(b => b.category === 'client')
.reduce((s, b) => s + (b.timeLostHours || 0), 0)
const totalBlockedHours = blockers.reduce((s, b) => s + (b.timeLostHours || 0), 0)
return {
planned,
actual,
projection: [],
metrics: {
totalSpPlanned,
totalSpCompleted,
velocityPerWeek,
weeksElapsed,
weeksRemaining,
estimatedEndDate,
spi,
clientBlockedHours,
totalBlockedHours,
},
}
}
// ─── Helpers ────────────────────────────────────────
function accumulatePoints(points: { date: string; sp: number }[]): CurvePoint[] {
if (points.length === 0) return []
const sorted = [...points].sort((a, b) => a.date.localeCompare(b.date))
const result: CurvePoint[] = []
let cumulative = 0
let currentDate = sorted[0].date
let daySum = 0
for (const p of sorted) {
if (p.date !== currentDate) {
cumulative += Math.round(daySum)
result.push({ date: currentDate, cumulative })
currentDate = p.date
daySum = 0
}
daySum += p.sp
}
// Último día
cumulative += Math.round(daySum)
result.push({ date: currentDate, cumulative })
return result
}
function calcEndDate(start: string, sp: number): string {
const d = new Date(start)
d.setDate(d.getDate() + sp)
return d.toISOString().split('T')[0]
}
function midpointDate(start: string, end: string): string {
const s = new Date(start)
const e = new Date(end)
const mid = new Date((s.getTime() + e.getTime()) / 2)
return mid.toISOString().split('T')[0]
}
+17 -1
View File
@@ -19,12 +19,15 @@ export interface EnrichedUserStory extends KappaUserStory {
_assignedName: string
_statusName: string
_assignedEmployeeId: number | null
_epicCode: string | null
_itemCode: string | null
}
export interface EnrichedEpic extends KappaEpicDevelopment {
_itemType: ItemType
_hierarchyPath: string | null
_cleanName: string
_epicCode: string | null
}
export const useWorkItemsStore = defineStore('workitems', () => {
@@ -61,6 +64,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
_itemType: h?.items[h.items.length - 1]?.type || 'E',
_hierarchyPath: h?.fullPath ?? null,
_cleanName: h ? stripHierarchy(title) : title,
_epicCode: h?.epicCode ?? null,
}
}
@@ -89,7 +93,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
return { id: null, name: '', employeeId: null }
}
function enrichHU(hu: { title?: string; id?: number; description?: string | null; status?: string | number | null; status_name?: string | null; priority?: string | number | null; story_points?: number | null; acceptance_criteria?: string | null; criterios_aceptacion?: string | null; assigned_to?: number | null; asignado_a?: number[] | string[] | null; asignado_a_names?: string[] | string | null; assigned_name?: string }, initiativeId: number): EnrichedUserStory {
function enrichHU(hu: { title?: string; id?: number; description?: string | null; status?: string | number | null; status_name?: string | null; priority?: string | number | null; story_points?: number | null; end_date?: string | null; sprint?: number | string | null; acceptance_criteria?: string | null; criterios_aceptacion?: string | null; assigned_to?: number | null; asignado_a?: number[] | string[] | null; asignado_a_names?: string[] | string | null; assigned_name?: string }, initiativeId: number): EnrichedUserStory {
const h = parseHierarchy(hu.title || '')
const rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || ''
const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : []
@@ -100,6 +104,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
status: String(hu.status ?? ''),
priority: String(hu.priority ?? ''),
story_points: hu.story_points ?? undefined,
end_date: hu.end_date || undefined,
sprint: hu.sprint != null ? String(hu.sprint) : undefined,
description: hu.description || '',
acceptance_criteria: rawCriteria,
criterios_aceptacion: rawCriteria,
@@ -113,6 +119,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
_assignedName: assignedName,
_statusName: hu.status_name || resolveStatusName(hu.status),
_assignedEmployeeId: employeeId,
_epicCode: h?.epicCode ?? null,
_itemCode: h?.itemCode ?? null,
}
}
@@ -177,6 +185,14 @@ export const useWorkItemsStore = defineStore('workitems', () => {
// 4. Actualizar UI con datos frescos de KAPPA
userStories.value = stories.map(hu => enrichHU(hu, id))
if (userStories.value.length > 0) {
console.log('[Alpha DEBUG] 1ra HU desc:', {
id: userStories.value[0].id,
title: userStories.value[0].title?.slice(0, 40),
hasDescription: !!userStories.value[0].description,
descLength: userStories.value[0].description?.length,
})
}
epics.value = epicData.map(epic => ({
...epic,
+3 -1
View File
@@ -48,7 +48,9 @@ export interface KappaUserStory {
priority?: string | number
initiative: number | string
story_points?: number
sprint?: string
sprint?: number | string
end_date?: string
initial_date?: string | null
created_at?: string
assigned_to?: number | null
asignado_a?: number[] | string[] | null
+225 -50
View File
@@ -11,7 +11,9 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import HuDrafts from '@/components/HuDrafts.vue'
import AiProjectChat from '@/components/AiProjectChat.vue'
import { analyzeProject, saveAsDrafts } from '@/services/project-analyzer'
import PrioritizerCard from '@/components/PrioritizerCard.vue'
import SCurveChart from '@/components/SCurveChart.vue'
import { analyzeProjectEpics, analyzeProjectHUs, saveEpicDrafts, saveHUDrafts } from '@/services/project-analyzer'
import { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
import { kappa } from '@/services/kappa-api'
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
@@ -154,9 +156,9 @@ function assignedName(hu: { _assignedName?: string; _assignedEmployeeId?: number
// ─── Priority helpers ─────────────────────────────────
const PRIORITY_MAP: Record<string, { label: string; variant: string }> = {
'1': { label: 'Alta', variant: 'destructive' },
'1': { label: 'Baja', variant: 'secondary' },
'2': { label: 'Media', variant: 'default' },
'3': { label: 'Baja', variant: 'secondary' },
'3': { label: 'Alta', variant: 'destructive' },
'alta': { label: 'Alta', variant: 'destructive' },
'high': { label: 'Alta', variant: 'destructive' },
'critical': { label: 'Crítica', variant: 'destructive' },
@@ -270,6 +272,22 @@ function getEpicLinkedHUs(d: HuDraftRecord): string[] {
return meta.linkedHuTitles || []
} catch { return [] }
}
function getEpicCode(d: HuDraftRecord): string {
try {
const meta = JSON.parse(d.metadata || '{}')
return meta.epicCode || ''
} catch { return '' }
}
function getItemCode(d: HuDraftRecord): string {
const match = d.title.match(/\[([^\]]+)\]/)
return match ? match[1] : ''
}
function stripEpicTitle(title: string): string {
return title.replace(/^\[[^\]]+\]\s*/, '')
}
const drafts = ref<HuDraftRecord[]>([])
const pushingDraftId = ref<string | null>(null)
@@ -289,7 +307,7 @@ async function pushDraft(d: HuDraftRecord) {
let endpoint: string, body: string
if (d.type === 'E') {
// Push épica
// Push épica primero — KAPPA asigna el ID
const meta = JSON.parse(d.metadata || '{}')
const linkedHuIds: number[] = []
for (const huTitle of meta.linkedHuTitles || []) {
@@ -308,24 +326,51 @@ async function pushDraft(d: HuDraftRecord) {
status: false,
})
} else {
// Push HU
// Push HU con epic_development si está vinculada a una épica
const meta = JSON.parse(d.metadata || '{}')
const epicDevId = meta.epicDevelopment || null
endpoint = '/api/userstorys/create/'
body = JSON.stringify({
initiative: String(d.projectId), title: d.title,
initiative: String(d.projectId),
title: d.title,
description: d.description ? `<p>${d.description.replace(/\n/g, '</p><p>')}</p>` : '',
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '',
story_points: String(d.story_points ?? ''),
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
sprint: '', asignado_a: [], client_taker: null,
characterization_hu: '', has_impairment: false,
epic_development: null, feature: '',
initial_date: null, end_date: null,
story_points: d.story_points ?? null,
priority: d.priority === 'Alta' ? '3' : d.priority === 'Baja' ? '1' : '2',
sprint: meta.sprint ?? '',
asignado_a: meta.asignado_a ?? [],
client_taker: meta.clientTaker ?? null,
characterization_hu: meta.characterizationHu ?? '',
has_impairment: false,
epic_development: epicDevId,
feature: meta.feature ?? '',
initial_date: null,
end_date: meta.endDate ?? null,
status: meta.status ?? 1,
is_planned: false,
})
}
const res = await fetch(endpoint, { method: 'POST', headers, body })
if (res.ok) {
if (d.type !== 'E') {
if (d.type === 'E') {
// Épica creada → capturar ID y vincular HUs pendientes
const created = await res.json()
const epicKappaId = created.id
const meta = JSON.parse(d.metadata || '{}')
for (const huTitle of meta.linkedHuTitles || []) {
const linkedDraft = drafts.value.find(x =>
x.type !== 'E' && x.syncStatus === 'draft' &&
x.title.toLowerCase().trim() === huTitle.toLowerCase().trim()
)
if (linkedDraft) {
const linkedMeta = JSON.parse(linkedDraft.metadata || '{}')
linkedMeta.epicDevelopment = String(epicKappaId)
linkedDraft.metadata = JSON.stringify(linkedMeta)
await dbSaveDraft(linkedDraft)
}
}
} else {
const created = await res.json()
d.kappaId = created.id || undefined
}
@@ -365,44 +410,119 @@ async function discardDraft(id: string) {
await loadDrafts()
}
// ─── Project analysis ────────────────────────────────────
// ─── Project analysis — Two-phase ─────────────────────────
const phase = ref<'idle' | 'epics' | 'hus' | 'done'>('idle')
const analyzing = ref(false)
const analysisAbort = ref<AbortController | null>(null)
const analysisMessage = ref('')
const analysisResult = ref<{ saved: number; skipped: number } | null>(null)
const analysisSummary = ref('')
function cancelAnalysis() {
analysisAbort.value?.abort()
analyzing.value = false
analysisAbort.value = null
analysisSummary.value = 'Análisis cancelado'
analysisMessage.value = ''
phase.value = 'idle'
}
async function runAnalysis() {
// Obtener épicas que ya existen en KAPPA
function getExistingEpicNames(): string[] {
return workItems.epics.map(e => (e._cleanName || e.name || e.title || '').toLowerCase().trim()).filter(Boolean)
}
// FASE 1: Generar épicas
async function runPhaseEpics() {
if (!project.value) return
phase.value = 'epics'
analyzing.value = true
analysisAbort.value = new AbortController()
analysisResult.value = null
analysisSummary.value = ''
analysisMessage.value = 'Analizando contexto y generando épicas...'
try {
const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories, analysisAbort.value?.signal)
analysisSummary.value = result.summary
const existingEpics = getExistingEpicNames()
const result = await analyzeProjectEpics(
project.value.id, project.value.name || '',
workItems.userStories, existingEpics,
analysisAbort.value?.signal,
workItems.epics,
)
analysisMessage.value = result.rationale
if (result.hus.length > 0) {
const outcome = await saveAsDrafts(project.value.id, result, workItems.userStories)
if (result.epics.length > 0) {
const outcome = await saveEpicDrafts(project.value.id, result.epics, workItems.userStories)
analysisResult.value = outcome
await loadDrafts()
} else {
analysisResult.value = { saved: 0, skipped: 0 }
analysisMessage.value = 'No se identificaron nuevas épicas necesarias.'
}
} catch (e: any) {
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
analysisSummary.value = 'Análisis cancelado'
analysisMessage.value = 'Análisis cancelado'
} else {
console.error('[Alpha] Analysis error:', e)
analysisSummary.value = `Error: ${e.message}`
console.error('[Alpha] Phase 1 error:', e)
analysisMessage.value = `Error: ${e.message}`
}
analysisResult.value = { saved: 0, skipped: 0 }
phase.value = 'idle'
} finally {
analyzing.value = false
}
}
// FASE 2: Generar HUs dentro de las épicas
async function runPhaseHUs() {
if (!project.value) return
phase.value = 'hus'
analyzing.value = true
analysisAbort.value = new AbortController()
analysisResult.value = null
analysisMessage.value = 'Generando HUs dentro de las épicas...'
try {
// Obtener las épicas confirmadas desde los drafts de tipo épica
const epicDrafts = drafts.value.filter(d => d.type === 'E')
const confirmedEpics = epicDrafts.map(d => ({
name: d.title,
description: d.description,
linkedHuTitles: (() => { try { return JSON.parse(d.metadata || '{}').linkedHuTitles || [] } catch { return [] } })(),
}))
if (confirmedEpics.length === 0) {
analysisMessage.value = 'No hay épicas en borrador. Primero generá y enviá las épicas a KAPPA.'
analysisResult.value = { saved: 0, skipped: 0 }
phase.value = 'idle'
analyzing.value = false
return
}
const result = await analyzeProjectHUs(
project.value.id, project.value.name || '',
workItems.userStories, confirmedEpics,
analysisAbort.value?.signal,
)
analysisMessage.value = result.summary
if (result.hus.length > 0) {
const outcome = await saveHUDrafts(project.value.id, result.hus, workItems.userStories)
analysisResult.value = outcome
await loadDrafts()
phase.value = 'done'
} else {
analysisResult.value = { saved: 0, skipped: 0 }
analysisMessage.value = 'No se identificaron HUs nuevas. Todo parece cubierto.'
phase.value = 'done'
}
} catch (e: any) {
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
analysisMessage.value = 'Análisis cancelado'
} else {
console.error('[Alpha] Phase 2 error:', e)
analysisMessage.value = `Error: ${e.message}`
}
analysisResult.value = { saved: 0, skipped: 0 }
phase.value = 'idle'
} finally {
analyzing.value = false
}
@@ -421,6 +541,32 @@ watch(
{ immediate: true }
)
// ─── Epic progress bar ───────────────────────────────
const epicProgress = computed(() => {
const map = new Map<string, { total: number; done: number }>()
for (const hu of workItems.userStories) {
const epicCode = hu._epicCode || ''
if (!epicCode) continue
if (!map.has(epicCode)) map.set(epicCode, { total: 0, done: 0 })
const entry = map.get(epicCode)!
entry.total++
const s = String(hu.status ?? '').toLowerCase()
if (['done', 'completed', 'closed', 'finalizado', '5', '6', '7', 'qa-client', 'ready to deploy'].includes(s)) {
entry.done++
}
}
const result: Record<string, number> = {}
for (const [code, { total, done }] of map) {
result[code] = total > 0 ? Math.round((done / total) * 100) : 0
}
return result
})
function getEpicProgress(epic: any): number {
const code = epic._epicCode
return code ? epicProgress.value[code] ?? 0 : 0
}
const statusVariant = (status: unknown) => {
const s = String(status ?? '').toLowerCase()
if (['done', 'completed', 'closed', 'finalizado'].includes(s)) return 'secondary'
@@ -519,6 +665,12 @@ const statusLabel = (status: unknown) => {
</Card>
</div>
<!-- Priorizador Diario -->
<PrioritizerCard />
<!-- Curva S -->
<SCurveChart v-if="project" :project-id="project.id" />
<!-- AI Chat -->
<AiProjectChat
:project-id="project.id"
@@ -529,14 +681,32 @@ const statusLabel = (status: unknown) => {
@navigate-settings="emit('navigate-settings')"
/>
<!-- Project Analysis -->
<!-- Project Analysis Two-phase -->
<Card id="dashboard-analysis" class="border-dashed">
<CardHeader class="pb-3 flex flex-row items-center justify-between">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<Sparkles class="size-4" />
Análisis completo del proyecto
Análisis del proyecto
</CardTitle>
<div class="flex gap-2">
<div v-if="!analyzing" class="flex gap-2">
<Button
size="sm"
variant="outline"
:disabled="analyzing || phase === 'done'"
@click="runPhaseEpics()"
>
<Sparkles class="size-3 mr-1" />
1. Generar Épicas
</Button>
<Button
size="sm"
:disabled="analyzing || phase === 'idle'"
@click="runPhaseHUs()"
>
<Sparkles class="size-3 mr-1" />
2. Generar HUs
</Button>
</div>
<Button
v-if="analyzing"
size="sm"
@@ -545,19 +715,14 @@ const statusLabel = (status: unknown) => {
>
Cancelar
</Button>
<Button
size="sm"
:disabled="analyzing"
@click="runAnalysis()"
>
<Loader2 v-if="analyzing" class="size-4 mr-1 animate-spin" />
<Sparkles v-else class="size-4 mr-1" />
{{ analyzing ? 'Analizando...' : 'Generar HUs faltantes' }}
</Button>
</div>
</CardHeader>
<CardContent v-if="analysisResult" class="space-y-2 text-sm">
<p class="text-muted-foreground">{{ analysisSummary }}</p>
<CardContent class="space-y-3">
<div v-if="analyzing" class="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 class="size-4 animate-spin" />
{{ analysisMessage }}
</div>
<div v-if="analysisResult" class="space-y-2 text-sm">
<p class="text-muted-foreground">{{ analysisMessage }}</p>
<div class="flex items-center gap-3 text-xs">
<span v-if="analysisResult.saved > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
<CheckCircle2 class="size-3" /> {{ analysisResult.saved }} borradores guardados
@@ -566,9 +731,10 @@ const statusLabel = (status: unknown) => {
{{ analysisResult.skipped }} duplicadas saltadas
</span>
<span v-if="analysisResult.saved === 0 && analysisResult.skipped === 0" class="text-muted-foreground">
Todo ya está cubierto. No se requieren nuevas HUs.
{{ phase === 'epics' ? 'No se requieren nuevas épicas.' : phase === 'hus' ? 'No se requieren nuevas HUs.' : '' }}
</span>
</div>
</div>
</CardContent>
</Card>
@@ -584,7 +750,9 @@ const statusLabel = (status: unknown) => {
<Badge variant="outline" class="text-[10px]" :class="d.type === 'E' ? 'border-purple-300 text-purple-600' : ''">
{{ d.type === 'E' ? 'Épica' : 'HU' }}
</Badge>
<p class="font-medium">{{ d.title }}</p>
<span v-if="d.type === 'E'" class="font-mono text-[10px] text-muted-foreground shrink-0">[{{ getEpicCode(d) }}]</span>
<span v-else class="font-mono text-[10px] text-muted-foreground shrink-0">{{ getItemCode(d) }}</span>
<p class="font-medium">{{ d.type === 'E' ? stripEpicTitle(d.title) : d.title }}</p>
</div>
<div v-if="d.type === 'E' && d.metadata" class="text-xs text-muted-foreground mt-0.5">
<span v-if="getEpicLinkedHUs(d).length > 0">
@@ -637,13 +805,12 @@ const statusLabel = (status: unknown) => {
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
<TableHead>{{ t('dashboard.title') }}</TableHead>
<TableHead class="w-[60px] text-center">Desc</TableHead>
<TableHead class="w-[90px] text-center">{{ t('dashboard.assignedTo') }}</TableHead>
<TableHead class="w-[100px]">{{ t('dashboard.status') }}</TableHead>
<TableHead class="w-[140px]">Progreso</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="epic in workItems.epics" :key="epic.id">
<TableCell class="font-mono text-xs text-muted-foreground">{{ epic.code || `EP-${epic.id}` }}</TableCell>
<TableCell class="font-mono text-xs text-muted-foreground">{{ epic._epicCode || epic.code || `EP-${epic.id}` }}</TableCell>
<TableCell>
<span class="inline-flex items-center rounded px-1 py-0.5 text-[10px] font-bold" :class="getTypeColor(epic._itemType)">{{ getTypeLabel(epic._itemType) }}</span>
</TableCell>
@@ -658,9 +825,17 @@ const statusLabel = (status: unknown) => {
</Dialog>
<span v-else class="text-xs text-muted-foreground/40"></span>
</TableCell>
<TableCell class="text-center text-xs text-muted-foreground"></TableCell>
<TableCell>
<Badge :variant="statusVariant(epic.status || '')" class="text-xs">{{ statusLabel(epic.status || '') }}</Badge>
<TableCell class="text-xs">
<div class="flex items-center gap-2">
<div class="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:class="getEpicProgress(epic) >= 100 ? 'bg-green-500' : getEpicProgress(epic) > 0 ? 'bg-primary' : ''"
:style="{ width: getEpicProgress(epic) + '%' }"
/>
</div>
<span class="font-mono text-[10px] text-muted-foreground w-8 text-right">{{ getEpicProgress(epic) }}%</span>
</div>
</TableCell>
</TableRow>
</TableBody>
@@ -734,9 +909,9 @@ const statusLabel = (status: unknown) => {
<SelectItem value="baja" class="text-xs">Baja</SelectItem>
<SelectItem value="critical" class="text-xs">Crítica</SelectItem>
<SelectItem value="urgente" class="text-xs">Urgente</SelectItem>
<SelectItem value="1" class="text-xs">Alta (1)</SelectItem>
<SelectItem value="3" class="text-xs">Alta (3)</SelectItem>
<SelectItem value="2" class="text-xs">Media (2)</SelectItem>
<SelectItem value="3" class="text-xs">Baja (3)</SelectItem>
<SelectItem value="1" class="text-xs">Baja (1)</SelectItem>
</SelectContent>
</Select>
<Select v-model="filterAssigned">