Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf81b8e04b | |||
| ad715a9409 | |||
| f09a249caa | |||
| d8a5917bad | |||
| 0958d52fa9 | |||
| bbd367a266 | |||
| cb0e8067b6 | |||
| 2073d936e2 | |||
| 3035351e6f | |||
| 9cf12b482f |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -135,6 +135,22 @@ export interface PromptRecord {
|
|||||||
updatedAt: string
|
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 & {
|
const db = new Dexie('alpha-core') as Dexie & {
|
||||||
settings: Dexie.Table<SettingEntry, string>
|
settings: Dexie.Table<SettingEntry, string>
|
||||||
project_docs: Dexie.Table<ProjectDocRecord, number>
|
project_docs: Dexie.Table<ProjectDocRecord, number>
|
||||||
@@ -149,9 +165,10 @@ const db = new Dexie('alpha-core') as Dexie & {
|
|||||||
cell_members: Dexie.Table<CellMemberRecord, string>
|
cell_members: Dexie.Table<CellMemberRecord, string>
|
||||||
user_stories: Dexie.Table<DexieUserStory, number>
|
user_stories: Dexie.Table<DexieUserStory, number>
|
||||||
prompts: Dexie.Table<PromptRecord, string>
|
prompts: Dexie.Table<PromptRecord, string>
|
||||||
|
blockers: Dexie.Table<DexieBlocker, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
db.version(9).stores({
|
db.version(10).stores({
|
||||||
settings: '&key',
|
settings: '&key',
|
||||||
project_docs: '&projectId, projectName, updatedAt',
|
project_docs: '&projectId, projectName, updatedAt',
|
||||||
sessions: '++id, projectId, date',
|
sessions: '++id, projectId, date',
|
||||||
@@ -165,6 +182,7 @@ db.version(9).stores({
|
|||||||
cell_members: '[cellId+userId], cellId, userId',
|
cell_members: '[cellId+userId], cellId, userId',
|
||||||
user_stories: '&id, initiative_id',
|
user_stories: '&id, initiative_id',
|
||||||
prompts: '&key',
|
prompts: '&key',
|
||||||
|
blockers: '++id, projectId, category',
|
||||||
})
|
})
|
||||||
|
|
||||||
export default db
|
export default db
|
||||||
|
|||||||
@@ -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
@@ -1,10 +1,9 @@
|
|||||||
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B'
|
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B' | 'S'
|
||||||
|
|
||||||
export interface HierarchyInfo {
|
export interface HierarchyInfo {
|
||||||
fullPath: string
|
fullPath: string
|
||||||
items: { type: ItemType; number: number }[]
|
items: { type: ItemType; number: number }[]
|
||||||
epicCode: string | null
|
epicCode: string | null
|
||||||
featureCode: string | null
|
|
||||||
itemCode: string | null
|
itemCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,6 +13,7 @@ const TYPE_LABELS: Record<ItemType, string> = {
|
|||||||
U: 'HU',
|
U: 'HU',
|
||||||
T: 'Tarea',
|
T: 'Tarea',
|
||||||
B: 'Bug',
|
B: 'Bug',
|
||||||
|
S: 'Spike',
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_COLORS: Record<ItemType, string> = {
|
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',
|
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',
|
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',
|
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> = {
|
const TYPE_ICONS: Record<ItemType, string> = {
|
||||||
@@ -30,6 +31,7 @@ const TYPE_ICONS: Record<ItemType, string> = {
|
|||||||
U: '📋',
|
U: '📋',
|
||||||
T: '⚙️',
|
T: '⚙️',
|
||||||
B: '🐛',
|
B: '🐛',
|
||||||
|
S: '🔬',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTypeLabel(type: ItemType): string {
|
export function getTypeLabel(type: ItemType): string {
|
||||||
@@ -44,7 +46,7 @@ export function getTypeIcon(type: ItemType): string {
|
|||||||
return TYPE_ICONS[type] || '📄'
|
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 {
|
export function parseHierarchy(title: string): HierarchyInfo | null {
|
||||||
const match = title.match(HIERARCHY_REGEX)
|
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 epic = items.find(i => i.type === 'E')
|
||||||
const feature = items.find(i => i.type === 'F')
|
|
||||||
const item = items[items.length - 1]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullPath,
|
fullPath,
|
||||||
items,
|
items,
|
||||||
epicCode: epic ? `E${String(epic.number).padStart(2, '0')}` : null,
|
epicCode: epic ? `E${String(epic.number).padStart(2, '0')}` : null,
|
||||||
featureCode: feature ? `F${String(feature.number).padStart(2, '0')}` : null,
|
itemCode: items.length > 1 ? `${items[1].type}${String(items[1].number).padStart(2, '0')}` : null,
|
||||||
itemCode: item ? `${item.type}${String(item.number).padStart(2, '0')}` : null,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,25 +84,24 @@ export function getItemType(title: string): ItemType {
|
|||||||
|
|
||||||
export function buildHierarchyPath(
|
export function buildHierarchyPath(
|
||||||
epicNum?: number,
|
epicNum?: number,
|
||||||
featureNum?: number,
|
|
||||||
itemType?: ItemType,
|
itemType?: ItemType,
|
||||||
itemNum?: number,
|
itemNum?: number,
|
||||||
): string {
|
): string {
|
||||||
const parts: string[] = []
|
if (epicNum === undefined) return ''
|
||||||
if (epicNum !== undefined) parts.push(`E${String(epicNum).padStart(2, '0')}`)
|
const parts: string[] = [`E${String(epicNum).padStart(2, '0')}`]
|
||||||
if (featureNum !== undefined) parts.push(`F${String(featureNum).padStart(2, '0')}`)
|
if (itemType && itemNum !== undefined) {
|
||||||
if (itemType && itemNum !== undefined) parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
|
parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
|
||||||
return parts.length ? `[${parts.join('-')}]` : ''
|
}
|
||||||
|
return `[${parts.join('-')}]`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFullTitle(
|
export function buildFullTitle(
|
||||||
name: string,
|
name: string,
|
||||||
epicNum?: number,
|
epicNum?: number,
|
||||||
featureNum?: number,
|
|
||||||
itemType?: ItemType,
|
itemType?: ItemType,
|
||||||
itemNum?: number,
|
itemNum?: number,
|
||||||
): string {
|
): string {
|
||||||
const path = buildHierarchyPath(epicNum, featureNum, itemType, itemNum)
|
const path = buildHierarchyPath(epicNum, itemType, itemNum)
|
||||||
return path ? `${path} ${name}` : name
|
return path ? `${path} ${name}` : name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import { callAI } from '@/services/ai'
|
|||||||
import { getPrompt } from '@/services/prompts-db'
|
import { getPrompt } from '@/services/prompts-db'
|
||||||
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
||||||
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
|
import { saveDraft, createDraftId } from '@/services/hu-drafts-db'
|
||||||
import { generateAndSavePlan } from '@/services/qa-analyzer'
|
import { analyzeExisting, assignEpicCodes, assignItemCodes } from '@/services/hierarchy-generator'
|
||||||
import type { EnrichedUserStory } from '@/stores/workitems'
|
import type { EnrichedEpic, EnrichedUserStory } from '@/stores/workitems'
|
||||||
|
|
||||||
export interface AnalysisHU {
|
export interface AnalysisHU {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
acceptance_criteria: string[]
|
acceptance_criteria: string[]
|
||||||
priority: 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 {
|
export interface AnalysisEpic {
|
||||||
@@ -20,18 +25,18 @@ export interface AnalysisEpic {
|
|||||||
estimatedEnd?: string
|
estimatedEnd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnalysisResult {
|
interface AnalysisEpicsResult {
|
||||||
hus: AnalysisHU[]
|
|
||||||
epics: AnalysisEpic[]
|
epics: AnalysisEpic[]
|
||||||
summary: string
|
summary: string
|
||||||
|
rationale: string // explicación de por qué estas épicas
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalysisHUsResult {
|
||||||
|
hus: AnalysisHU[]
|
||||||
|
summary: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeProject(
|
async function buildProjectContext(projectId: number, projectName: string, existingHUs: EnrichedUserStory[]) {
|
||||||
projectId: number,
|
|
||||||
projectName: string,
|
|
||||||
existingHUs: EnrichedUserStory[],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<AnalysisResult> {
|
|
||||||
const sessions = await getSessionsByProject(projectId)
|
const sessions = await getSessionsByProject(projectId)
|
||||||
const state = await getProjectState(projectId)
|
const state = await getProjectState(projectId)
|
||||||
|
|
||||||
@@ -48,9 +53,9 @@ export async function analyzeProject(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = {
|
return {
|
||||||
projectName,
|
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(),
|
sessions: sessionsWithSummaries.slice(-10).reverse(),
|
||||||
projectState: state ? {
|
projectState: state ? {
|
||||||
summary: state.summary?.slice(0, 500),
|
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),
|
tasks: (safeParse<{ status: string }[]>(state.tasks, [])).filter(t => t.status !== 'completada').slice(0, 30),
|
||||||
} : null,
|
} : 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')
|
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(
|
const content = await callAI(
|
||||||
[{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }],
|
[{ role: 'system', content: phasePrompt }, { role: 'user', content: userContent }],
|
||||||
0.3, 8192, signal,
|
0.3, 8192, signal,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jsonStr = extractJSON(content)
|
const jsonStr = extractJSON(content)
|
||||||
const result: AnalysisResult = JSON.parse(jsonStr)
|
const parsed = JSON.parse(jsonStr)
|
||||||
console.log(`[Alpha] Analysis result: ${result.hus.length} HUs, ${result.epics?.length || 0} épicas`)
|
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
|
return result
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Alpha] Failed to parse analysis. Raw:', content)
|
console.error('[Alpha] Failed to parse epics analysis. Raw:', content)
|
||||||
throw new Error('No se pudo procesar el análisis del proyecto')
|
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,
|
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[],
|
existingHUs: EnrichedUserStory[],
|
||||||
sourceSessionId?: number,
|
|
||||||
): Promise<{ saved: number; skipped: number }> {
|
): Promise<{ saved: number; skipped: number }> {
|
||||||
let saved = 0
|
let saved = 0
|
||||||
let skipped = 0
|
let skipped = 0
|
||||||
|
|
||||||
// Guardar HUs
|
for (const epic of epics) {
|
||||||
for (const hu of analysis.hus || []) {
|
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 normalizedTitle = hu.title.toLowerCase().trim()
|
||||||
const isDuplicate = existingHUs.some(ex => {
|
const isDuplicate = existingHUs.some(ex => {
|
||||||
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
|
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
|
||||||
@@ -102,27 +240,14 @@ export async function saveAsDrafts(
|
|||||||
await saveDraft({
|
await saveDraft({
|
||||||
id: draftId, projectId, title: hu.title,
|
id: draftId, projectId, title: hu.title,
|
||||||
description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
|
description: hu.description, acceptanceCriteria: hu.acceptance_criteria.join('\n'),
|
||||||
priority: hu.priority, type: 'U', metadata: '{}',
|
priority: hu.priority, type: hu.type === 'task' ? 'T' : hu.type === 'bug' ? 'B' : hu.type === 'feature' ? 'F' : 'U',
|
||||||
sourceSessionId, syncStatus: 'draft', createdAt: new Date().toISOString(),
|
metadata: JSON.stringify({
|
||||||
})
|
epicName: hu.epicName,
|
||||||
// QA plan: fire-and-forget para no bloquear el guardado
|
feature: hu.feature,
|
||||||
generateAndSavePlan(projectId, draftId, hu.title, hu.description, hu.acceptance_criteria.join('\n'))
|
sprint: hu.sprint,
|
||||||
.catch(e => console.error(`[Alpha] QA auto-gen failed for ${hu.title}:`, e))
|
storyPoints: hu.story_points,
|
||||||
saved++
|
}),
|
||||||
}
|
syncStatus: 'draft', createdAt: new Date().toISOString(),
|
||||||
|
|
||||||
// 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(),
|
|
||||||
})
|
})
|
||||||
saved++
|
saved++
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-15
@@ -20,8 +20,9 @@ Reglas:
|
|||||||
3. Los criterios de aceptación deben ser verificables (condiciones específicas)
|
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
|
4. Usa el formato "Como [rol] quiero [funcionalidad] para [beneficio]" cuando sea posible
|
||||||
5. Asigna prioridad (Alta/Media/Baja) basada en urgencia implícita
|
5. Asigna prioridad (Alta/Media/Baja) basada en urgencia implícita
|
||||||
6. No inventes información que no esté en la transcripción
|
6. Asigna story points donde 1 SP = 1 hora de trabajo estimado
|
||||||
7. Si el texto no contiene información relevante para HUs, devuelve un arreglo vacío
|
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:
|
Responde SOLO con JSON válido en este formato:
|
||||||
{
|
{
|
||||||
@@ -40,38 +41,68 @@ Responde SOLO con JSON válido en este formato:
|
|||||||
},
|
},
|
||||||
project_gap: {
|
project_gap: {
|
||||||
label: 'Análisis de brechas del proyecto',
|
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
|
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
|
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
|
3. No generes duplicados. Compará con TODO lo que ya existe
|
||||||
4. Cada HU debe tener: título claro, descripción, criterios de aceptación verificables
|
4. Respondé SOLO con JSON válido
|
||||||
5. No generes duplicados. Compará con la lista existente
|
5. Si todo ya está cubierto, devolvé arreglos vacíos
|
||||||
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
|
|
||||||
|
|
||||||
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": [
|
"epics": [
|
||||||
{
|
{
|
||||||
"name": "Nombre de la Épica",
|
"name": "Nombre de la Épica (ej: Módulo de Pagos)",
|
||||||
"description": "Descripción de la épica",
|
"description": "Descripción de la épica",
|
||||||
"linkedHuTitles": ["Título HU 1", "Título HU 2"],
|
"linkedHuTitles": ["Título HU 1", "Título HU 2"],
|
||||||
"estimatedStart": "YYYY-MM-DD",
|
"estimatedStart": "YYYY-MM-DD",
|
||||||
"estimatedEnd": "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": [
|
"hus": [
|
||||||
{
|
{
|
||||||
"title": "Título de la HU",
|
"title": "Nombre del ítem (sin código jerárquico)",
|
||||||
"description": "Descripción detallada",
|
"description": "Descripción detallada",
|
||||||
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
|
"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: {
|
session: {
|
||||||
|
|||||||
@@ -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
@@ -19,12 +19,15 @@ export interface EnrichedUserStory extends KappaUserStory {
|
|||||||
_assignedName: string
|
_assignedName: string
|
||||||
_statusName: string
|
_statusName: string
|
||||||
_assignedEmployeeId: number | null
|
_assignedEmployeeId: number | null
|
||||||
|
_epicCode: string | null
|
||||||
|
_itemCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnrichedEpic extends KappaEpicDevelopment {
|
export interface EnrichedEpic extends KappaEpicDevelopment {
|
||||||
_itemType: ItemType
|
_itemType: ItemType
|
||||||
_hierarchyPath: string | null
|
_hierarchyPath: string | null
|
||||||
_cleanName: string
|
_cleanName: string
|
||||||
|
_epicCode: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWorkItemsStore = defineStore('workitems', () => {
|
export const useWorkItemsStore = defineStore('workitems', () => {
|
||||||
@@ -61,6 +64,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
_itemType: h?.items[h.items.length - 1]?.type || 'E',
|
_itemType: h?.items[h.items.length - 1]?.type || 'E',
|
||||||
_hierarchyPath: h?.fullPath ?? null,
|
_hierarchyPath: h?.fullPath ?? null,
|
||||||
_cleanName: h ? stripHierarchy(title) : title,
|
_cleanName: h ? stripHierarchy(title) : title,
|
||||||
|
_epicCode: h?.epicCode ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +93,7 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
return { id: null, name: '', employeeId: null }
|
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 h = parseHierarchy(hu.title || '')
|
||||||
const rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || ''
|
const rawCriteria = hu.acceptance_criteria || hu.criterios_aceptacion || ''
|
||||||
const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : []
|
const criteriaList = rawCriteria ? parseQuillList(rawCriteria) : []
|
||||||
@@ -100,6 +104,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
status: String(hu.status ?? ''),
|
status: String(hu.status ?? ''),
|
||||||
priority: String(hu.priority ?? ''),
|
priority: String(hu.priority ?? ''),
|
||||||
story_points: hu.story_points ?? undefined,
|
story_points: hu.story_points ?? undefined,
|
||||||
|
end_date: hu.end_date || undefined,
|
||||||
|
sprint: hu.sprint != null ? String(hu.sprint) : undefined,
|
||||||
description: hu.description || '',
|
description: hu.description || '',
|
||||||
acceptance_criteria: rawCriteria,
|
acceptance_criteria: rawCriteria,
|
||||||
criterios_aceptacion: rawCriteria,
|
criterios_aceptacion: rawCriteria,
|
||||||
@@ -113,6 +119,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
|
|||||||
_assignedName: assignedName,
|
_assignedName: assignedName,
|
||||||
_statusName: hu.status_name || resolveStatusName(hu.status),
|
_statusName: hu.status_name || resolveStatusName(hu.status),
|
||||||
_assignedEmployeeId: employeeId,
|
_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
|
// 4. Actualizar UI con datos frescos de KAPPA
|
||||||
userStories.value = stories.map(hu => enrichHU(hu, id))
|
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 => ({
|
epics.value = epicData.map(epic => ({
|
||||||
...epic,
|
...epic,
|
||||||
|
|||||||
+3
-1
@@ -48,7 +48,9 @@ export interface KappaUserStory {
|
|||||||
priority?: string | number
|
priority?: string | number
|
||||||
initiative: number | string
|
initiative: number | string
|
||||||
story_points?: number
|
story_points?: number
|
||||||
sprint?: string
|
sprint?: number | string
|
||||||
|
end_date?: string
|
||||||
|
initial_date?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
assigned_to?: number | null
|
assigned_to?: number | null
|
||||||
asignado_a?: number[] | string[] | null
|
asignado_a?: number[] | string[] | null
|
||||||
|
|||||||
+232
-57
@@ -11,7 +11,9 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import HuDrafts from '@/components/HuDrafts.vue'
|
import HuDrafts from '@/components/HuDrafts.vue'
|
||||||
import AiProjectChat from '@/components/AiProjectChat.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 { getDrafts, deleteDraft, type HuDraftRecord } from '@/services/hu-drafts-db'
|
||||||
import { kappa } from '@/services/kappa-api'
|
import { kappa } from '@/services/kappa-api'
|
||||||
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
|
import { generateAndSavePlan, getQAPlans, type HUQAPlan } from '@/services/qa-analyzer'
|
||||||
@@ -154,9 +156,9 @@ function assignedName(hu: { _assignedName?: string; _assignedEmployeeId?: number
|
|||||||
|
|
||||||
// ─── Priority helpers ─────────────────────────────────
|
// ─── Priority helpers ─────────────────────────────────
|
||||||
const PRIORITY_MAP: Record<string, { label: string; variant: string }> = {
|
const PRIORITY_MAP: Record<string, { label: string; variant: string }> = {
|
||||||
'1': { label: 'Alta', variant: 'destructive' },
|
'1': { label: 'Baja', variant: 'secondary' },
|
||||||
'2': { label: 'Media', variant: 'default' },
|
'2': { label: 'Media', variant: 'default' },
|
||||||
'3': { label: 'Baja', variant: 'secondary' },
|
'3': { label: 'Alta', variant: 'destructive' },
|
||||||
'alta': { label: 'Alta', variant: 'destructive' },
|
'alta': { label: 'Alta', variant: 'destructive' },
|
||||||
'high': { label: 'Alta', variant: 'destructive' },
|
'high': { label: 'Alta', variant: 'destructive' },
|
||||||
'critical': { label: 'Crítica', variant: 'destructive' },
|
'critical': { label: 'Crítica', variant: 'destructive' },
|
||||||
@@ -270,6 +272,22 @@ function getEpicLinkedHUs(d: HuDraftRecord): string[] {
|
|||||||
return meta.linkedHuTitles || []
|
return meta.linkedHuTitles || []
|
||||||
} catch { return [] }
|
} 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 drafts = ref<HuDraftRecord[]>([])
|
||||||
const pushingDraftId = ref<string | null>(null)
|
const pushingDraftId = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -289,7 +307,7 @@ async function pushDraft(d: HuDraftRecord) {
|
|||||||
let endpoint: string, body: string
|
let endpoint: string, body: string
|
||||||
|
|
||||||
if (d.type === 'E') {
|
if (d.type === 'E') {
|
||||||
// Push épica
|
// Push épica primero — KAPPA asigna el ID
|
||||||
const meta = JSON.parse(d.metadata || '{}')
|
const meta = JSON.parse(d.metadata || '{}')
|
||||||
const linkedHuIds: number[] = []
|
const linkedHuIds: number[] = []
|
||||||
for (const huTitle of meta.linkedHuTitles || []) {
|
for (const huTitle of meta.linkedHuTitles || []) {
|
||||||
@@ -308,24 +326,51 @@ async function pushDraft(d: HuDraftRecord) {
|
|||||||
status: false,
|
status: false,
|
||||||
})
|
})
|
||||||
} else {
|
} 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/'
|
endpoint = '/api/userstorys/create/'
|
||||||
body = JSON.stringify({
|
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>` : '',
|
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>` : '',
|
criterios_aceptacion: d.acceptanceCriteria ? `<p>${d.acceptanceCriteria.replace(/\n/g, '</p><p>')}</p>` : '',
|
||||||
story_points: String(d.story_points ?? ''),
|
story_points: d.story_points ?? null,
|
||||||
priority: d.priority === 'Alta' ? '1' : d.priority === 'Baja' ? '3' : '2',
|
priority: d.priority === 'Alta' ? '3' : d.priority === 'Baja' ? '1' : '2',
|
||||||
sprint: '', asignado_a: [], client_taker: null,
|
sprint: meta.sprint ?? '',
|
||||||
characterization_hu: '', has_impairment: false,
|
asignado_a: meta.asignado_a ?? [],
|
||||||
epic_development: null, feature: '',
|
client_taker: meta.clientTaker ?? null,
|
||||||
initial_date: null, end_date: 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 })
|
const res = await fetch(endpoint, { method: 'POST', headers, body })
|
||||||
if (res.ok) {
|
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()
|
const created = await res.json()
|
||||||
d.kappaId = created.id || undefined
|
d.kappaId = created.id || undefined
|
||||||
}
|
}
|
||||||
@@ -365,44 +410,119 @@ async function discardDraft(id: string) {
|
|||||||
await loadDrafts()
|
await loadDrafts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Project analysis ────────────────────────────────────
|
// ─── Project analysis — Two-phase ─────────────────────────
|
||||||
|
const phase = ref<'idle' | 'epics' | 'hus' | 'done'>('idle')
|
||||||
const analyzing = ref(false)
|
const analyzing = ref(false)
|
||||||
const analysisAbort = ref<AbortController | null>(null)
|
const analysisAbort = ref<AbortController | null>(null)
|
||||||
|
const analysisMessage = ref('')
|
||||||
const analysisResult = ref<{ saved: number; skipped: number } | null>(null)
|
const analysisResult = ref<{ saved: number; skipped: number } | null>(null)
|
||||||
const analysisSummary = ref('')
|
|
||||||
|
|
||||||
function cancelAnalysis() {
|
function cancelAnalysis() {
|
||||||
analysisAbort.value?.abort()
|
analysisAbort.value?.abort()
|
||||||
analyzing.value = false
|
analyzing.value = false
|
||||||
analysisAbort.value = null
|
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
|
if (!project.value) return
|
||||||
|
phase.value = 'epics'
|
||||||
analyzing.value = true
|
analyzing.value = true
|
||||||
analysisAbort.value = new AbortController()
|
analysisAbort.value = new AbortController()
|
||||||
analysisResult.value = null
|
analysisResult.value = null
|
||||||
analysisSummary.value = ''
|
analysisMessage.value = 'Analizando contexto y generando épicas...'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories, analysisAbort.value?.signal)
|
const existingEpics = getExistingEpicNames()
|
||||||
analysisSummary.value = result.summary
|
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) {
|
if (result.epics.length > 0) {
|
||||||
const outcome = await saveAsDrafts(project.value.id, result, workItems.userStories)
|
const outcome = await saveEpicDrafts(project.value.id, result.epics, workItems.userStories)
|
||||||
analysisResult.value = outcome
|
analysisResult.value = outcome
|
||||||
|
await loadDrafts()
|
||||||
} else {
|
} else {
|
||||||
analysisResult.value = { saved: 0, skipped: 0 }
|
analysisResult.value = { saved: 0, skipped: 0 }
|
||||||
|
analysisMessage.value = 'No se identificaron nuevas épicas necesarias.'
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
|
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
|
||||||
analysisSummary.value = 'Análisis cancelado'
|
analysisMessage.value = 'Análisis cancelado'
|
||||||
} else {
|
} else {
|
||||||
console.error('[Alpha] Analysis error:', e)
|
console.error('[Alpha] Phase 1 error:', e)
|
||||||
analysisSummary.value = `Error: ${e.message}`
|
analysisMessage.value = `Error: ${e.message}`
|
||||||
}
|
}
|
||||||
analysisResult.value = { saved: 0, skipped: 0 }
|
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 {
|
} finally {
|
||||||
analyzing.value = false
|
analyzing.value = false
|
||||||
}
|
}
|
||||||
@@ -421,6 +541,32 @@ watch(
|
|||||||
{ immediate: true }
|
{ 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 statusVariant = (status: unknown) => {
|
||||||
const s = String(status ?? '').toLowerCase()
|
const s = String(status ?? '').toLowerCase()
|
||||||
if (['done', 'completed', 'closed', 'finalizado'].includes(s)) return 'secondary'
|
if (['done', 'completed', 'closed', 'finalizado'].includes(s)) return 'secondary'
|
||||||
@@ -519,6 +665,12 @@ const statusLabel = (status: unknown) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Priorizador Diario -->
|
||||||
|
<PrioritizerCard />
|
||||||
|
|
||||||
|
<!-- Curva S -->
|
||||||
|
<SCurveChart v-if="project" :project-id="project.id" />
|
||||||
|
|
||||||
<!-- AI Chat -->
|
<!-- AI Chat -->
|
||||||
<AiProjectChat
|
<AiProjectChat
|
||||||
:project-id="project.id"
|
:project-id="project.id"
|
||||||
@@ -529,45 +681,59 @@ const statusLabel = (status: unknown) => {
|
|||||||
@navigate-settings="emit('navigate-settings')"
|
@navigate-settings="emit('navigate-settings')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Project Analysis -->
|
<!-- Project Analysis — Two-phase -->
|
||||||
<Card id="dashboard-analysis" class="border-dashed">
|
<Card id="dashboard-analysis" class="border-dashed">
|
||||||
<CardHeader class="pb-3 flex flex-row items-center justify-between">
|
<CardHeader class="pb-3 flex flex-row items-center justify-between">
|
||||||
<CardTitle class="text-sm font-medium flex items-center gap-2">
|
<CardTitle class="text-sm font-medium flex items-center gap-2">
|
||||||
<Sparkles class="size-4" />
|
<Sparkles class="size-4" />
|
||||||
Análisis completo del proyecto
|
Análisis del proyecto
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div class="flex gap-2">
|
<div v-if="!analyzing" class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
v-if="analyzing"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@click="cancelAnalysis()"
|
:disabled="analyzing || phase === 'done'"
|
||||||
|
@click="runPhaseEpics()"
|
||||||
>
|
>
|
||||||
Cancelar
|
<Sparkles class="size-3 mr-1" />
|
||||||
|
1. Generar Épicas
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="analyzing"
|
:disabled="analyzing || phase === 'idle'"
|
||||||
@click="runAnalysis()"
|
@click="runPhaseHUs()"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="analyzing" class="size-4 mr-1 animate-spin" />
|
<Sparkles class="size-3 mr-1" />
|
||||||
<Sparkles v-else class="size-4 mr-1" />
|
2. Generar HUs
|
||||||
{{ analyzing ? 'Analizando...' : 'Generar HUs faltantes' }}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="analyzing"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="cancelAnalysis()"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent v-if="analysisResult" class="space-y-2 text-sm">
|
<CardContent class="space-y-3">
|
||||||
<p class="text-muted-foreground">{{ analysisSummary }}</p>
|
<div v-if="analyzing" class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<div class="flex items-center gap-3 text-xs">
|
<Loader2 class="size-4 animate-spin" />
|
||||||
<span v-if="analysisResult.saved > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
|
{{ analysisMessage }}
|
||||||
<CheckCircle2 class="size-3" /> {{ analysisResult.saved }} borradores guardados
|
</div>
|
||||||
</span>
|
<div v-if="analysisResult" class="space-y-2 text-sm">
|
||||||
<span v-if="analysisResult.skipped > 0" class="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
<p class="text-muted-foreground">{{ analysisMessage }}</p>
|
||||||
{{ analysisResult.skipped }} duplicadas saltadas
|
<div class="flex items-center gap-3 text-xs">
|
||||||
</span>
|
<span v-if="analysisResult.saved > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
<span v-if="analysisResult.saved === 0 && analysisResult.skipped === 0" class="text-muted-foreground">
|
<CheckCircle2 class="size-3" /> {{ analysisResult.saved }} borradores guardados
|
||||||
Todo ya está cubierto. No se requieren nuevas HUs.
|
</span>
|
||||||
</span>
|
<span v-if="analysisResult.skipped > 0" class="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
{{ analysisResult.skipped }} duplicadas saltadas
|
||||||
|
</span>
|
||||||
|
<span v-if="analysisResult.saved === 0 && analysisResult.skipped === 0" class="text-muted-foreground">
|
||||||
|
{{ phase === 'epics' ? 'No se requieren nuevas épicas.' : phase === 'hus' ? 'No se requieren nuevas HUs.' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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' : ''">
|
<Badge variant="outline" class="text-[10px]" :class="d.type === 'E' ? 'border-purple-300 text-purple-600' : ''">
|
||||||
{{ d.type === 'E' ? 'Épica' : 'HU' }}
|
{{ d.type === 'E' ? 'Épica' : 'HU' }}
|
||||||
</Badge>
|
</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>
|
||||||
<div v-if="d.type === 'E' && d.metadata" class="text-xs text-muted-foreground mt-0.5">
|
<div v-if="d.type === 'E' && d.metadata" class="text-xs text-muted-foreground mt-0.5">
|
||||||
<span v-if="getEpicLinkedHUs(d).length > 0">
|
<span v-if="getEpicLinkedHUs(d).length > 0">
|
||||||
@@ -637,13 +805,12 @@ const statusLabel = (status: unknown) => {
|
|||||||
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
|
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
|
||||||
<TableHead>{{ t('dashboard.title') }}</TableHead>
|
<TableHead>{{ t('dashboard.title') }}</TableHead>
|
||||||
<TableHead class="w-[60px] text-center">Desc</TableHead>
|
<TableHead class="w-[60px] text-center">Desc</TableHead>
|
||||||
<TableHead class="w-[90px] text-center">{{ t('dashboard.assignedTo') }}</TableHead>
|
<TableHead class="w-[140px]">Progreso</TableHead>
|
||||||
<TableHead class="w-[100px]">{{ t('dashboard.status') }}</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="epic in workItems.epics" :key="epic.id">
|
<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>
|
<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>
|
<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>
|
</TableCell>
|
||||||
@@ -658,9 +825,17 @@ const statusLabel = (status: unknown) => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
<span v-else class="text-xs text-muted-foreground/40">—</span>
|
<span v-else class="text-xs text-muted-foreground/40">—</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center text-xs text-muted-foreground">—</TableCell>
|
<TableCell class="text-xs">
|
||||||
<TableCell>
|
<div class="flex items-center gap-2">
|
||||||
<Badge :variant="statusVariant(epic.status || '')" class="text-xs">{{ statusLabel(epic.status || '') }}</Badge>
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -734,9 +909,9 @@ const statusLabel = (status: unknown) => {
|
|||||||
<SelectItem value="baja" class="text-xs">Baja</SelectItem>
|
<SelectItem value="baja" class="text-xs">Baja</SelectItem>
|
||||||
<SelectItem value="critical" class="text-xs">Crítica</SelectItem>
|
<SelectItem value="critical" class="text-xs">Crítica</SelectItem>
|
||||||
<SelectItem value="urgente" class="text-xs">Urgente</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="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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select v-model="filterAssigned">
|
<Select v-model="filterAssigned">
|
||||||
|
|||||||
Reference in New Issue
Block a user