Tareas #8568
Actualizado por Anielkis Herrera hace 17 días
h2. Contexto
El widget de _Línea de Tiempo_ ( %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget% ) muestra los eventos del partido (goles, tarjetas, sustituciones, VAR, pitidos) como hitos interactivos sobre una barra horizontal. Esta tarea cubre la creación de los *componentes atómicos de incidencia* que se posicionan sobre dicha barra.
La descripción Reference HTML: %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html%
Depende de: *%{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}BaseTimeCard.vue%* (componente base — crear primero)
Es dependencia de: %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}timeline-widget.md%
---
h2. 1. Componentes a Crear
|_. Incidencia |_. Nombre |_. Ruta |_. Categoría |
|*Componente base UI*|@BaseTimeCard.vue@|@components/base/@|Base Específico|
|*Gol*|@GoalTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
|*Tarjeta amarilla/roja*|@CardTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
|*Sustitución*|@SubstitutionTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
|*VAR*|@VarTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
|*Pitido árbitro*|@WhistleTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
bq. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}WhistleTimeCard% se agrega porque el HTML de ejemplo muestra pitidos ( %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}Opta-Event-Type-Whistle% ) como hitos especiales en la línea central del campo.
---
h2. 2. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}BaseTimeCard.vue% — Componente Base
*Archivo:* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/components/base/BaseTimeCard.vue%
*Responsabilidad:* Estructura visual genérica del hito en el timeline. Se posiciona de forma absoluta sobre la barra, muestra un ícono y un tooltip al hover/click. No conoce nada sobre el negocio del fútbol.
*Restricciones:* Sin imports de stores ni servicios. Solo emite eventos.
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@side@|@String@|Sí|—|@'home'@ \|@'away'@ \|@'center'@ — determina si el hito se muestra arriba o abajo de la barra|
|@positionPx@|@Number@|Sí|—|Posición @left@ en píxeles calculada por el contenedor padre|
|@stackOffset@|@Number@|No|@0@|Desplazamiento vertical adicional (múltiplos de 17px) cuando hay varios eventos en el mismo minuto|
|@customClass@|@String@|No|@''@|Clase CSS adicional para personalizar estilos|
h3. Slots
|_. Slot |_. Descripción |
|@#icon@|Ícono visual del evento (imagen SVG, componente, etc.)|
|@#tooltip@|Contenido del tooltip al hacer hover/click (nombre, minuto, motivo)|
h3. Emits
|_. Evento |_. Payload |_. Descripción |
|@item-click@|Event object|Se emite al hacer click sobre el hito|
h3. Implementación de referencia
<pre><code class="vue">
<!-- src/components/base/BaseTimeCard.vue -->
<template>
<li
class="base-time-card list-unstyled position-absolute translate-middle-x"
:class="[`side-${side}`, customClass]"
:style="{
left: `${positionPx}px`,
[side === 'away' ? 'top' : 'bottom']: `${stackOffset}px`,
}"
@click="$emit('item-click')"
>
<!-- Ícono del evento -->
<div class="time-card-icon d-flex align-items-center justify-content-center">
<slot name="icon" />
</div>
<!-- Tooltip con información detallada -->
<div class="time-card-tooltip shadow-sm border rounded bg-white p-2">
<slot name="tooltip" />
</div>
</li>
</template>
<script setup>
defineProps({
side: { type: String, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
customClass: { type: String, default: '' },
})
defineEmits(['item-click'])
</script>
<style scoped>
.base-time-card {
z-index: 5;
cursor: pointer;
transition: transform 0.2s ease;
}
.base-time-card:hover {
transform: scale(1.1);
z-index: 10;
}
.time-card-tooltip {
display: none;
position: absolute;
top: 110%;
left: 50%;
transform: translateX(-50%);
min-width: 180px;
}
.base-time-card:hover .time-card-tooltip {
display: block;
}
</style>
</code></pre>
---
h2. 3. Componentes Específicos de Incidencia
Todos siguen el mismo patrón: reciben un %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}event% tipado, adaptan sus campos al slot %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}#icon% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}#tooltip% de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}BaseTimeCard% , y reemiten el click hacia arriba.
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}GoalTimeCard.vue% — Gol
*Referencia (Bootstrap 5):*
<pre><code class="html">
<li class="list-unstyled position-absolute" style="left: 290px; bottom: 0px;">
<div class="d-flex align-items-center">
<img src="icon-goal.svg" class="w-20" />
<div class="tooltip-mock shadow p-2 bg-white rounded">
<h3 class="h6 mb-1 d-flex justify-content-between fw-bold">
<span>Gol</span>
<span class="text-primary small">44'</span>
</h3>
<p class="mb-0 small fw-semibold">Ángel Stringa</p>
<p class="mb-0 text-muted extra-small">Asistencia: Pedro Ramirez</p>
</div>
</div>
</li>
</code></pre>
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, assistanceBy, playerName, teamId, type }@|
|@positionPx@|@Number@|Sí|Calculado por @TimelineEventList@|
|@stackOffset@|@Number@|No|Default @0@|
|@homeTeamId@|@Number@|Sí|Para determinar el lado (@home@ o @away@)|
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/GoalTimeCard.vue -->
<template>
<BaseTimeCard
:side="side"
:positionPx="positionPx"
:stackOffset="stackOffset"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img src="@/assets/icons/football/icon-Goal.svg" alt="Gol" class="timeline-icon" />
</template>
<template #tooltip>
<h3 class="h6 mb-1 d-flex justify-content-between fw-bold">
<span>Gol</span>
<span class="text-primary small">{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p class="mb-0 small fw-semibold">{{ event.playerName }}</p>
<p v-if="event.assistanceBy" class="mb-0 text-muted extra-small">
Asistencia: {{ event.assistanceBy }}
</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import { computed } from 'vue'
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
homeTeamId: { type: Number, required: true },
})
defineEmits(['item-click'])
const side = computed(() => (props.event.teamId === props.homeTeamId ? 'home' : 'away'))
</script>
</code></pre>
*Ejemplo de uso (Datos real JSON):*
<pre><code class="vue">
<GoalTimeCard
:event="{
minutes: 77,
playerName: 'Désiré Doué',
assistanceBy: 'João Pedro Gonçalves Neves',
teamId: 1370,
}"
:positionPx="580"
:homeTeamId="1370"
/>
</code></pre>
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}CardTimeCard.vue% — Tarjeta Amarilla / Roja
*Referencia (Bootstrap 5):*
<pre><code class="html">
<li class="list-unstyled position-absolute" style="left: 149px; bottom: 0px;">
<div class="d-flex align-items-center">
<div class="badge bg-warning text-dark px-2">Yellow</div>
<div class="tooltip-mock shadow p-2 bg-white rounded">
<h3 class="h6 mb-1 d-flex justify-content-between fw-bold">
<span>Tarjeta amarilla</span>
<span class="text-primary small">23'</span>
</h3>
<p class="mb-0 small fw-semibold">Mateo Ramirez</p>
<p class="mb-0 text-muted extra-small italic">Falta</p>
</div>
</div>
</li>
</code></pre>
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, playerName, card, reason, teamId }@|
|@positionPx@|@Number@|Sí||
|@stackOffset@|@Number@|No||
|@homeTeamId@|@Number@|Sí||
bq. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}card% : %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}'Yellow'% \| %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}'Red'%
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/CardTimeCard.vue -->
<template>
<BaseTimeCard
:side="side"
:positionPx="positionPx"
:stackOffset="stackOffset"
:customClass="`card-${event.card.toLowerCase()}`"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img :src="cardIconUrl" :alt="cardLabel" class="timeline-icon" />
</template>
<template #tooltip>
<h3 class="h6 mb-1 d-flex justify-content-between fw-bold">
<span>{{ cardLabel }}</span>
<span class="text-primary small">{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p class="mb-0 small fw-semibold">{{ event.playerName }}</p>
<p v-if="event.reason" class="mb-0 text-muted extra-small italic">{{ event.reason }}</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import { computed } from 'vue'
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
homeTeamId: { type: Number, required: true },
})
defineEmits(['item-click'])
const side = computed(() => (props.event.teamId === props.homeTeamId ? 'home' : 'away'))
const CARD_CONFIG = {
Yellow: { icon: 'icon-Yellow.svg', label: 'Tarjeta amarilla' },
Red: { icon: 'icon-Red.svg', label: 'Tarjeta roja' },
}
const cardIconUrl = computed(
() => `/src/assets/icons/football/${CARD_CONFIG[props.event.card]?.icon ?? 'icon-Yellow.svg'}`,
)
const cardLabel = computed(() => CARD_CONFIG[props.event.card]?.label ?? 'Tarjeta')
</script>
</code></pre>
*Ejemplos de uso (Datos real JSON):*
<pre><code class="vue">
<!-- Tarjeta amarilla -->
<CardTimeCard
:event="{
minutes: 68,
playerName: 'Konrad Laimer',
card: 'Yellow',
reason: 'Unsportsmanlike conduct',
teamId: 617,
}"
:positionPx="510"
:homeTeamId="1370"
/>
<!-- Tarjeta roja directa -->
<CardTimeCard
:event="{
minutes: 81,
playerName: 'William Joel Pacho Tenorio',
card: 'Red',
reason: 'Serious rough play',
teamId: 1370,
}"
:positionPx="620"
:homeTeamId="1370"
/>
</code></pre>
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}SubstitutionTimeCard.vue% — Sustitución
*Referencia (Bootstrap 5):*
<pre><code class="html">
<li class="list-unstyled position-absolute" style="left: 492px; bottom: 0px;">
<div class="d-flex align-items-center">
<img src="icon-sub.svg" class="w-16" />
<div class="tooltip-mock shadow p-2 bg-white rounded">
<h3 class="h6 mb-1 fw-bold">Sustitución <small class="text-muted">69'</small></h3>
<div class="text-danger small"><i class="bi bi-arrow-down-circle"></i> Ramón Gonzalez</div>
<div class="text-success small"><i class="bi bi-arrow-up-circle"></i> Matías García</div>
</div>
</div>
</li>
</code></pre>
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, offName, inName, teamId }@|
|@positionPx@|@Number@|Sí||
|@stackOffset@|@Number@|No||
|@homeTeamId@|@Number@|Sí||
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/SubstitutionTimeCard.vue -->
<template>
<BaseTimeCard
:side="side"
:positionPx="positionPx"
:stackOffset="stackOffset"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img
src="@/assets/icons/football/icon-Substitution.svg"
alt="Sustitución"
class="timeline-icon"
/>
</template>
<template #tooltip>
<h3>
<span>Sustitución</span>
<span>{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p class="sub-off">
<img src="@/assets/icons/football/icon-Off.svg" alt="Sale" /> {{ event.offName }}
</p>
<p class="sub-on">
<img src="@/assets/icons/football/icon-On.svg" alt="Entra" /> {{ event.inName }}
</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import { computed } from 'vue'
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
homeTeamId: { type: Number, required: true },
})
defineEmits(['item-click'])
const side = computed(() => (props.event.teamId === props.homeTeamId ? 'home' : 'away'))
</script>
</code></pre>
*Ejemplo de uso (Datos real JSON):*
<pre><code class="vue">
<SubstitutionTimeCard
:event="{
minutes: 33,
offName: 'Josip Stanisic',
inName: 'Sacha Boey',
teamId: 617,
}"
:positionPx="245"
:homeTeamId="1370"
/>
</code></pre>
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}VarTimeCard.vue% — VAR
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, reason, type, teamId }@|
|@positionPx@|@Number@|Sí||
|@stackOffset@|@Number@|No||
|@homeTeamId@|@Number@|Sí||
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/VarTimeCard.vue -->
<template>
<BaseTimeCard
:side="side"
:positionPx="positionPx"
:stackOffset="stackOffset"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img src="@/assets/icons/football/icon-VAR.svg" alt="VAR" class="timeline-icon" />
</template>
<template #tooltip>
<h3>
<span>VAR</span>
<span>{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p>{{ event.reason }}</p>
<p v-if="event.type" class="type-badge">{{ event.type }}</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import { computed } from 'vue'
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
homeTeamId: { type: Number, required: true },
})
defineEmits(['item-click'])
const side = computed(() => (props.event.teamId === props.homeTeamId ? 'home' : 'away'))
</script>
</code></pre>
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}WhistleTimeCard.vue% — Pitido de Árbitro
*Referencia HTML:*
<pre><code class="html">
<div
data-event-id="2"
class="Opta-MatchEvent Opta-Event-Type-Whistle"
style="left: 336.19px; bottom: 0px;"
>
<div class="Opta-JS-Tip">
<p class="Opta-Icon Opta-IconWhistle"></p>
<span class="Opta-Hidden">
<h3>
<span class="Opta-Event-Title">Pitido</span>
<span class="Opta-Event-Min">45+5'</span>
</h3>
<div><p>45+5'</p></div>
</span>
</div>
</div>
</code></pre>
bq. Los pitidos se lleva acá: https://docs.google.com/document/d/1sDoJXbhuITqK82Akf0ISf3iNfxuI4werQbRUfhpTROs/edit?usp=sharing ubican *en la pista central* ( %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}side: 'center'% ), no en las bandas de home/away.
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, type }@|
|@positionPx@|@Number@|Sí||
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/WhistleTimeCard.vue -->
<template>
<BaseTimeCard
side="center"
:positionPx="positionPx"
:stackOffset="0"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img src="@/assets/icons/football/icon-Whistle.svg" alt="Pitido" class="timeline-icon" />
</template>
<template #tooltip>
<h3>
<span>Pitido</span>
<span>{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p>{{ event.type }}</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
})
defineEmits(['item-click'])
</script>
</code></pre>
*Ejemplo de uso (Datos real JSON):*
<pre><code class="vue">
<WhistleTimeCard :event="{ minutes: 0, type: 'Begin of match' }" :positionPx="0" />
</code></pre>
---
h2. 4. Helper Compartido — %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}formatMatchTime%
*Ubicación:* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/utils/datetime/formatMatchTime.js%
<pre><code class="js">
/**
* Formatea el minuto de un evento al estilo "44'"
* @param {number} minutes - Minuto base del partido
* @returns {string}
*/
export function formatMatchTime(minutes) {
return `${minutes}'`
}
</code></pre>
---
h2. 5. Criterios de Calidad
* Cada archivo de incidencia: *≤ 100 líneas* (son componentes delegantes, no deben tener lógica compleja).
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}BaseTimeCard% : *≤ 80 líneas*.
* Interacciones con %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}defineEmits% en %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}kebab-case% ( %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}item-click% , no %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}itemClick% ).
* El tooltip usa %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}h3% + %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}p% para accesibilidad semántica.
* Los íconos tienen %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}alt% descriptivo en todos los casos.
* El cálculo de %{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% es responsabilidad exclusiva de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineEventList% — estos componentes solo los reciben y los usan.
---
h2. 6. Orden de Implementación
# ✅ Crear/verificar %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/utils/datetime/formatMatchTime.js%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/components/base/BaseTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}GoalTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}CardTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}SubstitutionTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}VarTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}WhistleTimeCard.vue%
# ✅ Agregar historias Storybook para cada componente usando datos de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html%
---
El widget de _Línea de Tiempo_ ( %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineWidget% ) muestra los eventos del partido (goles, tarjetas, sustituciones, VAR, pitidos) como hitos interactivos sobre una barra horizontal. Esta tarea cubre la creación de los *componentes atómicos de incidencia* que se posicionan sobre dicha barra.
La descripción Reference HTML: %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html%
Depende de: *%{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}BaseTimeCard.vue%* (componente base — crear primero)
Es dependencia de: %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}timeline-widget.md%
---
h2. 1. Componentes a Crear
|_. Incidencia |_. Nombre |_. Ruta |_. Categoría |
|*Componente base UI*|@BaseTimeCard.vue@|@components/base/@|Base Específico|
|*Gol*|@GoalTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
|*Tarjeta amarilla/roja*|@CardTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
|*Sustitución*|@SubstitutionTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
|*VAR*|@VarTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
|*Pitido árbitro*|@WhistleTimeCard.vue@|@components/sports/football/specific/@|Negocio Específico|
bq. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}WhistleTimeCard% se agrega porque el HTML de ejemplo muestra pitidos ( %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}Opta-Event-Type-Whistle% ) como hitos especiales en la línea central del campo.
---
h2. 2. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}BaseTimeCard.vue% — Componente Base
*Archivo:* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/components/base/BaseTimeCard.vue%
*Responsabilidad:* Estructura visual genérica del hito en el timeline. Se posiciona de forma absoluta sobre la barra, muestra un ícono y un tooltip al hover/click. No conoce nada sobre el negocio del fútbol.
*Restricciones:* Sin imports de stores ni servicios. Solo emite eventos.
h3. Props
|_. Prop |_. Tipo |_. Requerido |_. Default |_. Descripción |
|@side@|@String@|Sí|—|@'home'@ \|@'away'@ \|@'center'@ — determina si el hito se muestra arriba o abajo de la barra|
|@positionPx@|@Number@|Sí|—|Posición @left@ en píxeles calculada por el contenedor padre|
|@stackOffset@|@Number@|No|@0@|Desplazamiento vertical adicional (múltiplos de 17px) cuando hay varios eventos en el mismo minuto|
|@customClass@|@String@|No|@''@|Clase CSS adicional para personalizar estilos|
h3. Slots
|_. Slot |_. Descripción |
|@#icon@|Ícono visual del evento (imagen SVG, componente, etc.)|
|@#tooltip@|Contenido del tooltip al hacer hover/click (nombre, minuto, motivo)|
h3. Emits
|_. Evento |_. Payload |_. Descripción |
|@item-click@|Event object|Se emite al hacer click sobre el hito|
h3. Implementación de referencia
<pre><code class="vue">
<!-- src/components/base/BaseTimeCard.vue -->
<template>
<li
class="base-time-card list-unstyled position-absolute translate-middle-x"
:class="[`side-${side}`, customClass]"
:style="{
left: `${positionPx}px`,
[side === 'away' ? 'top' : 'bottom']: `${stackOffset}px`,
}"
@click="$emit('item-click')"
>
<!-- Ícono del evento -->
<div class="time-card-icon d-flex align-items-center justify-content-center">
<slot name="icon" />
</div>
<!-- Tooltip con información detallada -->
<div class="time-card-tooltip shadow-sm border rounded bg-white p-2">
<slot name="tooltip" />
</div>
</li>
</template>
<script setup>
defineProps({
side: { type: String, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
customClass: { type: String, default: '' },
})
defineEmits(['item-click'])
</script>
<style scoped>
.base-time-card {
z-index: 5;
cursor: pointer;
transition: transform 0.2s ease;
}
.base-time-card:hover {
transform: scale(1.1);
z-index: 10;
}
.time-card-tooltip {
display: none;
position: absolute;
top: 110%;
left: 50%;
transform: translateX(-50%);
min-width: 180px;
}
.base-time-card:hover .time-card-tooltip {
display: block;
}
</style>
</code></pre>
---
h2. 3. Componentes Específicos de Incidencia
Todos siguen el mismo patrón: reciben un %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}event% tipado, adaptan sus campos al slot %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}#icon% y %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}#tooltip% de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}BaseTimeCard% , y reemiten el click hacia arriba.
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}GoalTimeCard.vue% — Gol
*Referencia (Bootstrap 5):*
<pre><code class="html">
<li class="list-unstyled position-absolute" style="left: 290px; bottom: 0px;">
<div class="d-flex align-items-center">
<img src="icon-goal.svg" class="w-20" />
<div class="tooltip-mock shadow p-2 bg-white rounded">
<h3 class="h6 mb-1 d-flex justify-content-between fw-bold">
<span>Gol</span>
<span class="text-primary small">44'</span>
</h3>
<p class="mb-0 small fw-semibold">Ángel Stringa</p>
<p class="mb-0 text-muted extra-small">Asistencia: Pedro Ramirez</p>
</div>
</div>
</li>
</code></pre>
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, assistanceBy, playerName, teamId, type }@|
|@positionPx@|@Number@|Sí|Calculado por @TimelineEventList@|
|@stackOffset@|@Number@|No|Default @0@|
|@homeTeamId@|@Number@|Sí|Para determinar el lado (@home@ o @away@)|
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/GoalTimeCard.vue -->
<template>
<BaseTimeCard
:side="side"
:positionPx="positionPx"
:stackOffset="stackOffset"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img src="@/assets/icons/football/icon-Goal.svg" alt="Gol" class="timeline-icon" />
</template>
<template #tooltip>
<h3 class="h6 mb-1 d-flex justify-content-between fw-bold">
<span>Gol</span>
<span class="text-primary small">{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p class="mb-0 small fw-semibold">{{ event.playerName }}</p>
<p v-if="event.assistanceBy" class="mb-0 text-muted extra-small">
Asistencia: {{ event.assistanceBy }}
</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import { computed } from 'vue'
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
homeTeamId: { type: Number, required: true },
})
defineEmits(['item-click'])
const side = computed(() => (props.event.teamId === props.homeTeamId ? 'home' : 'away'))
</script>
</code></pre>
*Ejemplo de uso (Datos real JSON):*
<pre><code class="vue">
<GoalTimeCard
:event="{
minutes: 77,
playerName: 'Désiré Doué',
assistanceBy: 'João Pedro Gonçalves Neves',
teamId: 1370,
}"
:positionPx="580"
:homeTeamId="1370"
/>
</code></pre>
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}CardTimeCard.vue% — Tarjeta Amarilla / Roja
*Referencia (Bootstrap 5):*
<pre><code class="html">
<li class="list-unstyled position-absolute" style="left: 149px; bottom: 0px;">
<div class="d-flex align-items-center">
<div class="badge bg-warning text-dark px-2">Yellow</div>
<div class="tooltip-mock shadow p-2 bg-white rounded">
<h3 class="h6 mb-1 d-flex justify-content-between fw-bold">
<span>Tarjeta amarilla</span>
<span class="text-primary small">23'</span>
</h3>
<p class="mb-0 small fw-semibold">Mateo Ramirez</p>
<p class="mb-0 text-muted extra-small italic">Falta</p>
</div>
</div>
</li>
</code></pre>
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, playerName, card, reason, teamId }@|
|@positionPx@|@Number@|Sí||
|@stackOffset@|@Number@|No||
|@homeTeamId@|@Number@|Sí||
bq. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}card% : %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}'Yellow'% \| %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}'Red'%
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/CardTimeCard.vue -->
<template>
<BaseTimeCard
:side="side"
:positionPx="positionPx"
:stackOffset="stackOffset"
:customClass="`card-${event.card.toLowerCase()}`"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img :src="cardIconUrl" :alt="cardLabel" class="timeline-icon" />
</template>
<template #tooltip>
<h3 class="h6 mb-1 d-flex justify-content-between fw-bold">
<span>{{ cardLabel }}</span>
<span class="text-primary small">{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p class="mb-0 small fw-semibold">{{ event.playerName }}</p>
<p v-if="event.reason" class="mb-0 text-muted extra-small italic">{{ event.reason }}</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import { computed } from 'vue'
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
homeTeamId: { type: Number, required: true },
})
defineEmits(['item-click'])
const side = computed(() => (props.event.teamId === props.homeTeamId ? 'home' : 'away'))
const CARD_CONFIG = {
Yellow: { icon: 'icon-Yellow.svg', label: 'Tarjeta amarilla' },
Red: { icon: 'icon-Red.svg', label: 'Tarjeta roja' },
}
const cardIconUrl = computed(
() => `/src/assets/icons/football/${CARD_CONFIG[props.event.card]?.icon ?? 'icon-Yellow.svg'}`,
)
const cardLabel = computed(() => CARD_CONFIG[props.event.card]?.label ?? 'Tarjeta')
</script>
</code></pre>
*Ejemplos de uso (Datos real JSON):*
<pre><code class="vue">
<!-- Tarjeta amarilla -->
<CardTimeCard
:event="{
minutes: 68,
playerName: 'Konrad Laimer',
card: 'Yellow',
reason: 'Unsportsmanlike conduct',
teamId: 617,
}"
:positionPx="510"
:homeTeamId="1370"
/>
<!-- Tarjeta roja directa -->
<CardTimeCard
:event="{
minutes: 81,
playerName: 'William Joel Pacho Tenorio',
card: 'Red',
reason: 'Serious rough play',
teamId: 1370,
}"
:positionPx="620"
:homeTeamId="1370"
/>
</code></pre>
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}SubstitutionTimeCard.vue% — Sustitución
*Referencia (Bootstrap 5):*
<pre><code class="html">
<li class="list-unstyled position-absolute" style="left: 492px; bottom: 0px;">
<div class="d-flex align-items-center">
<img src="icon-sub.svg" class="w-16" />
<div class="tooltip-mock shadow p-2 bg-white rounded">
<h3 class="h6 mb-1 fw-bold">Sustitución <small class="text-muted">69'</small></h3>
<div class="text-danger small"><i class="bi bi-arrow-down-circle"></i> Ramón Gonzalez</div>
<div class="text-success small"><i class="bi bi-arrow-up-circle"></i> Matías García</div>
</div>
</div>
</li>
</code></pre>
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, offName, inName, teamId }@|
|@positionPx@|@Number@|Sí||
|@stackOffset@|@Number@|No||
|@homeTeamId@|@Number@|Sí||
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/SubstitutionTimeCard.vue -->
<template>
<BaseTimeCard
:side="side"
:positionPx="positionPx"
:stackOffset="stackOffset"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img
src="@/assets/icons/football/icon-Substitution.svg"
alt="Sustitución"
class="timeline-icon"
/>
</template>
<template #tooltip>
<h3>
<span>Sustitución</span>
<span>{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p class="sub-off">
<img src="@/assets/icons/football/icon-Off.svg" alt="Sale" /> {{ event.offName }}
</p>
<p class="sub-on">
<img src="@/assets/icons/football/icon-On.svg" alt="Entra" /> {{ event.inName }}
</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import { computed } from 'vue'
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
homeTeamId: { type: Number, required: true },
})
defineEmits(['item-click'])
const side = computed(() => (props.event.teamId === props.homeTeamId ? 'home' : 'away'))
</script>
</code></pre>
*Ejemplo de uso (Datos real JSON):*
<pre><code class="vue">
<SubstitutionTimeCard
:event="{
minutes: 33,
offName: 'Josip Stanisic',
inName: 'Sacha Boey',
teamId: 617,
}"
:positionPx="245"
:homeTeamId="1370"
/>
</code></pre>
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}VarTimeCard.vue% — VAR
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, reason, type, teamId }@|
|@positionPx@|@Number@|Sí||
|@stackOffset@|@Number@|No||
|@homeTeamId@|@Number@|Sí||
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/VarTimeCard.vue -->
<template>
<BaseTimeCard
:side="side"
:positionPx="positionPx"
:stackOffset="stackOffset"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img src="@/assets/icons/football/icon-VAR.svg" alt="VAR" class="timeline-icon" />
</template>
<template #tooltip>
<h3>
<span>VAR</span>
<span>{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p>{{ event.reason }}</p>
<p v-if="event.type" class="type-badge">{{ event.type }}</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import { computed } from 'vue'
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
const props = defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
stackOffset: { type: Number, default: 0 },
homeTeamId: { type: Number, required: true },
})
defineEmits(['item-click'])
const side = computed(() => (props.event.teamId === props.homeTeamId ? 'home' : 'away'))
</script>
</code></pre>
---
h3. %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}WhistleTimeCard.vue% — Pitido de Árbitro
*Referencia HTML:*
<pre><code class="html">
<div
data-event-id="2"
class="Opta-MatchEvent Opta-Event-Type-Whistle"
style="left: 336.19px; bottom: 0px;"
>
<div class="Opta-JS-Tip">
<p class="Opta-Icon Opta-IconWhistle"></p>
<span class="Opta-Hidden">
<h3>
<span class="Opta-Event-Title">Pitido</span>
<span class="Opta-Event-Min">45+5'</span>
</h3>
<div><p>45+5'</p></div>
</span>
</div>
</div>
</code></pre>
bq. Los pitidos se lleva acá: https://docs.google.com/document/d/1sDoJXbhuITqK82Akf0ISf3iNfxuI4werQbRUfhpTROs/edit?usp=sharing ubican *en la pista central* ( %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}side: 'center'% ), no en las bandas de home/away.
*Props:*
|_. Prop |_. Tipo |_. Requerido |_. Descripción |
|@event@|@Object@|Sí|@{ minutes, type }@|
|@positionPx@|@Number@|Sí||
*Implementación:*
<pre><code class="vue">
<!-- src/components/sports/football/specific/WhistleTimeCard.vue -->
<template>
<BaseTimeCard
side="center"
:positionPx="positionPx"
:stackOffset="0"
@item-click="$emit('item-click', event)"
>
<template #icon>
<img src="@/assets/icons/football/icon-Whistle.svg" alt="Pitido" class="timeline-icon" />
</template>
<template #tooltip>
<h3>
<span>Pitido</span>
<span>{{ formatMatchTime(event.minutes) }}</span>
</h3>
<p>{{ event.type }}</p>
</template>
</BaseTimeCard>
</template>
<script setup>
import BaseTimeCard from '@/components/base/BaseTimeCard.vue'
import { formatMatchTime } from '@/utils/datetime/formatMatchTime'
defineProps({
event: { type: Object, required: true },
positionPx: { type: Number, required: true },
})
defineEmits(['item-click'])
</script>
</code></pre>
*Ejemplo de uso (Datos real JSON):*
<pre><code class="vue">
<WhistleTimeCard :event="{ minutes: 0, type: 'Begin of match' }" :positionPx="0" />
</code></pre>
---
h2. 4. Helper Compartido — %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}formatMatchTime%
*Ubicación:* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/utils/datetime/formatMatchTime.js%
<pre><code class="js">
/**
* Formatea el minuto de un evento al estilo "44'"
* @param {number} minutes - Minuto base del partido
* @returns {string}
*/
export function formatMatchTime(minutes) {
return `${minutes}'`
}
</code></pre>
---
h2. 5. Criterios de Calidad
* Cada archivo de incidencia: *≤ 100 líneas* (son componentes delegantes, no deben tener lógica compleja).
* %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}BaseTimeCard% : *≤ 80 líneas*.
* Interacciones con %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}defineEmits% en %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}kebab-case% ( %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}item-click% , no %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}itemClick% ).
* El tooltip usa %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}h3% + %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}p% para accesibilidad semántica.
* Los íconos tienen %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}alt% descriptivo en todos los casos.
* El cálculo de %{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% es responsabilidad exclusiva de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}TimelineEventList% — estos componentes solo los reciben y los usan.
---
h2. 6. Orden de Implementación
# ✅ Crear/verificar %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/utils/datetime/formatMatchTime.js%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}src/components/base/BaseTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}GoalTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}CardTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}SubstitutionTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}VarTimeCard.vue%
# ✅ Crear %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}WhistleTimeCard.vue%
# ✅ Agregar historias Storybook para cada componente usando datos de %{font-size: 0.85em;padding: 0.2em 0.4em;background-color: #656c7633;border-radius: 3px;font-weight:bold;}docs/examples/timeline.html%
---