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
+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
}
export interface DexieBlocker {
id?: number
projectId: number
huId?: number
huTitle?: string
date: string
category: string
description: string
spAffected: number
resolvedAt?: string
resolution?: string
timeLostHours: number
createdBy?: string
createdAt: string
}
const db = new Dexie('alpha-core') as Dexie & {
settings: Dexie.Table<SettingEntry, string>
project_docs: Dexie.Table<ProjectDocRecord, number>
@@ -149,9 +165,10 @@ const db = new Dexie('alpha-core') as Dexie & {
cell_members: Dexie.Table<CellMemberRecord, string>
user_stories: Dexie.Table<DexieUserStory, number>
prompts: Dexie.Table<PromptRecord, string>
blockers: Dexie.Table<DexieBlocker, number>
}
db.version(9).stores({
db.version(10).stores({
settings: '&key',
project_docs: '&projectId, projectName, updatedAt',
sessions: '++id, projectId, date',
@@ -165,6 +182,7 @@ db.version(9).stores({
cell_members: '[cellId+userId], cellId, userId',
user_stories: '&id, initiative_id',
prompts: '&key',
blockers: '++id, projectId, category',
})
export default db
+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]
}