hierarchy: parsear nomenclatura [E05-F04-U02], badges de tipo en dashboard, clean title

This commit is contained in:
2026-05-27 21:59:37 -05:00
parent 9ae2af3ea2
commit cf770a6a44
4 changed files with 195 additions and 30 deletions
+10
View File
@@ -128,6 +128,8 @@ pub struct Epic {
pub end_date: Option<String>, pub end_date: Option<String>,
pub created_at: Option<String>, pub created_at: Option<String>,
pub updated_at: Option<String>, pub updated_at: Option<String>,
pub item_type: Option<String>,
pub hierarchy_path: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@@ -146,6 +148,9 @@ pub struct UserStory {
pub actual_hours: Option<f64>, pub actual_hours: Option<f64>,
pub assigned_to: Option<i64>, pub assigned_to: Option<i64>,
pub created_at: Option<String>, pub created_at: Option<String>,
pub item_type: Option<String>,
pub hierarchy_path: Option<String>,
pub parent_code: Option<String>,
} }
async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> { async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> {
@@ -278,6 +283,8 @@ async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> {
stimated_end_date TEXT, stimated_end_date TEXT,
start_date TEXT, start_date TEXT,
end_date TEXT, end_date TEXT,
item_type TEXT DEFAULT 'E',
hierarchy_path TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (initiative_id) REFERENCES projects(id) FOREIGN KEY (initiative_id) REFERENCES projects(id)
@@ -297,6 +304,9 @@ async fn get_conn(db_path: &str) -> Result<libsql::Connection, String> {
estimated_hours REAL, estimated_hours REAL,
actual_hours REAL, actual_hours REAL,
assigned_to INTEGER, assigned_to INTEGER,
item_type TEXT DEFAULT 'U',
hierarchy_path TEXT,
parent_code TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (initiative_id) REFERENCES projects(id), FOREIGN KEY (initiative_id) REFERENCES projects(id),
FOREIGN KEY (epic_id) REFERENCES epics(id) FOREIGN KEY (epic_id) REFERENCES epics(id)
+115
View File
@@ -0,0 +1,115 @@
export type ItemType = 'E' | 'F' | 'U' | 'T' | 'B'
export interface HierarchyInfo {
fullPath: string
items: { type: ItemType; number: number }[]
epicCode: string | null
featureCode: string | null
itemCode: string | null
}
const TYPE_LABELS: Record<ItemType, string> = {
E: 'Épica',
F: 'Feature',
U: 'HU',
T: 'Tarea',
B: 'Bug',
}
const TYPE_COLORS: Record<ItemType, string> = {
E: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
F: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
U: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
T: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
B: 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400',
}
const TYPE_ICONS: Record<ItemType, string> = {
E: '📦',
F: '⭐',
U: '📋',
T: '⚙️',
B: '🐛',
}
export function getTypeLabel(type: ItemType): string {
return TYPE_LABELS[type] || type
}
export function getTypeColor(type: ItemType): string {
return TYPE_COLORS[type] || 'bg-muted text-muted-foreground'
}
export function getTypeIcon(type: ItemType): string {
return TYPE_ICONS[type] || '📄'
}
const HIERARCHY_REGEX = /\[(([EFUTB]\d+)(?:-([EFUTB]\d+))?(?:-([EFUTB]\d+))?)\]/i
export function parseHierarchy(title: string): HierarchyInfo | null {
const match = title.match(HIERARCHY_REGEX)
if (!match) return null
const fullPath = match[1].toUpperCase()
const segments = [match[2]]
if (match[3]) segments.push(match[3])
if (match[4]) segments.push(match[4])
const items = segments.map(s => ({
type: s[0] as ItemType,
number: parseInt(s.slice(1), 10),
}))
const epic = items.find(i => i.type === 'E')
const feature = items.find(i => i.type === 'F')
const item = items[items.length - 1]
return {
fullPath,
items,
epicCode: epic ? `E${String(epic.number).padStart(2, '0')}` : null,
featureCode: feature ? `F${String(feature.number).padStart(2, '0')}` : null,
itemCode: item ? `${item.type}${String(item.number).padStart(2, '0')}` : null,
}
}
export function stripHierarchy(title: string): string {
return title.replace(HIERARCHY_REGEX, '').trim()
}
export function getItemType(title: string): ItemType {
const h = parseHierarchy(title)
if (h?.items.length) return h.items[h.items.length - 1].type
return 'U'
}
export function buildHierarchyPath(
epicNum?: number,
featureNum?: number,
itemType?: ItemType,
itemNum?: number,
): string {
const parts: string[] = []
if (epicNum !== undefined) parts.push(`E${String(epicNum).padStart(2, '0')}`)
if (featureNum !== undefined) parts.push(`F${String(featureNum).padStart(2, '0')}`)
if (itemType && itemNum !== undefined) parts.push(`${itemType}${String(itemNum).padStart(2, '0')}`)
return parts.length ? `[${parts.join('-')}]` : ''
}
export function buildFullTitle(
name: string,
epicNum?: number,
featureNum?: number,
itemType?: ItemType,
itemNum?: number,
): string {
const path = buildHierarchyPath(epicNum, featureNum, itemType, itemNum)
return path ? `${path} ${name}` : name
}
export function extractItemNumber(title: string, type: ItemType): number | null {
const h = parseHierarchy(title)
if (!h) return null
const found = h.items.find(i => i.type === type)
return found?.number ?? null
}
+50 -26
View File
@@ -3,8 +3,21 @@ import { ref, computed } from 'vue'
import { kappa } from '@/services/kappa-api' import { kappa } from '@/services/kappa-api'
import { tauriDb, type UserStoryRecord, type EpicRecord } from '@/services/tauri-db' import { tauriDb, type UserStoryRecord, type EpicRecord } from '@/services/tauri-db'
import { stripHtml } from '@/services/clean-html' import { stripHtml } from '@/services/clean-html'
import { parseHierarchy, stripHierarchy, getItemType, type ItemType } from '@/services/hierarchy'
import type { KappaUserStory, KappaLogbookEntry, KappaPlanningEntry, KappaEpicDevelopment } from '@/types/kappa' import type { KappaUserStory, KappaLogbookEntry, KappaPlanningEntry, KappaEpicDevelopment } from '@/types/kappa'
export interface EnrichedUserStory extends KappaUserStory {
_itemType: ItemType
_hierarchyPath: string | null
_cleanTitle: string
}
export interface EnrichedEpic extends KappaEpicDevelopment {
_itemType: ItemType
_hierarchyPath: string | null
_cleanName: string
}
export const useWorkItemsStore = defineStore('workitems', () => { export const useWorkItemsStore = defineStore('workitems', () => {
const creating = ref(false) const creating = ref(false)
const loading = ref(false) const loading = ref(false)
@@ -12,8 +25,8 @@ export const useWorkItemsStore = defineStore('workitems', () => {
const error = ref<string | null>(null) const error = ref<string | null>(null)
const firstVisit = ref<Set<number>>(new Set()) const firstVisit = ref<Set<number>>(new Set())
const userStories = ref<KappaUserStory[]>([]) const userStories = ref<EnrichedUserStory[]>([])
const epics = ref<KappaEpicDevelopment[]>([]) const epics = ref<EnrichedEpic[]>([])
const logbooks = ref<KappaLogbookEntry[]>([]) const logbooks = ref<KappaLogbookEntry[]>([])
const plannings = ref<KappaPlanningEntry[]>([]) const plannings = ref<KappaPlanningEntry[]>([])
@@ -27,13 +40,41 @@ export const useWorkItemsStore = defineStore('workitems', () => {
) )
const totalSessions = computed(() => logbooks.value.length) const totalSessions = computed(() => logbooks.value.length)
async function createUserStory(story: KappaUserStory): Promise<KappaUserStory | null> { function enrichEpic(e: { name?: string; title?: string; id?: number }, initiativeId: number): EnrichedEpic {
const title = e.name || e.title || ''
const h = parseHierarchy(title)
return {
id: e.id ?? 0,
code: undefined,
name: e.name,
title: e.name || e.title,
initiative: initiativeId,
_itemType: h?.items[h.items.length - 1]?.type || 'E',
_hierarchyPath: h?.fullPath ?? null,
_cleanName: h ? stripHierarchy(title) : title,
}
}
function enrichHU(hu: { title?: string; id?: number }, initiativeId: number): EnrichedUserStory {
const h = parseHierarchy(hu.title || '')
return {
id: hu.id ?? 0,
title: hu.title || '',
initiative: initiativeId,
_itemType: h?.items[h.items.length - 1]?.type || 'U',
_hierarchyPath: h?.fullPath ?? null,
_cleanTitle: h ? stripHierarchy(hu.title || '') : (hu.title || ''),
}
}
async function createUserStory(story: KappaUserStory): Promise<EnrichedUserStory | null> {
creating.value = true creating.value = true
error.value = null error.value = null
try { try {
const result = await kappa.createUserStory(story) const result = await kappa.createUserStory(story)
userStories.value.push(result) const enriched = enrichHU(result, Number(result.initiative))
return result userStories.value.push(enriched)
return enriched
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
return null return null
@@ -62,27 +103,9 @@ export const useWorkItemsStore = defineStore('workitems', () => {
tauriDb.getUserStories(id).catch(() => [] as UserStoryRecord[]), tauriDb.getUserStories(id).catch(() => [] as UserStoryRecord[]),
]) ])
epics.value = localEpics.map(e => ({ epics.value = localEpics.map(e => enrichEpic(e, id))
id: e.id,
code: e.code || undefined,
name: e.name,
title: e.name,
description: e.description || undefined,
status: e.status || undefined,
client_taker: e.client_taker || undefined,
initiative: id,
}))
userStories.value = localHUs.map(hu => ({ userStories.value = localHUs.map(hu => enrichHU(hu, id))
id: hu.id,
code: hu.code || undefined,
title: hu.title,
description: hu.description || undefined,
acceptance_criteria: hu.acceptance_criteria || undefined,
status: hu.status || undefined,
priority: hu.priority || undefined,
initiative: id,
}))
} }
// 2. Consultar KAPPA (siempre, para detectar cambios) // 2. Consultar KAPPA (siempre, para detectar cambios)
@@ -100,11 +123,12 @@ export const useWorkItemsStore = defineStore('workitems', () => {
const newEpics = epicData.filter(e => !localEpicIds.has(e.id)) const newEpics = epicData.filter(e => !localEpicIds.has(e.id))
// 4. Actualizar UI con datos frescos de KAPPA // 4. Actualizar UI con datos frescos de KAPPA
userStories.value = stories userStories.value = stories.map(hu => enrichHU(hu, id))
epics.value = epicData.map(epic => ({ epics.value = epicData.map(epic => ({
...epic, ...epic,
description: stripHtml(epic.description || ''), description: stripHtml(epic.description || ''),
...enrichEpic(epic, id),
})) }))
// 5. Guardar en Turso: insertar/actualizar HUs y épicas // 5. Guardar en Turso: insertar/actualizar HUs y épicas
+20 -4
View File
@@ -3,6 +3,7 @@ import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useProjectsStore } from '@/stores/projects' import { useProjectsStore } from '@/stores/projects'
import { useWorkItemsStore } from '@/stores/workitems' import { useWorkItemsStore } from '@/stores/workitems'
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
import { Activity, FileText, Layers, Clock } from 'lucide-vue-next' import { Activity, FileText, Layers, Clock } from 'lucide-vue-next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -152,10 +153,16 @@ const statusLabel = (status: unknown) => {
<CardHeader class="p-4 pb-2"> <CardHeader class="p-4 pb-2">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 min-w-0">
<span
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold"
:class="getTypeColor(epic._itemType)"
>
{{ getTypeIcon(epic._itemType) }} {{ getTypeLabel(epic._itemType) }}
</span>
<span class="font-mono text-xs text-muted-foreground flex-shrink-0"> <span class="font-mono text-xs text-muted-foreground flex-shrink-0">
{{ epic.code || `EP-${epic.id}` }} {{ epic.code || `EP-${epic.id}` }}
</span> </span>
<CardTitle class="text-sm truncate"> {{ epic.name || epic.title || t('dashboard.epicFallback', { id: epic.id }) }}</CardTitle> <CardTitle class="text-sm truncate">{{ epic._cleanName || epic.name || epic.title || t('dashboard.epicFallback', { id: epic.id }) }}</CardTitle>
</div> </div>
<Badge :variant="statusVariant(epic.status || '')" class="text-xs flex-shrink-0"> <Badge :variant="statusVariant(epic.status || '')" class="text-xs flex-shrink-0">
{{ statusLabel(epic.status || '') }} {{ statusLabel(epic.status || '') }}
@@ -182,7 +189,8 @@ const statusLabel = (status: unknown) => {
<Table v-if="workItems.userStories.length > 0"> <Table v-if="workItems.userStories.length > 0">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-[100px]">{{ t('dashboard.code') }}</TableHead> <TableHead class="w-[80px]">{{ t('dashboard.code') }}</TableHead>
<TableHead class="w-[60px]">{{ t('users.role') }}</TableHead>
<TableHead>{{ t('dashboard.title') }}</TableHead> <TableHead>{{ t('dashboard.title') }}</TableHead>
<TableHead class="w-[110px]">{{ t('dashboard.status') }}</TableHead> <TableHead class="w-[110px]">{{ t('dashboard.status') }}</TableHead>
<TableHead class="w-[90px] text-right">{{ t('dashboard.priority') }}</TableHead> <TableHead class="w-[90px] text-right">{{ t('dashboard.priority') }}</TableHead>
@@ -193,8 +201,16 @@ const statusLabel = (status: unknown) => {
<TableCell class="font-mono text-xs text-muted-foreground"> <TableCell class="font-mono text-xs text-muted-foreground">
{{ hu.code || `HU-${hu.id}` }} {{ hu.code || `HU-${hu.id}` }}
</TableCell> </TableCell>
<TableCell class="text-sm max-w-[300px] truncate"> <TableCell>
{{ hu.title }} <span
class="inline-flex items-center rounded px-1 py-0.5 text-[10px] font-bold"
:class="getTypeColor(hu._itemType)"
>
{{ getTypeLabel(hu._itemType) }}
</span>
</TableCell>
<TableCell class="text-sm max-w-[280px] truncate">
{{ hu._cleanTitle || hu.title }}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize"> <Badge :variant="statusVariant(hu.status || '')" class="text-xs capitalize">