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:
@@ -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: '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
@@ -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,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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user