Tareas #8569
Actualizado por Anielkis Herrera hace 17 días
h1. Tarea: TimelineWidget — Contenedor y Ensamblaje
h2. Contexto
Esta tarea cubre la creación del *widget completo de Línea de Tiempo*: el contenedor orquestador, la barra horizontal, la lista de eventos por equipo, la pista central y el reloj del partido.
La descripción detallada Reference HTML: %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html%
*Prerequisito:* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}timeline-items.md% (todos los %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}*TimeCard% deben existir antes de ensamblar el widget).
---
h2. 1. Componentes a Crear
|_. Componente |_. Ruta |_. Categoría |
|@TimelineBar.vue@|@components/sports/football/general/@|Negocio General|
|@TimelineEventList.vue@|@components/sports/football/specific/@|Negocio Específico|
|@TimelineTrack.vue@|@components/sports/football/specific/@|Negocio Específico|
|@MatchClock.vue@|@components/sports/football/specific/@|Negocio Específico|
|@TimelineWidget.vue@|@src/widgets/timeline/@|Widget (entry point)|
---
h2. 2. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineEventList.vue% — Lista de Eventos por Equipo
*Responsabilidad:* Renderiza la lista de eventos de *un equipo* (home o away), calcula la posición %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}left% y el apilamiento vertical de cada evento, y selecciona el componente %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}*TimeCard% correcto según el tipo.
*Referencia (Bootstrap 5):*
<pre><code class="html">
<ul class="list-unstyled d-flex position-relative m-0 p-0 side-home">
<!-- Tarjeta amarilla min 23 -->
<li class="position-absolute" style="left: 149px; bottom: 0px;">
<div class="badge bg-warning text-dark px-1 py-1">Y</div>
</li>
<!-- Gol min 44 -->
<li class="position-absolute" style="left: 290px; bottom: 0px;">
<img src="goal.svg" class="w-20" />
</li>
<!-- Sustitución min 69 (apilada) -->
<li class="position-absolute" style="left: 492px; bottom: 0px;">
<i class="bi bi-arrow-left-right text-success"></i>
</li>
<li class="position-absolute" style="left: 492px; bottom: 17px;">
<i class="bi bi-arrow-left-right text-success"></i>
</li>
</ul>
</code></pre>
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@events@|@Array<MatchEvent>@|Sí|—|Lista de eventos del equipo|
|@side@|@String@|Sí|—|@'home'@ \|@'away'@|
|@matchDuration@|@Number@|No|@90@|Minutos totales del partido (para escalar posiciones)|
|@totalWidthPx@|@Number@|No|@700@|Ancho disponible de la barra en px|
h3. Lógica de posicionamiento
<pre><code class="js">
// positionPx — posición horizontal proporcional al minuto
const positionPx = (event) => {
return (event.minutes / props.matchDuration) * props.totalWidthPx
}
// stackOffset — apilamiento vertical para eventos simultáneos
// Agrupa eventos por minuto y asigna offset incremental de 17px
const enrichedEvents = computed(() => {
const minuteCount = {}
return props.events.map((e) => {
const key = `${e.minutes}`
minuteCount[key] = minuteCount[key] ?? 0
const offset = minuteCount[key] * 17
minuteCount[key]++
return { ...e, positionPx: positionPx(e), stackOffset: offset }
})
})
</code></pre>
h3. Mapeo de tipo → componente
|_. @event.type@ |_. TypeId (JSON) |_. Componente |
|@'goal'@|9, 16|@GoalTimeCard@|
|@'yellow-card'@|3|@CardTimeCard@ con @cardType="yellow"@|
|@'red-card'@|5|@CardTimeCard@ con @cardType="red"@|
|@'yellow-red'@|-|@CardTimeCard@ con @cardType="yellow-red"@|
|@'substitution'@|7|@SubstitutionTimeCard@|
|@'var'@|568|@VarTimeCard@|
h3. Implementación completa
<pre><code class="vue">
<!-- src/components/sports/football/specific/TimelineEventList.vue -->
<template>
<ul :class="['list-unstyled m-0 p-0 position-absolute w-100', `side-${side}`]">
<component
v-for="event in enrichedEvents"
:key="event.incidenceId"
:is="componentFor(event.typeId)"
:event="event"
:positionPx="event.positionPx"
:stackOffset="event.stackOffset"
:homeTeamId="homeTeamId"
@item-click="$emit('event-click', $event)"
/>
</ul>
</template>
<script setup>
import { computed } from 'vue'
import GoalTimeCard from './GoalTimeCard.vue'
import CardTimeCard from './CardTimeCard.vue'
import SubstitutionTimeCard from './SubstitutionTimeCard.vue'
import VarTimeCard from './VarTimeCard.vue'
const props = defineProps({
events: { type: Array, required: true },
side: { type: String, required: true },
homeTeamId: { type: Number, required: true },
matchDuration: { type: Number, default: 90 },
totalWidthPx: { type: Number, default: 700 },
})
defineEmits(['event-click'])
// El TYPE_MAP idealmente mapea los typeId del JSON de producción
const TYPE_MAP = {
9: GoalTimeCard, // Goal by play
16: GoalTimeCard, // Goal
3: CardTimeCard, // Yellow card
5: CardTimeCard, // Red card
7: SubstitutionTimeCard, // Substitution
568: VarTimeCard, // Start VAR
}
const componentFor = (typeId) => TYPE_MAP[typeId] ?? null
const enrichedEvents = computed(() => {
const minuteCount = {}
return props.events.map((e) => {
const key = `${e.minutes}`
minuteCount[key] = minuteCount[key] ?? 0
const stackOffset = minuteCount[key] * 17
minuteCount[key]++
const positionPx = (e.minutes / props.matchDuration) * props.totalWidthPx
return { ...e, positionPx, stackOffset }
})
})
</script>
</code></pre>
*Ejemplo de uso:*
<pre><code class="vue">
<TimelineEventList
:events="[
{
incidenceId: 52055284,
typeId: 3,
minutes: 68,
playerName: 'Konrad Laimer',
card: 'Yellow',
teamId: 617,
},
{
incidenceId: 52055430,
typeId: 9,
minutes: 77,
playerName: 'Désiré Doué',
assistanceBy: 'João Pedro Gonçalves Neves',
teamId: 1370,
},
{
incidenceId: 52054580,
typeId: 7,
minutes: 33,
offName: 'Josip Stanisic',
inName: 'Sacha Boey',
teamId: 617,
},
]"
side="home"
:homeTeamId="1370"
:matchDuration="95"
:totalWidthPx="700"
@event-click="handleEventClick"
/>
</code></pre>
---
h2. 3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineTrack.vue% — Barra Central con Pitidos
*Responsabilidad:* Renderiza la línea horizontal central y los pitidos de árbitro (final de primera parte, tiempo completo, etc.).
*Referencia (Bootstrap 5):*
<pre><code class="html">
<div class="position-relative w-100 bg-secondary-subtle rounded-pill" style="height: 4px;">
<div class="position-absolute bg-dark h-100" style="width: 100%;"></div>
<!-- Marcador de pitido -->
<div class="position-absolute translate-middle-x" style="left: 336px; top: -10px;">
<i class="bi bi-megaphone-fill text-muted small"></i>
</div>
</div>
</code></pre>
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@whistles@|@Array@|No|@[]@|@[{ id, minute, extraTime, periodLabel }]@|
|@matchDuration@|@Number@|No|@90@|Para calcular posición de pitidos|
|@totalWidthPx@|@Number@|No|@700@|Ancho de la pista en px|
h3. Implementación
<pre><code class="vue">
<!-- src/components/sports/football/specific/TimelineTrack.vue -->
<template>
<div class="timeline-track position-relative w-100 my-2">
<div class="track-line bg-dark-subtle position-absolute w-100" style="height: 2px; top: 50%" />
<WhistleTimeCard
v-for="whistle in enrichedWhistles"
:key="whistle.incidenceId"
:event="whistle"
:positionPx="whistle.positionPx"
@item-click="$emit('whistle-click', $event)"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import WhistleTimeCard from './WhistleTimeCard.vue'
const props = defineProps({
whistles: { type: Array, default: () => [] },
matchDuration: { type: Number, default: 90 },
totalWidthPx: { type: Number, default: 700 },
})
defineEmits(['whistle-click'])
const enrichedWhistles = computed(() =>
props.whistles.map((w) => ({
...w,
positionPx: (w.minutes / props.matchDuration) * props.totalWidthPx,
})),
)
</script>
</code></pre>
*Ejemplo de uso:*
<pre><code class="vue">
<!-- Pitido de inicio de partido -->
<TimelineTrack
:whistles="[{ incidenceId: 52054099, minutes: 0, type: 'Begin of match' }]"
:matchDuration="103"
:totalWidthPx="700"
/>
</code></pre>
---
h2. 4. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchClock.vue% — Estado del Reloj del Partido
*Responsabilidad:* Muestra la etiqueta del período actual (TC, MT, minuto en curso) de forma concisa.
*Referencia (Bootstrap 5):*
<pre><code class="html">
<div class="d-flex align-items-center gap-2">
<div class="badge bg-dark rounded-circle p-2 fs-6">
<span title="Tiempo completo">TC</span>
</div>
</div>
</code></pre>
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@period@|@String@|Sí|—|@'FullTime'@ \|@'HalfTime'@ \|@'InProgress'@ \|@'PreMatch'@|
|@minute@|@Number@|No|@null@|Minuto actual (solo cuando @period === 'InProgress'@)|
|@extraTime@|@Number@|No|@0@|Tiempo añadido actual|
h3. Mapeo de período a etiqueta
|_. @period@ |_. Abreviatura |_. Título (@abbr@) |
|@'FullTime'@|@TC@|Tiempo Completo|
|@'HalfTime'@|@MT@|Medio Tiempo|
|@'InProgress'@|@{minute}'@ ó @{minute}+{extra}'@|En curso|
|@'PreMatch'@|No muestra reloj|—|
h3. Implementación
<pre><code class="vue">
<!-- src/components/sports/football/specific/MatchClock.vue -->
<template>
<div v-if="clockLabel" class="match-clock d-flex align-items-center">
<span
class="badge bg-dark rounded-circle fw-bold d-flex align-items-center justify-content-center"
style="width: 32px; height: 32px; font-size: 0.75rem"
:title="clockTitle"
>
{{ clockLabel }}
</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
period: { type: String, required: true },
minute: { type: Number, default: null },
extraTime: { type: Number, default: 0 },
})
const PERIOD_CONFIG = {
FullTime: { label: 'TC', title: 'Tiempo Completo' },
HalfTime: { label: 'MT', title: 'Medio Tiempo' },
InProgress: { label: null, title: 'En curso' },
PreMatch: { label: null, title: '' },
}
const clockLabel = computed(() => {
if (props.period === 'InProgress' && props.minutes != null) {
return formatMatchTime(props.minutes)
}
return PERIOD_CONFIG[props.period]?.label ?? null
})
const clockTitle = computed(() => PERIOD_CONFIG[props.period]?.title ?? '')
</script>
</code></pre>
*Ejemplos de uso:*
<pre><code class="vue">
<!-- Partido terminado -->
<MatchClock period="FullTime" />
<!-- Muestra: TC (con abbr "Tiempo Completo") -->
<!-- En curso, minuto 67 -->
<abbr :title="clockTitle">67'</abbr>
</code></pre>
---
h2. 5. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar.vue% — Orquestador Principal
*Responsabilidad:* Ensambla todos los sub-componentes: escudos de equipo, listas de eventos home/away, la pista central y el reloj.
*Referencia (Bootstrap 5):*
<pre><code class="html">
<div class="container-fluid py-3 shadow-sm bg-light rounded d-flex align-items-center gap-3">
<!-- Escudos -->
<div class="d-flex flex-column gap-1">
<img src="home.png" class="img-thumbnail rounded-circle" style="width: 24px" />
<img src="away.png" class="img-thumbnail rounded-circle" style="width: 24px" />
</div>
<!-- Barra -->
<div class="flex-grow-1 position-relative" style="height: 60px">
<!-- Events Home -->
<ul class="position-absolute w-100 list-unstyled" style="bottom: 30px">
...
</ul>
<!-- Pista -->
<div class="position-absolute w-100 bg-secondary" style="height: 2px; top: 29px"></div>
<!-- Events Away -->
<ul class="position-absolute w-100 list-unstyled" style="top: 30px">
...
</ul>
</div>
<!-- Reloj -->
<div class="ms-auto badge bg-dark p-2">TC</div>
</div>
</code></pre>
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@homeTeam@|@Object@|Sí|—|@{ teamId, name, teamType }@ para los escudos|
|@awayTeam@|@Object@|Sí|—|igual|
|@homeEvents@|@Array<MatchEvent>@|Sí|@[]@|Eventos del equipo local|
|@awayEvents@|@Array<MatchEvent>@|Sí|@[]@|Eventos del equipo visitante|
|@whistleEvents@|@Array@|No|@[]@|Pitidos de árbitro para la pista central|
|@matchPeriod@|@String@|Sí|—|@'FullTime'@ \|@'HalfTime'@ \|@'InProgress'@ \|@'PreMatch'@|
|@currentMinute@|@Number@|No|@null@|Minuto actual (si está en curso)|
|@matchDuration@|@Number@|No|@90@|Para escalar posiciones|
|@extraTimeDuration@|@Number@|No|@0@|Minutos de tiempo añadido totales|
h3. Implementación
<pre><code class="vue">
<!-- src/components/sports/football/general/TimelineBar.vue -->
<template>
<div class="timeline-bar d-flex align-items-center p-3 bg-white rounded shadow-sm gap-3">
<!-- Escudos a los lados de la barra -->
<div class="team-thumbs d-flex flex-column gap-2 border-end pe-3">
<div class="thumb home">
<TeamImage :teamId="homeTeam.teamId" :teamType="homeTeam.teamType" :imgSize="24" />
</div>
<div class="thumb away">
<TeamImage :teamId="awayTeam.teamId" :teamType="awayTeam.teamType" :imgSize="24" />
</div>
</div>
<!-- Área de eventos -->
<div class="timeline-content flex-grow-1 position-relative" style="height: 80px" ref="barRef">
<TimelineEventList
:events="homeEvents"
side="home"
:homeTeamId="homeTeam.teamId"
:matchDuration="effectiveDuration"
:totalWidthPx="barWidth"
@event-click="$emit('event-click', $event)"
/>
<TimelineTrack
:whistles="whistleEvents"
:matchDuration="effectiveDuration"
:totalWidthPx="barWidth"
/>
<TimelineEventList
:events="awayEvents"
side="away"
:homeTeamId="homeTeam.teamId"
:matchDuration="effectiveDuration"
:totalWidthPx="barWidth"
@event-click="$emit('event-click', $event)"
/>
</div>
<!-- Reloj del partido -->
<div class="border-start ps-3">
<MatchClock :period="matchPeriod" :minutes="currentMinutes" />
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import TeamImage from '@/components/sports/TeamImage.vue'
import TimelineEventList from '../specific/TimelineEventList.vue'
import TimelineTrack from '../specific/TimelineTrack.vue'
import MatchClock from '../specific/MatchClock.vue'
const props = defineProps({
homeTeam: { type: Object, required: true },
awayTeam: { type: Object, required: true },
homeEvents: { type: Array, default: () => [] },
awayEvents: { type: Array, default: () => [] },
whistleEvents: { type: Array, default: () => [] },
matchPeriod: { type: String, required: true },
currentMinute: { type: Number, default: null },
matchDuration: { type: Number, default: 90 },
extraTimeDuration: { type: Number, default: 0 },
})
defineEmits(['event-click'])
// Ancho real de la barra — se lleva en https://docs.google.com/document/d/1qJEuiRw4AEOn3RndT0I76Tu43ZI5Y_jrlZ2lfWXiptc/edit?usp=sharing mide con ResizeObserver
const barRef = ref(null)
const barWidth = ref(700)
const effectiveDuration = computed(() => props.matchDuration + props.extraTimeDuration)
let observer
onMounted(() => {
observer = new ResizeObserver(([entry]) => {
barWidth.value = entry.contentRect.width
})
if (barRef.value) observer.observe(barRef.value)
})
onUnmounted(() => observer?.disconnect())
</script>
</code></pre>
*Ejemplo de uso:*
<pre><code class="vue">
<TimelineBar
:homeTeam="{ teamId: 9319, name: 'Deportivo Riestra', teamType: 'club' }"
:awayTeam="{ teamId: 14236, name: 'Deportivo Maipú', teamType: 'club' }"
:homeEvents="homeEvents"
:awayEvents="awayEvents"
:whistleEvents="[{ id: 'w1', minute: 45, extraTime: 5, periodLabel: '45+5\'' }]"
matchPeriod="FullTime"
:matchDuration="90"
:extraTimeDuration="5"
@event-click="openEventDetail"
/>
</code></pre>
---
h2. 6. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget.vue% — Entry Point del Widget
*Responsabilidad:* Widget autocontenido. Carga los datos del partido desde la API o store, los normaliza y los pasa a %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchHeader% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar% . Gestiona estados de carga y error.
*Referencia HTML:* Estructura completa de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html%
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@matchId@|`Number\|String`|Sí|—|ID del partido|
|@lang@|@String@|No|@'es'@|Locale para fechas y textos|
|@showHeader@|@Boolean@|No|@true@|Mostrar/ocultar el @MatchHeader@|
h3. Implementación
<pre><code class="vue">
<!-- src/widgets/timeline/TimelineWidget.vue -->
<template>
<ErrorBoundary>
<div class="timeline-widget">
<LoadingSpinner v-if="loading" />
<template v-else-if="matchData">
<MatchHeader v-if="showHeader" v-bind="matchHeaderProps" />
<TimelineBar v-bind="timelineBarProps" @event-click="selectedEvent = $event" />
</template>
<p v-else class="error-message">No se pudo cargar el partido.</p>
</div>
</ErrorBoundary>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import MatchHeader from '@/components/sports/football/general/MatchHeader.vue'
import TimelineBar from '@/components/sports/football/general/TimelineBar.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ErrorBoundary from '@/components/common/ErrorBoundary.vue'
import { useMatchData } from '@/composables/useMatchData'
const props = defineProps({
matchId: { type: [Number, String], required: true },
lang: { type: String, default: 'es' },
showHeader: { type: Boolean, default: true },
})
const { matchData, loading, fetchMatch } = useMatchData()
const selectedEvent = ref(null)
onMounted(() => fetchMatch(props.matchId))
const matchHeaderProps = computed(() => ({
homeTeam: matchData.value?.homeTeam,
awayTeam: matchData.value?.awayTeam,
homeScore: matchData.value?.homeScore,
awayScore: matchData.value?.awayScore,
competition: matchData.value?.competition,
matchDate: matchData.value?.matchDate,
stadium: matchData.value?.stadium,
referee: matchData.value?.referee,
dateLocale: props.lang,
}))
const timelineBarProps = computed(() => ({
homeTeam: matchData.value?.homeTeam,
awayTeam: matchData.value?.awayTeam,
homeEvents: matchData.value?.homeEvents ?? [],
awayEvents: matchData.value?.awayEvents ?? [],
whistleEvents: matchData.value?.whistleEvents ?? [],
matchPeriod: matchData.value?.period,
currentMinute: matchData.value?.currentMinute,
matchDuration: matchData.value?.matchDuration ?? 90,
}))
</script>
</code></pre>
---
h2. 7. Modelo de Datos — %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchEvent%
<pre><code class="js">
// Estructura normalizada de evento que consumen los *TimeCard y TimelineEventList
{
incidenceId: Number, // Ej: 52055430
typeId: Number, // 9 (Goal), 7 (Sub), 3 (Yellow Card), 5 (Red Card), 568 (VAR)
minutes: Number, // Minuto del partido (ej: 77)
teamId: Number, // ID del equipo
// Campos por tipo (mapeados directo del JSON de producción):
playerName: String, // goal, card
assistanceBy: String, // solo goal
card: String, // 'Yellow' | 'Red' — solo card
reason: String, // card, var
offName: String, // solo substitution
inName: String, // solo substitution
type: String, // whistle (tipo de pitido) o VAR status
}
</code></pre>
---
h2. 8. Criterios de Calidad
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget% muestra %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}LoadingSpinner% mientras carga y usa %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}ErrorBoundary% para errores.
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar% no conoce el %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}matchId% — solo recibe datos procesados.
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineEventList% es el *único responsable* de calcular %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}positionPx% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}stackOffset% .
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchClock% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineTrack% son componentes de solo presentación (< 80 líneas).
* Todos los componentes tienen historia Storybook con datos de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html% .
---
h2. 9. Orden de Implementación
# ✅ Completar tarea %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}timeline-items.md%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchClock.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineTrack.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineEventList.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/widgets/timeline/timeline-widget.js%
# ✅ Agregar historias Storybook para %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar%
---
h2. Contexto
Esta tarea cubre la creación del *widget completo de Línea de Tiempo*: el contenedor orquestador, la barra horizontal, la lista de eventos por equipo, la pista central y el reloj del partido.
La descripción detallada Reference HTML: %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html%
*Prerequisito:* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}timeline-items.md% (todos los %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}*TimeCard% deben existir antes de ensamblar el widget).
---
h2. 1. Componentes a Crear
|_. Componente |_. Ruta |_. Categoría |
|@TimelineBar.vue@|@components/sports/football/general/@|Negocio General|
|@TimelineEventList.vue@|@components/sports/football/specific/@|Negocio Específico|
|@TimelineTrack.vue@|@components/sports/football/specific/@|Negocio Específico|
|@MatchClock.vue@|@components/sports/football/specific/@|Negocio Específico|
|@TimelineWidget.vue@|@src/widgets/timeline/@|Widget (entry point)|
---
h2. 2. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineEventList.vue% — Lista de Eventos por Equipo
*Responsabilidad:* Renderiza la lista de eventos de *un equipo* (home o away), calcula la posición %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}left% y el apilamiento vertical de cada evento, y selecciona el componente %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}*TimeCard% correcto según el tipo.
*Referencia (Bootstrap 5):*
<pre><code class="html">
<ul class="list-unstyled d-flex position-relative m-0 p-0 side-home">
<!-- Tarjeta amarilla min 23 -->
<li class="position-absolute" style="left: 149px; bottom: 0px;">
<div class="badge bg-warning text-dark px-1 py-1">Y</div>
</li>
<!-- Gol min 44 -->
<li class="position-absolute" style="left: 290px; bottom: 0px;">
<img src="goal.svg" class="w-20" />
</li>
<!-- Sustitución min 69 (apilada) -->
<li class="position-absolute" style="left: 492px; bottom: 0px;">
<i class="bi bi-arrow-left-right text-success"></i>
</li>
<li class="position-absolute" style="left: 492px; bottom: 17px;">
<i class="bi bi-arrow-left-right text-success"></i>
</li>
</ul>
</code></pre>
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@events@|@Array<MatchEvent>@|Sí|—|Lista de eventos del equipo|
|@side@|@String@|Sí|—|@'home'@ \|@'away'@|
|@matchDuration@|@Number@|No|@90@|Minutos totales del partido (para escalar posiciones)|
|@totalWidthPx@|@Number@|No|@700@|Ancho disponible de la barra en px|
h3. Lógica de posicionamiento
<pre><code class="js">
// positionPx — posición horizontal proporcional al minuto
const positionPx = (event) => {
return (event.minutes / props.matchDuration) * props.totalWidthPx
}
// stackOffset — apilamiento vertical para eventos simultáneos
// Agrupa eventos por minuto y asigna offset incremental de 17px
const enrichedEvents = computed(() => {
const minuteCount = {}
return props.events.map((e) => {
const key = `${e.minutes}`
minuteCount[key] = minuteCount[key] ?? 0
const offset = minuteCount[key] * 17
minuteCount[key]++
return { ...e, positionPx: positionPx(e), stackOffset: offset }
})
})
</code></pre>
h3. Mapeo de tipo → componente
|_. @event.type@ |_. TypeId (JSON) |_. Componente |
|@'goal'@|9, 16|@GoalTimeCard@|
|@'yellow-card'@|3|@CardTimeCard@ con @cardType="yellow"@|
|@'red-card'@|5|@CardTimeCard@ con @cardType="red"@|
|@'yellow-red'@|-|@CardTimeCard@ con @cardType="yellow-red"@|
|@'substitution'@|7|@SubstitutionTimeCard@|
|@'var'@|568|@VarTimeCard@|
h3. Implementación completa
<pre><code class="vue">
<!-- src/components/sports/football/specific/TimelineEventList.vue -->
<template>
<ul :class="['list-unstyled m-0 p-0 position-absolute w-100', `side-${side}`]">
<component
v-for="event in enrichedEvents"
:key="event.incidenceId"
:is="componentFor(event.typeId)"
:event="event"
:positionPx="event.positionPx"
:stackOffset="event.stackOffset"
:homeTeamId="homeTeamId"
@item-click="$emit('event-click', $event)"
/>
</ul>
</template>
<script setup>
import { computed } from 'vue'
import GoalTimeCard from './GoalTimeCard.vue'
import CardTimeCard from './CardTimeCard.vue'
import SubstitutionTimeCard from './SubstitutionTimeCard.vue'
import VarTimeCard from './VarTimeCard.vue'
const props = defineProps({
events: { type: Array, required: true },
side: { type: String, required: true },
homeTeamId: { type: Number, required: true },
matchDuration: { type: Number, default: 90 },
totalWidthPx: { type: Number, default: 700 },
})
defineEmits(['event-click'])
// El TYPE_MAP idealmente mapea los typeId del JSON de producción
const TYPE_MAP = {
9: GoalTimeCard, // Goal by play
16: GoalTimeCard, // Goal
3: CardTimeCard, // Yellow card
5: CardTimeCard, // Red card
7: SubstitutionTimeCard, // Substitution
568: VarTimeCard, // Start VAR
}
const componentFor = (typeId) => TYPE_MAP[typeId] ?? null
const enrichedEvents = computed(() => {
const minuteCount = {}
return props.events.map((e) => {
const key = `${e.minutes}`
minuteCount[key] = minuteCount[key] ?? 0
const stackOffset = minuteCount[key] * 17
minuteCount[key]++
const positionPx = (e.minutes / props.matchDuration) * props.totalWidthPx
return { ...e, positionPx, stackOffset }
})
})
</script>
</code></pre>
*Ejemplo de uso:*
<pre><code class="vue">
<TimelineEventList
:events="[
{
incidenceId: 52055284,
typeId: 3,
minutes: 68,
playerName: 'Konrad Laimer',
card: 'Yellow',
teamId: 617,
},
{
incidenceId: 52055430,
typeId: 9,
minutes: 77,
playerName: 'Désiré Doué',
assistanceBy: 'João Pedro Gonçalves Neves',
teamId: 1370,
},
{
incidenceId: 52054580,
typeId: 7,
minutes: 33,
offName: 'Josip Stanisic',
inName: 'Sacha Boey',
teamId: 617,
},
]"
side="home"
:homeTeamId="1370"
:matchDuration="95"
:totalWidthPx="700"
@event-click="handleEventClick"
/>
</code></pre>
---
h2. 3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineTrack.vue% — Barra Central con Pitidos
*Responsabilidad:* Renderiza la línea horizontal central y los pitidos de árbitro (final de primera parte, tiempo completo, etc.).
*Referencia (Bootstrap 5):*
<pre><code class="html">
<div class="position-relative w-100 bg-secondary-subtle rounded-pill" style="height: 4px;">
<div class="position-absolute bg-dark h-100" style="width: 100%;"></div>
<!-- Marcador de pitido -->
<div class="position-absolute translate-middle-x" style="left: 336px; top: -10px;">
<i class="bi bi-megaphone-fill text-muted small"></i>
</div>
</div>
</code></pre>
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@whistles@|@Array@|No|@[]@|@[{ id, minute, extraTime, periodLabel }]@|
|@matchDuration@|@Number@|No|@90@|Para calcular posición de pitidos|
|@totalWidthPx@|@Number@|No|@700@|Ancho de la pista en px|
h3. Implementación
<pre><code class="vue">
<!-- src/components/sports/football/specific/TimelineTrack.vue -->
<template>
<div class="timeline-track position-relative w-100 my-2">
<div class="track-line bg-dark-subtle position-absolute w-100" style="height: 2px; top: 50%" />
<WhistleTimeCard
v-for="whistle in enrichedWhistles"
:key="whistle.incidenceId"
:event="whistle"
:positionPx="whistle.positionPx"
@item-click="$emit('whistle-click', $event)"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import WhistleTimeCard from './WhistleTimeCard.vue'
const props = defineProps({
whistles: { type: Array, default: () => [] },
matchDuration: { type: Number, default: 90 },
totalWidthPx: { type: Number, default: 700 },
})
defineEmits(['whistle-click'])
const enrichedWhistles = computed(() =>
props.whistles.map((w) => ({
...w,
positionPx: (w.minutes / props.matchDuration) * props.totalWidthPx,
})),
)
</script>
</code></pre>
*Ejemplo de uso:*
<pre><code class="vue">
<!-- Pitido de inicio de partido -->
<TimelineTrack
:whistles="[{ incidenceId: 52054099, minutes: 0, type: 'Begin of match' }]"
:matchDuration="103"
:totalWidthPx="700"
/>
</code></pre>
---
h2. 4. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchClock.vue% — Estado del Reloj del Partido
*Responsabilidad:* Muestra la etiqueta del período actual (TC, MT, minuto en curso) de forma concisa.
*Referencia (Bootstrap 5):*
<pre><code class="html">
<div class="d-flex align-items-center gap-2">
<div class="badge bg-dark rounded-circle p-2 fs-6">
<span title="Tiempo completo">TC</span>
</div>
</div>
</code></pre>
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@period@|@String@|Sí|—|@'FullTime'@ \|@'HalfTime'@ \|@'InProgress'@ \|@'PreMatch'@|
|@minute@|@Number@|No|@null@|Minuto actual (solo cuando @period === 'InProgress'@)|
|@extraTime@|@Number@|No|@0@|Tiempo añadido actual|
h3. Mapeo de período a etiqueta
|_. @period@ |_. Abreviatura |_. Título (@abbr@) |
|@'FullTime'@|@TC@|Tiempo Completo|
|@'HalfTime'@|@MT@|Medio Tiempo|
|@'InProgress'@|@{minute}'@ ó @{minute}+{extra}'@|En curso|
|@'PreMatch'@|No muestra reloj|—|
h3. Implementación
<pre><code class="vue">
<!-- src/components/sports/football/specific/MatchClock.vue -->
<template>
<div v-if="clockLabel" class="match-clock d-flex align-items-center">
<span
class="badge bg-dark rounded-circle fw-bold d-flex align-items-center justify-content-center"
style="width: 32px; height: 32px; font-size: 0.75rem"
:title="clockTitle"
>
{{ clockLabel }}
</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
period: { type: String, required: true },
minute: { type: Number, default: null },
extraTime: { type: Number, default: 0 },
})
const PERIOD_CONFIG = {
FullTime: { label: 'TC', title: 'Tiempo Completo' },
HalfTime: { label: 'MT', title: 'Medio Tiempo' },
InProgress: { label: null, title: 'En curso' },
PreMatch: { label: null, title: '' },
}
const clockLabel = computed(() => {
if (props.period === 'InProgress' && props.minutes != null) {
return formatMatchTime(props.minutes)
}
return PERIOD_CONFIG[props.period]?.label ?? null
})
const clockTitle = computed(() => PERIOD_CONFIG[props.period]?.title ?? '')
</script>
</code></pre>
*Ejemplos de uso:*
<pre><code class="vue">
<!-- Partido terminado -->
<MatchClock period="FullTime" />
<!-- Muestra: TC (con abbr "Tiempo Completo") -->
<!-- En curso, minuto 67 -->
<abbr :title="clockTitle">67'</abbr>
</code></pre>
---
h2. 5. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar.vue% — Orquestador Principal
*Responsabilidad:* Ensambla todos los sub-componentes: escudos de equipo, listas de eventos home/away, la pista central y el reloj.
*Referencia (Bootstrap 5):*
<pre><code class="html">
<div class="container-fluid py-3 shadow-sm bg-light rounded d-flex align-items-center gap-3">
<!-- Escudos -->
<div class="d-flex flex-column gap-1">
<img src="home.png" class="img-thumbnail rounded-circle" style="width: 24px" />
<img src="away.png" class="img-thumbnail rounded-circle" style="width: 24px" />
</div>
<!-- Barra -->
<div class="flex-grow-1 position-relative" style="height: 60px">
<!-- Events Home -->
<ul class="position-absolute w-100 list-unstyled" style="bottom: 30px">
...
</ul>
<!-- Pista -->
<div class="position-absolute w-100 bg-secondary" style="height: 2px; top: 29px"></div>
<!-- Events Away -->
<ul class="position-absolute w-100 list-unstyled" style="top: 30px">
...
</ul>
</div>
<!-- Reloj -->
<div class="ms-auto badge bg-dark p-2">TC</div>
</div>
</code></pre>
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@homeTeam@|@Object@|Sí|—|@{ teamId, name, teamType }@ para los escudos|
|@awayTeam@|@Object@|Sí|—|igual|
|@homeEvents@|@Array<MatchEvent>@|Sí|@[]@|Eventos del equipo local|
|@awayEvents@|@Array<MatchEvent>@|Sí|@[]@|Eventos del equipo visitante|
|@whistleEvents@|@Array@|No|@[]@|Pitidos de árbitro para la pista central|
|@matchPeriod@|@String@|Sí|—|@'FullTime'@ \|@'HalfTime'@ \|@'InProgress'@ \|@'PreMatch'@|
|@currentMinute@|@Number@|No|@null@|Minuto actual (si está en curso)|
|@matchDuration@|@Number@|No|@90@|Para escalar posiciones|
|@extraTimeDuration@|@Number@|No|@0@|Minutos de tiempo añadido totales|
h3. Implementación
<pre><code class="vue">
<!-- src/components/sports/football/general/TimelineBar.vue -->
<template>
<div class="timeline-bar d-flex align-items-center p-3 bg-white rounded shadow-sm gap-3">
<!-- Escudos a los lados de la barra -->
<div class="team-thumbs d-flex flex-column gap-2 border-end pe-3">
<div class="thumb home">
<TeamImage :teamId="homeTeam.teamId" :teamType="homeTeam.teamType" :imgSize="24" />
</div>
<div class="thumb away">
<TeamImage :teamId="awayTeam.teamId" :teamType="awayTeam.teamType" :imgSize="24" />
</div>
</div>
<!-- Área de eventos -->
<div class="timeline-content flex-grow-1 position-relative" style="height: 80px" ref="barRef">
<TimelineEventList
:events="homeEvents"
side="home"
:homeTeamId="homeTeam.teamId"
:matchDuration="effectiveDuration"
:totalWidthPx="barWidth"
@event-click="$emit('event-click', $event)"
/>
<TimelineTrack
:whistles="whistleEvents"
:matchDuration="effectiveDuration"
:totalWidthPx="barWidth"
/>
<TimelineEventList
:events="awayEvents"
side="away"
:homeTeamId="homeTeam.teamId"
:matchDuration="effectiveDuration"
:totalWidthPx="barWidth"
@event-click="$emit('event-click', $event)"
/>
</div>
<!-- Reloj del partido -->
<div class="border-start ps-3">
<MatchClock :period="matchPeriod" :minutes="currentMinutes" />
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import TeamImage from '@/components/sports/TeamImage.vue'
import TimelineEventList from '../specific/TimelineEventList.vue'
import TimelineTrack from '../specific/TimelineTrack.vue'
import MatchClock from '../specific/MatchClock.vue'
const props = defineProps({
homeTeam: { type: Object, required: true },
awayTeam: { type: Object, required: true },
homeEvents: { type: Array, default: () => [] },
awayEvents: { type: Array, default: () => [] },
whistleEvents: { type: Array, default: () => [] },
matchPeriod: { type: String, required: true },
currentMinute: { type: Number, default: null },
matchDuration: { type: Number, default: 90 },
extraTimeDuration: { type: Number, default: 0 },
})
defineEmits(['event-click'])
// Ancho real de la barra — se lleva en https://docs.google.com/document/d/1qJEuiRw4AEOn3RndT0I76Tu43ZI5Y_jrlZ2lfWXiptc/edit?usp=sharing mide con ResizeObserver
const barRef = ref(null)
const barWidth = ref(700)
const effectiveDuration = computed(() => props.matchDuration + props.extraTimeDuration)
let observer
onMounted(() => {
observer = new ResizeObserver(([entry]) => {
barWidth.value = entry.contentRect.width
})
if (barRef.value) observer.observe(barRef.value)
})
onUnmounted(() => observer?.disconnect())
</script>
</code></pre>
*Ejemplo de uso:*
<pre><code class="vue">
<TimelineBar
:homeTeam="{ teamId: 9319, name: 'Deportivo Riestra', teamType: 'club' }"
:awayTeam="{ teamId: 14236, name: 'Deportivo Maipú', teamType: 'club' }"
:homeEvents="homeEvents"
:awayEvents="awayEvents"
:whistleEvents="[{ id: 'w1', minute: 45, extraTime: 5, periodLabel: '45+5\'' }]"
matchPeriod="FullTime"
:matchDuration="90"
:extraTimeDuration="5"
@event-click="openEventDetail"
/>
</code></pre>
---
h2. 6. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget.vue% — Entry Point del Widget
*Responsabilidad:* Widget autocontenido. Carga los datos del partido desde la API o store, los normaliza y los pasa a %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchHeader% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar% . Gestiona estados de carga y error.
*Referencia HTML:* Estructura completa de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html%
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@matchId@|`Number\|String`|Sí|—|ID del partido|
|@lang@|@String@|No|@'es'@|Locale para fechas y textos|
|@showHeader@|@Boolean@|No|@true@|Mostrar/ocultar el @MatchHeader@|
h3. Implementación
<pre><code class="vue">
<!-- src/widgets/timeline/TimelineWidget.vue -->
<template>
<ErrorBoundary>
<div class="timeline-widget">
<LoadingSpinner v-if="loading" />
<template v-else-if="matchData">
<MatchHeader v-if="showHeader" v-bind="matchHeaderProps" />
<TimelineBar v-bind="timelineBarProps" @event-click="selectedEvent = $event" />
</template>
<p v-else class="error-message">No se pudo cargar el partido.</p>
</div>
</ErrorBoundary>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import MatchHeader from '@/components/sports/football/general/MatchHeader.vue'
import TimelineBar from '@/components/sports/football/general/TimelineBar.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ErrorBoundary from '@/components/common/ErrorBoundary.vue'
import { useMatchData } from '@/composables/useMatchData'
const props = defineProps({
matchId: { type: [Number, String], required: true },
lang: { type: String, default: 'es' },
showHeader: { type: Boolean, default: true },
})
const { matchData, loading, fetchMatch } = useMatchData()
const selectedEvent = ref(null)
onMounted(() => fetchMatch(props.matchId))
const matchHeaderProps = computed(() => ({
homeTeam: matchData.value?.homeTeam,
awayTeam: matchData.value?.awayTeam,
homeScore: matchData.value?.homeScore,
awayScore: matchData.value?.awayScore,
competition: matchData.value?.competition,
matchDate: matchData.value?.matchDate,
stadium: matchData.value?.stadium,
referee: matchData.value?.referee,
dateLocale: props.lang,
}))
const timelineBarProps = computed(() => ({
homeTeam: matchData.value?.homeTeam,
awayTeam: matchData.value?.awayTeam,
homeEvents: matchData.value?.homeEvents ?? [],
awayEvents: matchData.value?.awayEvents ?? [],
whistleEvents: matchData.value?.whistleEvents ?? [],
matchPeriod: matchData.value?.period,
currentMinute: matchData.value?.currentMinute,
matchDuration: matchData.value?.matchDuration ?? 90,
}))
</script>
</code></pre>
---
h2. 7. Modelo de Datos — %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchEvent%
<pre><code class="js">
// Estructura normalizada de evento que consumen los *TimeCard y TimelineEventList
{
incidenceId: Number, // Ej: 52055430
typeId: Number, // 9 (Goal), 7 (Sub), 3 (Yellow Card), 5 (Red Card), 568 (VAR)
minutes: Number, // Minuto del partido (ej: 77)
teamId: Number, // ID del equipo
// Campos por tipo (mapeados directo del JSON de producción):
playerName: String, // goal, card
assistanceBy: String, // solo goal
card: String, // 'Yellow' | 'Red' — solo card
reason: String, // card, var
offName: String, // solo substitution
inName: String, // solo substitution
type: String, // whistle (tipo de pitido) o VAR status
}
</code></pre>
---
h2. 8. Criterios de Calidad
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget% muestra %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}LoadingSpinner% mientras carga y usa %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}ErrorBoundary% para errores.
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar% no conoce el %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}matchId% — solo recibe datos procesados.
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineEventList% es el *único responsable* de calcular %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}positionPx% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}stackOffset% .
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchClock% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineTrack% son componentes de solo presentación (< 80 líneas).
* Todos los componentes tienen historia Storybook con datos de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html% .
---
h2. 9. Orden de Implementación
# ✅ Completar tarea %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}timeline-items.md%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}MatchClock.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineTrack.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineEventList.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/widgets/timeline/timeline-widget.js%
# ✅ Agregar historias Storybook para %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineBar%
---