feat(ui): consolidate shared UI patterns into reusable components
- Token cleanup: replace raw hex colors (#c084fc, #60a5fa, #6ee7b7, #fdba74) in BoardCard.vue with nexus-tokens.css CSS variables (--clr-iris, --clr-bao, --clr-agent, --clr-review) - New composable: useFormatDate (formatDate, relativeTime, toDateInputValue, minutesSince, hoursSince) — extracts duplicated date helpers from TaskBoardView and BoardCard - New composable: useConfirm — reusable confirmation dialog logic (open/close, error/success state, Escape binding, body scroll lock) - New component: StatusPill — unified state pill for backlog/progress/ review/blocked/done, replacing inline .detail-state-pill classes - New component: SkeletonLoader — loading placeholder with shimmer animation (card/text/circle variants) - TaskBoardView: imports StatusPill & useFormatDate, removes 35+ lines of duplicated helpers - ui/index.ts: exports new StatusPill & SkeletonLoader - Build verified: pnpm build green (vue-tsc --noEmit + vite build pass)
This commit is contained in:
@@ -20,6 +20,8 @@ import { useTaskStore, type DashboardTaskDto } from '../stores/tasks'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
||||
import BoardCard from '../components/board/BoardCard.vue'
|
||||
import StatusPill from '../components/ui/StatusPill.vue'
|
||||
import { formatDate, toDateInputValue, relativeTime, minutesSince, hoursSince } from '../composables/useFormatDate'
|
||||
|
||||
/** Schwelle (min) ohne Aktivität, ab der ein In-Bearbeitung-Task als „hängt" gilt.
|
||||
* Spiegelt die Backend-Watchdog-Schwelle (TaskRecovery:StalledMinutes). */
|
||||
@@ -186,32 +188,20 @@ async function onDrop(e: DragEvent, targetState: string) {
|
||||
}
|
||||
|
||||
/* ── Helpers ──────────────────────────────────────── */
|
||||
function statusTone(state: string): string {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'done': return 'is-done'
|
||||
case 'blocked': return 'is-blocked'
|
||||
case 'review': return 'is-review'
|
||||
case 'in progress': return 'is-progress'
|
||||
default: return 'is-backlog'
|
||||
}
|
||||
/** Map state string to StatusPill variant */
|
||||
function statusPillVariant(state: string): 'backlog' | 'progress' | 'review' | 'blocked' | 'done' {
|
||||
const s = state.toLowerCase()
|
||||
if (s === 'done') return 'done'
|
||||
if (s === 'blocked') return 'blocked'
|
||||
if (s === 'review') return 'review'
|
||||
if (s === 'in progress') return 'progress'
|
||||
return 'backlog'
|
||||
}
|
||||
|
||||
function stateLabel(state: string): string {
|
||||
return state === 'Backlog' ? 'Offen' : state
|
||||
}
|
||||
|
||||
function formatDate(date?: string | null, withTime = false): string {
|
||||
if (!date) return '—'
|
||||
return new Date(date).toLocaleString('de-DE', withTime
|
||||
? { dateStyle: 'medium', timeStyle: 'short' }
|
||||
: { dateStyle: 'medium' })
|
||||
}
|
||||
|
||||
function toDateInputValue(date?: string | null): string {
|
||||
if (!date) return ''
|
||||
return new Date(date).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function flattenBoard() {
|
||||
const masters = [
|
||||
...taskStore.board.offen,
|
||||
@@ -246,11 +236,6 @@ const ballFilters = [
|
||||
{ key: 'stalled', label: 'Hängt' },
|
||||
] as const
|
||||
|
||||
function minutesSince(dateStr?: string | null): number {
|
||||
if (!dateStr) return Infinity
|
||||
return (Date.now() - new Date(dateStr).getTime()) / 60000
|
||||
}
|
||||
|
||||
function isStalledTask(t: DashboardTaskDto): boolean {
|
||||
if (t.state.toLowerCase() !== 'in progress') return false
|
||||
return minutesSince(t.lastActivityAt ?? t.updatedAt) > STALL_THRESHOLD_MIN
|
||||
@@ -319,23 +304,7 @@ function expectedFromLabel(expected: string | null | undefined): string {
|
||||
return TASK_AGENT_LABELS[expected.toLowerCase()] ?? expected
|
||||
}
|
||||
|
||||
function hoursSince(dateStr: string): number {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateStr).getTime()
|
||||
return Math.round((now - then) / 3600000)
|
||||
}
|
||||
|
||||
function relativeTime(date?: string | null): string {
|
||||
if (!date) return 'keine Updates'
|
||||
const diffMs = Date.now() - new Date(date).getTime()
|
||||
const mins = Math.max(0, Math.round(diffMs / 60000))
|
||||
if (mins < 1) return 'gerade eben'
|
||||
if (mins < 60) return `vor ${mins} min`
|
||||
const hours = Math.round(mins / 60)
|
||||
if (hours < 24) return `vor ${hours} h`
|
||||
const days = Math.round(hours / 24)
|
||||
return `vor ${days} d`
|
||||
}
|
||||
|
||||
function childStatusSummary(taskId: string): string {
|
||||
const children = allBoardTasks.value.filter(task => task.parentTaskId === taskId)
|
||||
@@ -807,7 +776,7 @@ onUnmounted(() => {
|
||||
<div class="detail-crumb">
|
||||
<span class="crumb-root">Task Board</span>
|
||||
<span class="crumb-sep">/</span>
|
||||
<span class="detail-state-pill" :class="statusTone(selectedTask.state)">{{ stateLabel(selectedTask.state) }}</span>
|
||||
<StatusPill :variant="statusPillVariant(selectedTask.state)">{{ stateLabel(selectedTask.state) }}</StatusPill>
|
||||
<button class="id-chip" :title="linkCopied ? 'Link kopiert!' : 'Link kopieren'" @click="copyTaskLink">
|
||||
#{{ selectedTask.id.slice(0, 8) }}
|
||||
<Check v-if="linkCopied" :size="11" />
|
||||
@@ -1100,12 +1069,6 @@ select:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.id-chip:hover { color: var(--tx); border-color: var(--line-2); }
|
||||
.detail-topbar-actions { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
|
||||
|
||||
.detail-state-pill { border-radius: 999px; padding: 3px 9px; font-size: 10.5px; font-weight: 700; letter-spacing: .03em; border: 1px solid transparent; white-space: nowrap; }
|
||||
.detail-state-pill.is-backlog { color: var(--pill-backlog); background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
|
||||
.detail-state-pill.is-progress { color: var(--pill-progress); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
||||
.detail-state-pill.is-review { color: var(--pill-review); background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
|
||||
.detail-state-pill.is-blocked { color: var(--pill-blocked); background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
|
||||
.detail-state-pill.is-done { color: var(--pill-done); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
||||
|
||||
.detail-content { display: grid; grid-template-columns: minmax(0, 1fr) 292px; min-height: 0; flex: 1; }
|
||||
.detail-main { padding: 18px 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
Reference in New Issue
Block a user