# UX-001: 事件卡片告警疲勞解決方案

**狀態**: 提案
**日期**: 2026-03-23
**提案者**: 統帥
**問題**: 事件卡片長時間堆積導致用戶麻木，失去警示效果

---

## 問題分析

```
現況：
┌─────────────────────────────────────────────────────┐
│  活躍事件                                            │
│  ┌────────────┐ ┌────────────┐ ┌────────────┐      │
│  │ P0 紅色    │ │ P0 紅色    │ │ P0 紅色    │      │
│  │ 09:39      │ │ 09:44      │ │ 09:57      │  ... │
│  │ mitigating │ │ mitigating │ │ mitigating │      │
│  └────────────┘ └────────────┘ └────────────┘      │
│                                                     │
│  問題：全部看起來一樣 → 視覺疲勞 → 無人處理          │
└─────────────────────────────────────────────────────┘
```

### 心理學原理

1. **Weber-Fechner Law**: 持續刺激會導致感知鈍化
2. **Banner Blindness**: 重複視覺元素被大腦自動忽略
3. **Decision Fatigue**: 太多選擇導致不做選擇

---

## 解決方案：四層優化

### Layer 1: 時間衰減視覺 (Time-based Visual Decay)

```
新舊卡片視覺差異：

🔴 剛發生 (< 5分鐘)     → 紅色脈動動畫 + 聲音提示
🟠 較新 (5-30分鐘)      → 橘色漸層 + 輕微動畫
🟡 等待中 (30分-2小時)  → 黃色靜態
⚪ 陳舊 (> 2小時)       → 灰色 + 淡化 50%
```

```tsx
// 時間衰減樣式
const getCardStyle = (createdAt: Date) => {
  const ageMinutes = (Date.now() - createdAt.getTime()) / 60000;

  if (ageMinutes < 5) return {
    borderColor: 'red',
    animation: 'pulse 1s infinite',
    playSound: true,
  };
  if (ageMinutes < 30) return {
    borderColor: 'orange',
    animation: 'subtle-glow 2s infinite',
  };
  if (ageMinutes < 120) return {
    borderColor: 'yellow',
    opacity: 0.9,
  };
  return {
    borderColor: 'gray',
    opacity: 0.5,
    filter: 'grayscale(50%)',
  };
};
```

### Layer 2: 智慧分組 (Smart Grouping)

```
優化後佈局：

┌─────────────────────────────────────────────────────┐
│  🚨 需要立即處理 (2)                    [全部處理]   │
│  ┌────────────────────────────────────────────────┐ │
│  │ 🔴 postgres-primary-0  P0  剛剛                │ │
│  │ 🔴 awoooi-worker       P0  3分鐘前             │ │
│  └────────────────────────────────────────────────┘ │
│                                                     │
│  ⏳ 等待處理 (3)                       [批量關閉 ▼] │
│  ┌────────────────────────────────────────────────┐ │
│  │ 🟡 harbor-core         P0  45分鐘前   [Y] [n]  │ │
│  │ 🟡 postgres-native     P0  1小時前    [Y] [n]  │ │
│  │ 🟡 health-check-test   P2  2小時前    [Y] [n]  │ │
│  └────────────────────────────────────────────────┘ │
│                                                     │
│  📦 陳舊事件 (1)                    [自動關閉倒數]   │
│  ┌────────────────────────────────────────────────┐ │
│  │ ⚪ postgres-primary-0  P0  3小時前  將在1h自動關 │ │
│  └────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```

### Layer 3: 升級機制 (Escalation)

```
時間軸自動升級：

0-15分鐘:  Dashboard 顯示
15-30分鐘: Telegram 通知 (首次)
30-60分鐘: Telegram 再次提醒 + @mention 負責人
1-2小時:   電話通知 (P0/P1)
2小時+:    自動降級為 P3 或自動關閉 (視規則)
```

```yaml
# 升級規則配置
escalation_rules:
  P0:
    - after: 15m
      action: telegram_notify
      message: "🚨 P0 事件等待處理: {incident_id}"
    - after: 30m
      action: telegram_mention
      mention: "@oncall"
    - after: 1h
      action: phone_call
      to: oncall_phone
    - after: 4h
      action: auto_close
      reason: "超過 4 小時無人處理，自動關閉"

  P1:
    - after: 30m
      action: telegram_notify
    - after: 2h
      action: auto_downgrade
      to: P3

  P2:
    - after: 2h
      action: auto_close
      reason: "低優先級超時自動關閉"
```

### Layer 4: 智慧合併 (Smart Merge)

```
同類事件合併：

Before:
├── postgres-primary-0 崩潰 (09:44)
├── postgres-primary-0 重啟 (09:57)
├── postgres-primary-0 OOM (10:15)

After:
├── 📦 postgres-primary-0 問題群組 (3 個事件)
│   └── 最近: OOM (10:15)
│   └── [展開詳情]
```

---

## 實施計畫

### Phase 1: 視覺優化 (Week 1)

