diff --git a/src/components/SCurveChart.vue b/src/components/SCurveChart.vue new file mode 100644 index 0000000..44a39d4 --- /dev/null +++ b/src/components/SCurveChart.vue @@ -0,0 +1,149 @@ + + + + + + + + Curva S del proyecto + + + + Cargando... + + + No hay suficientes datos. Se necesitan HUs con story points y fechas. + + + + + + + SP total + {{ metrics.totalSpPlanned }} + + + Velocidad/sem + {{ metrics.velocityPerWeek }} SP + + + SPI + + {{ metrics.spi }} + + + + Proyección + {{ metrics.estimatedEndDate || '—' }} + + + + + + + + {{ metrics.totalBlockedHours }}h bloqueadas + ({{ metrics.clientBlockedHours }}h imputables al cliente) + + + + + + + + + + {{ Math.round(maxSp * (i-1) / 4) }} + + + {{ planned[0]?.date?.slice(5) || '' }} + + + {{ planned[Math.floor(planned.length/2)]?.date?.slice(5) || '' }} + + + {{ planned[planned.length - 1]?.date?.slice(5) || '' }} + + + + Plan + + Real + + + + + diff --git a/src/services/blocker-log.ts b/src/services/blocker-log.ts new file mode 100644 index 0000000..4357193 --- /dev/null +++ b/src/services/blocker-log.ts @@ -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 = { + client: 'Cliente', + pm: 'PM / Alcance', + tech: 'Técnico', + external: 'Terceros', + other: 'Otro', +} + +const CATEGORY_COLORS: Record = { + 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 { + 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 { + return (db as any).table('blockers').where('projectId').equals(projectId).toArray() +} + +export async function getActiveBlockers(projectId: number): Promise { + const all = await getBlockers(projectId) + return all.filter(b => !b.resolvedAt) +} + +export async function getBlockersByCategory(projectId: number): Promise> { + const all = await getBlockers(projectId) + const grouped: Record = {} + for (const b of all) { + const cat = b.category || 'other' + if (!grouped[cat]) grouped[cat] = [] + grouped[cat].push(b) + } + return grouped as Record +} + +export async function resolveBlocker(id: number, resolution: string, resolvedAt: string): Promise { + 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 +}> { + const all = await getBlockers(projectId) + const active = all.filter(b => !b.resolvedAt) + const byCategory: Record = {} + + 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, + } +} diff --git a/src/services/db.ts b/src/services/db.ts index 0e3e6d2..123288a 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -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 project_docs: Dexie.Table @@ -149,9 +165,10 @@ const db = new Dexie('alpha-core') as Dexie & { cell_members: Dexie.Table user_stories: Dexie.Table prompts: Dexie.Table + blockers: Dexie.Table } -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 diff --git a/src/services/s-curve.ts b/src/services/s-curve.ts new file mode 100644 index 0000000..783051c --- /dev/null +++ b/src/services/s-curve.ts @@ -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] +}
{{ metrics.totalSpPlanned }}
{{ metrics.velocityPerWeek }} SP
+ {{ metrics.spi }} +
{{ metrics.estimatedEndDate || '—' }}