diff --git a/frontend/src/components/board/BoardCard.vue b/frontend/src/components/board/BoardCard.vue
index ab5ccf8..7b83a53 100644
--- a/frontend/src/components/board/BoardCard.vue
+++ b/frontend/src/components/board/BoardCard.vue
@@ -235,9 +235,9 @@ function toggleExpand(e: MouseEvent) {
.mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; }
.ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; }
-.ball.is-iris { background: rgba(147,51,234,.16); color: #c084fc; }
-.ball.is-bao { background: rgba(59,130,246,.16); color: #60a5fa; }
-.ball.is-agent { background: rgba(16,185,129,.14); color: #6ee7b7; }
+.ball.is-iris { background: rgba(147, 51, 234, .16); color: var(--clr-iris); }
+.ball.is-bao { background: rgba(59, 130, 246, .16); color: var(--clr-bao); }
+.ball.is-agent { background: rgba(16, 185, 129, .14); color: var(--clr-agent); }
.ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
.prio-high { color: var(--st-block); border-color: var(--st-block); }
@@ -263,9 +263,9 @@ function toggleExpand(e: MouseEvent) {
.children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; }
.child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; }
-.child-agent.is-iris { background: rgba(147,51,234,.14); color: #c084fc; }
-.child-agent.is-bao { background: rgba(59,130,246,.14); color: #60a5fa; }
-.child-agent.is-agent { background: rgba(16,185,129,.12); color: #6ee7b7; }
+.child-agent.is-iris { background: rgba(147, 51, 234, .14); color: var(--clr-iris); }
+.child-agent.is-bao { background: rgba(59, 130, 246, .14); color: var(--clr-bao); }
+.child-agent.is-agent { background: rgba(16, 185, 129, .12); color: var(--clr-agent); }
.child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); }
.child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; }
.child-row:hover { background: rgba(124,108,255,.08); }
@@ -275,7 +275,7 @@ function toggleExpand(e: MouseEvent) {
.child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; }
.cs-done { background: rgba(61,220,151,.14); color: var(--st-work); }
.cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); }
-.cs-review { background: rgba(251,146,60,.14); color: #fdba74; }
+.cs-review { background: rgba(251, 146, 60, .14); color: var(--clr-review); }
.cs-active { background: rgba(52,214,245,.14); color: var(--st-think); }
.cs-backlog { background: var(--glass-2); color: var(--tx-3); }
@@ -283,7 +283,7 @@ function toggleExpand(e: MouseEvent) {
.rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; }
.rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); }
.rv-approve:hover { background: rgba(61,220,151,.26); }
-.rv-changes { border: 1px solid rgba(251,146,60,.3); background: rgba(251,146,60,.12); color: #fdba74; }
+.rv-changes { border: 1px solid rgba(251, 146, 60, .3); background: rgba(251, 146, 60, .12); color: var(--clr-review); }
.rv-changes:hover { background: rgba(251,146,60,.22); }
.mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; }
diff --git a/frontend/src/components/ui/SkeletonLoader.vue b/frontend/src/components/ui/SkeletonLoader.vue
new file mode 100644
index 0000000..d141400
--- /dev/null
+++ b/frontend/src/components/ui/SkeletonLoader.vue
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ui/StatusPill.vue b/frontend/src/components/ui/StatusPill.vue
new file mode 100644
index 0000000..1fa0102
--- /dev/null
+++ b/frontend/src/components/ui/StatusPill.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
index c1a787d..e5419c8 100644
--- a/frontend/src/components/ui/index.ts
+++ b/frontend/src/components/ui/index.ts
@@ -5,6 +5,8 @@ export { default as PageHeading } from './PageHeading.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as ActivityTimeline } from './ActivityTimeline.vue'
export { default as SectionHeader } from './SectionHeader.vue'
+export { default as StatusPill } from './StatusPill.vue'
+export { default as SkeletonLoader } from './SkeletonLoader.vue'
export { default as Input } from './Input.vue'
export { default as Textarea } from './Textarea.vue'
export { default as Select } from './Select.vue'
diff --git a/frontend/src/composables/useConfirm.ts b/frontend/src/composables/useConfirm.ts
new file mode 100644
index 0000000..325a3bf
--- /dev/null
+++ b/frontend/src/composables/useConfirm.ts
@@ -0,0 +1,75 @@
+/**
+ * useConfirm — Wiederverwendbare Confirmation-Dialog-Logik
+ *
+ * Extrahiert aus dem Modal-Muster in TaskBoardView
+ * (showCreateModal / showChangesModal / showDetailPanel),
+ * das sich auch in anderen Views wiederholt.
+ *
+ * Bietet reaktiven open/close-State, Form-Fehler-Management
+ * und Escape-Key-Bindung.
+ */
+import { ref, watch, onMounted, onUnmounted } from 'vue'
+
+export function useConfirm() {
+ const isOpen = ref(false)
+ const error = ref('')
+ const success = ref('')
+ const submitting = ref(false)
+
+ function open() {
+ isOpen.value = true
+ error.value = ''
+ success.value = ''
+ }
+
+ function close() {
+ isOpen.value = false
+ }
+
+ function setError(msg: string) {
+ error.value = msg
+ success.value = ''
+ }
+
+ function setSuccess(msg: string) {
+ success.value = msg
+ error.value = ''
+ }
+
+ /** Escape-Taste schliesst den Dialog */
+ function onKeydown(e: KeyboardEvent) {
+ if (e.key === 'Escape' && isOpen.value) {
+ close()
+ }
+ }
+
+ /** Overlay-Klick ausserhalb schliesst */
+ function onOverlayClick(e: MouseEvent) {
+ if ((e.target as HTMLElement).classList.contains('modal-overlay')) {
+ close()
+ }
+ }
+
+ /** Sperrt Body-Scroll, wenn Dialog offen */
+ watch(isOpen, (open) => {
+ document.body.style.overflow = open ? 'hidden' : ''
+ })
+
+ onMounted(() => window.addEventListener('keydown', onKeydown))
+ onUnmounted(() => {
+ window.removeEventListener('keydown', onKeydown)
+ document.body.style.overflow = ''
+ })
+
+ return {
+ isOpen,
+ error,
+ success,
+ submitting,
+ open,
+ close,
+ setError,
+ setSuccess,
+ onOverlayClick,
+ }
+}
diff --git a/frontend/src/composables/useFormatDate.ts b/frontend/src/composables/useFormatDate.ts
new file mode 100644
index 0000000..3c3710a
--- /dev/null
+++ b/frontend/src/composables/useFormatDate.ts
@@ -0,0 +1,60 @@
+/**
+ * useFormatDate — Einheitliche Datums-/Zeitformatierung für Nexus V2
+ *
+ * Kapselt die mehrfach wiederholten formatDate-, relativeTime- und
+ * toDateInputValue-Helper, die bisher in TaskBoardView, BoardCard,
+ * mit anderen Views dupliziert waren.
+ *
+ * Alle Ausgaben orientieren sich an de-DE Locale.
+ */
+
+/**
+ * Datum als lesbaren String (de-DE, kurzes/medium-DateStyle).
+ */
+export 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' })
+}
+
+/**
+ * ISO-Datumsstring in YYYY-MM-DD für .
+ */
+export function toDateInputValue(date?: string | null): string {
+ if (!date) return ''
+ return new Date(date).toISOString().slice(0, 10)
+}
+
+/**
+ * Relative Zeit ("gerade eben", "vor 5 min", "vor 3 h", "vor 2 d").
+ * Optionaler Fallback, wenn date fehlt.
+ */
+export function relativeTime(date?: string | null, fallback = 'keine Aktivität'): string {
+ if (!date) return fallback
+ 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`
+}
+
+/**
+ * Minuten seit einem Datum (oder Infinity falls kein Datum).
+ */
+export function minutesSince(dateStr?: string | null): number {
+ if (!dateStr) return Infinity
+ return (Date.now() - new Date(dateStr).getTime()) / 60000
+}
+
+/**
+ * Stunden seit einem Datum (gerundet).
+ */
+export function hoursSince(dateStr: string): number {
+ const now = Date.now()
+ const then = new Date(dateStr).getTime()
+ return Math.round((now - then) / 3600000)
+}
diff --git a/frontend/src/views/TaskBoardView.vue b/frontend/src/views/TaskBoardView.vue
index fa45bce..c343e1b 100644
--- a/frontend/src/views/TaskBoardView.vue
+++ b/frontend/src/views/TaskBoardView.vue
@@ -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(() => {
Task Board
/
- {{ stateLabel(selectedTask.state) }}
+ {{ stateLabel(selectedTask.state) }}