```tsx
// apps/web/src/components/incident/IncidentCard.tsx

import { formatDistanceToNow } from 'date-fns';
import { zhTW } from 'date-fns/locale';

interface IncidentCardProps {
  incident: Incident;
}

export const IncidentCard = ({ incident }: IncidentCardProps) => {
  const ageMinutes = getAgeInMinutes(incident.created_at);
  const urgencyLevel = getUrgencyLevel(ageMinutes, incident.priority);

  return (
    <motion.div
      className={cn(
        "incident-card",
        urgencyLevel === 'critical' && "animate-pulse border-red-500",
        urgencyLevel === 'urgent' && "border-orange-400",
        urgencyLevel === 'waiting' && "border-yellow-300 opacity-90",
        urgencyLevel === 'stale' && "border-gray-300 opacity-50 grayscale-50"
      )}
      initial={{ scale: 0.95, opacity: 0 }}
      animate={{ scale: 1, opacity: 1 }}
    >
      {/* 時間徽章 */}
      <div className="absolute top-2 right-2">
        <Badge variant={urgencyLevel}>
          {formatDistanceToNow(incident.created_at, {
            addSuffix: true,
            locale: zhTW
          })}
        </Badge>
      </div>

      {/* 自動關閉倒數 (陳舊事件) */}
      {urgencyLevel === 'stale' && (
        <div className="text-xs text-gray-400 mt-2">
          ⏱️ 將在 {getAutoCloseCountdown(incident)} 自動關閉
        </div>
      )}

      {/* ... 其他內容 */}
    </motion.div>
  );
};
```

### Phase 2: 分組佈局 (Week 2)

```tsx
// apps/web/src/components/incident/IncidentDashboard.tsx

export const IncidentDashboard = () => {
  const { incidents } = useIncidents();

  const grouped = useMemo(() => ({
    critical: incidents.filter(i => getAgeMinutes(i) < 15),
    waiting: incidents.filter(i => getAgeMinutes(i) >= 15 && getAgeMinutes(i) < 120),
    stale: incidents.filter(i => getAgeMinutes(i) >= 120),
  }), [incidents]);

  return (
    <div className="space-y-6">
      {/* 緊急區 - 紅色背景 */}
      {grouped.critical.length > 0 && (
        <Section
          title="🚨 需要立即處理"
          count={grouped.critical.length}
          variant="critical"
          action={<Button>全部處理</Button>}
        >
          {grouped.critical.map(i => <IncidentCard key={i.id} incident={i} />)}
        </Section>
      )}

      {/* 等待區 - 黃色背景 */}
      {grouped.waiting.length > 0 && (
        <Section
          title="⏳ 等待處理"
          count={grouped.waiting.length}
          variant="waiting"
          collapsible
        >
          {grouped.waiting.map(i => <IncidentCard key={i.id} incident={i} />)}
        </Section>
      )}

      {/* 陳舊區 - 可折疊 */}
      {grouped.stale.length > 0 && (
        <Section
          title="📦 陳舊事件"
          count={grouped.stale.length}
          variant="stale"
          collapsible
          defaultCollapsed
        >
          {grouped.stale.map(i => <IncidentCard key={i.id} incident={i} />)}
        </Section>
      )}
    </div>
  );
};
```

### Phase 3: 升級機制 (Week 3)

```python
# apps/api/src/services/escalation_service.py

class EscalationService:
    """事件升級服務"""

    RULES = {
        'P0': [
            {'after_minutes': 15, 'action': 'telegram_notify'},
            {'after_minutes': 30, 'action': 'telegram_mention', 'mention': '@oncall'},
            {'after_minutes': 60, 'action': 'phone_call'},
            {'after_minutes': 240, 'action': 'auto_close'},
        ],
        'P1': [
            {'after_minutes': 30, 'action': 'telegram_notify'},
            {'after_minutes': 120, 'action': 'auto_downgrade', 'to': 'P3'},
        ],
        'P2': [
            {'after_minutes': 120, 'action': 'auto_close'},
        ],
    }

    async def check_escalations(self):
        """定期檢查需要升級的事件"""
        incidents = await self.get_open_incidents()

        for incident in incidents:
            age_minutes = self.get_age_minutes(incident)
            rules = self.RULES.get(incident.priority, [])

            for rule in rules:
                if age_minutes >= rule['after_minutes']:
                    if not await self.is_escalation_sent(incident.id, rule):
                        await self.execute_escalation(incident, rule)
```

---

## 預期效果

| 指標 | 現況 | 優化後 |
|------|------|--------|
| 平均處理時間 | > 2 小時 | < 30 分鐘 |
| 忽略率 | ~80% | < 20% |
| 用戶注意力 | 分散 | 集中在關鍵事件 |
| 告警疲勞 | 嚴重 | 可控 |

---

## 驗收標準

- [ ] 新事件有脈動動畫吸引注意
- [ ] 陳舊事件自動淡化
- [ ] 事件按時間分組顯示
- [ ] P0 事件 15 分鐘未處理自動 Telegram 通知
- [ ] 超過 4 小時自動關閉 (可配置)
- [ ] 同類事件智慧合併
