S-Curve + Blocker tracking: curva planificada/real, bloqueos categorizados, proyección

- services/blocker-log.ts: registro de bloqueos con categorías (cliente/pm/tech/external)
  Cada bloqueo: descripción, SP afectados, horas perdidas, categoría
- services/s-curve.ts: cálculo de curva S planificada (distribución lineal SP entre fechas)
  + curva real (desde status + fechas) + proyección (velocidad semanal)
  + métricas: SPI, velocity, blocked hours, estimated end date
- db.ts: schema v10 con tabla blockers (++id, projectId, category)
- components/SCurveChart.vue: gráfico SVG con curvas planificada (azul) y real (verde)
  + tarjetas de métricas (SP total, velocidad, SPI, proyección)
  + resumen de horas bloqueadas imputables al cliente
This commit is contained in:
2026-05-29 23:27:50 -05:00
parent d8a5917bad
commit f09a249caa
4 changed files with 512 additions and 1 deletions
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useWorkItemsStore } from '@/stores/workitems'
import { calculateSCurve, type CurvePoint, type SCurveMetrics, type SCurveData } from '@/services/s-curve'
import { getBlockers, getBlockerStats } from '@/services/blocker-log'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { TrendingUp, AlertTriangle } from 'lucide-vue-next'
const props = defineProps<{ projectId: number }>()
const workItems = useWorkItemsStore()
const loading = ref(true)
const sCurve = ref<SCurveData | null>(null)
async function load() {
loading.value = true
try {
const blockers = await getBlockers(props.projectId)
sCurve.value = calculateSCurve(workItems.userStories, blockers)
} catch (e) {
console.error('[Alpha] S-Curve error:', e)
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => workItems.userStories.length, load)
const CHART_W = 600
const CHART_H = 250
const PAD = { top: 20, right: 20, bottom: 35, left: 50 }
function buildPath(points: CurvePoint[], maxSp: number, totalDays: number): string {
if (points.length === 0) return ''
const w = CHART_W - PAD.left - PAD.right
const h = CHART_H - PAD.top - PAD.bottom
const firstDate = points[0].date
return points.map((p, i) => {
const first = new Date(firstDate)
const cur = new Date(p.date)
const dx = Math.max(0, Math.ceil((cur.getTime() - first.getTime()) / (1000 * 60 * 60 * 24)))
const x = PAD.left + (dx / totalDays) * w
const y = PAD.top + h - (p.cumulative / maxSp) * h
return `${i === 0 ? 'M' : 'L'}${x},${y}`
}).join(' ')
}
const planned = computed(() => sCurve.value?.planned || [])
const actual = computed(() => sCurve.value?.actual || [])
const metrics = computed(() => sCurve.value?.metrics)
const maxSp = computed(() => {
const all = [...planned.value, ...actual.value]
return Math.max(...all.map(p => p.cumulative), 1)
})
const totalDays = computed(() => {
const all = [...planned.value, ...actual.value]
if (all.length < 2) return 30
const first = new Date(all[0].date)
const last = new Date(all[all.length - 1].date)
return Math.max(1, Math.ceil((last.getTime() - first.getTime()) / (1000 * 60 * 60 * 24)))
})
</script>
<template>
<Card id="scurve-chart">
<CardHeader class="pb-3">
<CardTitle class="text-sm font-medium flex items-center gap-2">
<TrendingUp class="size-4" />
Curva S del proyecto
</CardTitle>
</CardHeader>
<CardContent>
<div v-if="loading" class="text-xs text-muted-foreground text-center py-8">Cargando...</div>
<div v-else-if="planned.length === 0" class="text-xs text-muted-foreground text-center py-8">
No hay suficientes datos. Se necesitan HUs con story points y fechas.
</div>
<div v-else class="space-y-4">
<!-- Metrics row -->
<div v-if="metrics" class="grid grid-cols-4 gap-2 text-xs">
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">SP total</span>
<p class="font-bold text-sm">{{ metrics.totalSpPlanned }}</p>
</div>
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">Velocidad/sem</span>
<p class="font-bold text-sm">{{ metrics.velocityPerWeek }} SP</p>
</div>
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">SPI</span>
<p class="font-bold text-sm" :class="metrics.spi >= 1 ? 'text-green-500' : 'text-red-500'">
{{ metrics.spi }}
</p>
</div>
<div class="p-2 rounded-lg bg-muted/30">
<span class="text-muted-foreground">Proyección</span>
<p class="font-bold text-sm">{{ metrics.estimatedEndDate || '—' }}</p>
</div>
</div>
<!-- Blockers summary -->
<div v-if="metrics && metrics.totalBlockedHours > 0" class="flex items-center gap-2 text-xs p-2 rounded-lg bg-orange-500/5 border border-orange-500/20">
<AlertTriangle class="size-3.5 text-orange-500 shrink-0" />
<span class="text-muted-foreground">
<strong class="text-orange-600">{{ metrics.totalBlockedHours }}h</strong> bloqueadas
(<strong class="text-orange-600">{{ metrics.clientBlockedHours }}h</strong> imputables al cliente)
</span>
</div>
<!-- SVG Chart -->
<svg :viewBox="`0 0 ${CHART_W} ${CHART_H}`" class="w-full h-auto">
<line v-for="i in 5" :key="i"
:x1="PAD.left" :y1="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom)"
:x2="CHART_W - PAD.right" :y2="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom)"
stroke="currentColor" stroke-opacity="0.1" stroke-dasharray="4,4"
/>
<path :d="buildPath(planned, maxSp, totalDays)" fill="none" stroke="#3b82f6" stroke-width="2" />
<path :d="buildPath(actual, maxSp, totalDays)" fill="none" stroke="#22c55e" stroke-width="2" />
<text v-for="i in 5" :key="'y'+i"
:x="PAD.left - 8"
:y="PAD.top + ((i-1)/4) * (CHART_H - PAD.top - PAD.bottom) + 4"
text-anchor="end" class="fill-muted-foreground text-[10px]"
>{{ Math.round(maxSp * (i-1) / 4) }}</text>
<text :x="PAD.left" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
{{ planned[0]?.date?.slice(5) || '' }}
</text>
<text :x="(CHART_W - PAD.right + PAD.left) / 2" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
{{ planned[Math.floor(planned.length/2)]?.date?.slice(5) || '' }}
</text>
<text :x="CHART_W - PAD.right" :y="CHART_H - 5" text-anchor="middle" class="fill-muted-foreground text-[9px]">
{{ planned[planned.length - 1]?.date?.slice(5) || '' }}
</text>
<line x1="20" y1="12" x2="40" y2="12" stroke="#3b82f6" stroke-width="2" />
<text x="45" y="16" class="fill-muted-foreground text-[9px]">Plan</text>
<line x1="90" y1="12" x2="110" y2="12" stroke="#22c55e" stroke-width="2" />
<text x="115" y="16" class="fill-muted-foreground text-[9px]">Real</text>
</svg>
</div>
</CardContent>
</Card>
</template>
+107
View File
@@ -0,0 +1,107 @@
import db from '@/services/db'
export interface BlockerRecord {
id?: number
projectId: number
huId?: number
huTitle?: string
date: string
category: string
description: string
spAffected: number
resolvedAt?: string
resolution?: string
timeLostHours: number
createdBy?: string
createdAt: string
}
export type BlockerCategory = 'client' | 'pm' | 'tech' | 'external' | 'other'
const CATEGORY_LABELS: Record<BlockerCategory, string> = {
client: 'Cliente',
pm: 'PM / Alcance',
tech: 'Técnico',
external: 'Terceros',
other: 'Otro',
}
const CATEGORY_COLORS: Record<BlockerCategory, string> = {
client: 'text-orange-600 bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400',
pm: 'text-blue-600 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400',
tech: 'text-red-600 bg-red-100 dark:bg-red-900/30 dark:text-red-400',
external: 'text-purple-600 bg-purple-100 dark:bg-purple-900/30 dark:text-purple-400',
other: 'text-gray-600 bg-gray-100 dark:bg-gray-900/30 dark:text-gray-400',
}
export function getBlockerCategoryLabel(cat: BlockerCategory): string {
return CATEGORY_LABELS[cat] || cat
}
export function getBlockerCategoryColor(cat: BlockerCategory): string {
return CATEGORY_COLORS[cat] || ''
}
export const BLOCKER_CATEGORIES: BlockerCategory[] = ['client', 'pm', 'tech', 'external', 'other']
export async function saveBlocker(blocker: BlockerRecord): Promise<number> {
if (blocker.id) {
await (db as any).table('blockers').put(blocker)
return blocker.id
}
return (db as any).table('blockers').add(blocker)
}
export async function getBlockers(projectId: number): Promise<BlockerRecord[]> {
return (db as any).table('blockers').where('projectId').equals(projectId).toArray()
}
export async function getActiveBlockers(projectId: number): Promise<BlockerRecord[]> {
const all = await getBlockers(projectId)
return all.filter(b => !b.resolvedAt)
}
export async function getBlockersByCategory(projectId: number): Promise<Record<BlockerCategory, BlockerRecord[]>> {
const all = await getBlockers(projectId)
const grouped: Record<string, BlockerRecord[]> = {}
for (const b of all) {
const cat = b.category || 'other'
if (!grouped[cat]) grouped[cat] = []
grouped[cat].push(b)
}
return grouped as Record<BlockerCategory, BlockerRecord[]>
}
export async function resolveBlocker(id: number, resolution: string, resolvedAt: string): Promise<void> {
const blocker = await (db as any).table('blockers').get(id)
if (blocker) {
blocker.resolvedAt = resolvedAt
blocker.resolution = resolution
await (db as any).table('blockers').put(blocker)
}
}
export async function getBlockerStats(projectId: number): Promise<{
totalBlocks: number
activeBlocks: number
totalHoursLost: number
byCategory: Record<string, { count: number; hours: number }>
}> {
const all = await getBlockers(projectId)
const active = all.filter(b => !b.resolvedAt)
const byCategory: Record<string, { count: number; hours: number }> = {}
for (const b of all) {
const cat = b.category || 'other'
if (!byCategory[cat]) byCategory[cat] = { count: 0, hours: 0 }
byCategory[cat].count++
byCategory[cat].hours += b.timeLostHours || 0
}
return {
totalBlocks: all.length,
activeBlocks: active.length,
totalHoursLost: all.reduce((s, b) => s + (b.timeLostHours || 0), 0),
byCategory,
}
}
+19 -1
View File
@@ -135,6 +135,22 @@ export interface PromptRecord {
updatedAt: string 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
+237
View File
@@ -0,0 +1,237 @@
import type { EnrichedUserStory } from '@/stores/workitems'
import type { BlockerRecord } from '@/services/blocker-log'
export interface CurvePoint {
date: string // ISO date YYYY-MM-DD
cumulative: number // cumulative SP
}
export interface SCurveData {
planned: CurvePoint[]
actual: CurvePoint[]
projection: CurvePoint[]
metrics: SCurveMetrics
}
export interface SCurveMetrics {
totalSpPlanned: number
totalSpCompleted: number
velocityPerWeek: number // average SP completed per week
weeksElapsed: number
weeksRemaining: number // estimated weeks to completion
estimatedEndDate: string // projected completion date
spi: number // Schedule Performance Index
clientBlockedHours: number
totalBlockedHours: number
}
const WORKING_HOURS_PER_DAY = 8
const WORKING_DAYS_PER_WEEK = 5
const SP_PER_WEEK = WORKING_HOURS_PER_DAY * WORKING_DAYS_PER_WEEK // 40
/**
* Calcula la curva planificada: distribuye los SP de cada HU
* linealmente entre su start_date y end_date.
*/
export function calculatePlannedCurve(hus: EnrichedUserStory[]): CurvePoint[] {
const points: { date: string; sp: number }[] = []
for (const hu of hus) {
const sp = hu.story_points || 0
if (sp <= 0) continue
// Si no tiene fechas, asumimos que empieza hoy y dura 1 día por SP
const start = hu.initial_date || new Date().toISOString().split('T')[0]
const end = hu.end_date || calcEndDate(start, sp)
// Distribuir SP linealmente entre start y end
const startD = new Date(start)
const endD = new Date(end)
const totalDays = Math.max(1, Math.ceil((endD.getTime() - startD.getTime()) / (1000 * 60 * 60 * 24)) + 1)
const spPerDay = sp / totalDays
for (let i = 0; i < totalDays; i++) {
const d = new Date(startD)
d.setDate(d.getDate() + i)
// Skip weekends
if (d.getDay() === 0 || d.getDay() === 6) continue
const dateStr = d.toISOString().split('T')[0]
points.push({ date: dateStr, sp: spPerDay })
}
}
// Acumular por fecha
return accumulatePoints(points)
}
/**
* Calcula la curva real desde daily logs (datos de progreso real).
* Por ahora, usa el end_date + status para estimar progreso.
*/
export function calculateActualCurve(hus: EnrichedUserStory[]): CurvePoint[] {
const points: { date: string; sp: number }[] = []
for (const hu of hus) {
const sp = hu.story_points || 0
if (sp <= 0) continue
const s = String(hu.status || '').toLowerCase()
const isDone = ['done', 'completed', 'closed', 'finalizado', '5', '6', '7', 'qa-client', 'ready to deploy'].includes(s)
const isInProgress = ['in_progress', 'doing', 'wip', 'active', 'in progress', 'en progreso', 'true', '2'].includes(s)
if (isDone && hu.end_date) {
points.push({ date: hu.end_date, sp })
} else if (isInProgress && hu.end_date) {
// 50% completado (estimado)
const halfDate = hu.initial_date
? midpointDate(hu.initial_date, hu.end_date)
: hu.end_date
points.push({ date: halfDate, sp: Math.round(sp * 0.5) })
}
}
return accumulatePoints(points)
}
/**
* Calcula la proyección: extiende la curva real usando la velocidad actual.
*/
export function calculateProjection(
actual: CurvePoint[],
planned: CurvePoint[],
totalSpPlanned: number,
totalSpCompleted: number,
): { projection: CurvePoint[]; estimatedEndDate: string; weeksRemaining: number } {
if (actual.length === 0) {
return {
projection: [],
estimatedEndDate: planned[planned.length - 1]?.date || '',
weeksRemaining: 0,
}
}
// Calcular velocidad semanal
const firstActual = new Date(actual[0].date)
const lastActual = new Date(actual[actual.length - 1].date)
const daysElapsed = Math.max(1, Math.ceil((lastActual.getTime() - firstActual.getTime()) / (1000 * 60 * 60 * 24)))
const weeksElapsed = Math.max(1, daysElapsed / WORKING_DAYS_PER_WEEK)
const velocityPerWeek = totalSpCompleted / weeksElapsed
if (velocityPerWeek <= 0) {
return { projection: [], estimatedEndDate: '', weeksRemaining: 0 }
}
const remainingSp = totalSpPlanned - totalSpCompleted
const weeksRemaining = Math.ceil(remainingSp / velocityPerWeek)
const estimatedEnd = new Date(lastActual)
estimatedEnd.setDate(estimatedEnd.getDate() + weeksRemaining * WORKING_DAYS_PER_WEEK)
// Generar puntos de proyección
const projection: CurvePoint[] = []
const lastCumulative = actual[actual.length - 1]?.cumulative || 0
let projectedCumulative = lastCumulative
const projDate = new Date(lastActual)
for (let w = 0; w <= weeksRemaining; w++) {
projectedCumulative += velocityPerWeek * WORKING_DAYS_PER_WEEK / 7
if (projectedCumulative > totalSpPlanned) projectedCumulative = totalSpPlanned
projDate.setDate(projDate.getDate() + 7)
projection.push({
date: projDate.toISOString().split('T')[0],
cumulative: Math.round(projectedCumulative),
})
}
return {
projection,
estimatedEndDate: estimatedEnd.toISOString().split('T')[0],
weeksRemaining,
}
}
/**
* Calcula todas las métricas de la curva S.
*/
export function calculateSCurve(hus: EnrichedUserStory[], blockers: BlockerRecord[]): SCurveData {
const planned = calculatePlannedCurve(hus)
const actual = calculateActualCurve(hus)
const totalSpPlanned = hus.reduce((s, h) => s + (h.story_points || 0), 0)
const totalSpCompleted = actual.length > 0 ? actual[actual.length - 1].cumulative : 0
const firstActual = actual.length > 0 ? new Date(actual[0].date) : new Date()
const lastActual = actual.length > 0 ? new Date(actual[actual.length - 1].date) : new Date()
const daysElapsed = Math.max(1, Math.ceil((lastActual.getTime() - firstActual.getTime()) / (1000 * 60 * 60 * 24)))
const weeksElapsed = Math.max(0.5, daysElapsed / WORKING_DAYS_PER_WEEK)
const velocityPerWeek = weeksElapsed > 0 ? +(totalSpCompleted / weeksElapsed).toFixed(1) : 0
// SPI = SP completados / SP planeados en el mismo periodo
const plannedAtEnd = planned.filter(p => new Date(p.date) <= lastActual)
const plannedInPeriod = plannedAtEnd.length > 0 ? plannedAtEnd[plannedAtEnd.length - 1].cumulative : 0
const spi = plannedInPeriod > 0 ? +(totalSpCompleted / plannedInPeriod).toFixed(2) : 1
const { estimatedEndDate, weeksRemaining } = calculateProjection(actual, planned, totalSpPlanned, totalSpCompleted)
// Estadísticas de bloqueos
const clientBlockedHours = blockers
.filter(b => b.category === 'client')
.reduce((s, b) => s + (b.timeLostHours || 0), 0)
const totalBlockedHours = blockers.reduce((s, b) => s + (b.timeLostHours || 0), 0)
return {
planned,
actual,
projection: [],
metrics: {
totalSpPlanned,
totalSpCompleted,
velocityPerWeek,
weeksElapsed,
weeksRemaining,
estimatedEndDate,
spi,
clientBlockedHours,
totalBlockedHours,
},
}
}
// ─── Helpers ────────────────────────────────────────
function accumulatePoints(points: { date: string; sp: number }[]): CurvePoint[] {
if (points.length === 0) return []
const sorted = [...points].sort((a, b) => a.date.localeCompare(b.date))
const result: CurvePoint[] = []
let cumulative = 0
let currentDate = sorted[0].date
let daySum = 0
for (const p of sorted) {
if (p.date !== currentDate) {
cumulative += Math.round(daySum)
result.push({ date: currentDate, cumulative })
currentDate = p.date
daySum = 0
}
daySum += p.sp
}
// Último día
cumulative += Math.round(daySum)
result.push({ date: currentDate, cumulative })
return result
}
function calcEndDate(start: string, sp: number): string {
const d = new Date(start)
d.setDate(d.getDate() + sp)
return d.toISOString().split('T')[0]
}
function midpointDate(start: string, end: string): string {
const s = new Date(start)
const e = new Date(end)
const mid = new Date((s.getTime() + e.getTime()) / 2)
return mid.toISOString().split('T')[0]
}