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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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