# Notification Expansion Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Wire push notifications to all major event types when app is backgrounded, and add in-app message toast banners for NPC conversations.

**Architecture:** Backend already has full APNS infrastructure (`notificationManager.ts`, `pushNotificationService.ts`). We add notification calls at each event dispatch point using the existing `queueRealtimeNotification()` helper. On iOS, we extend `ToastManager` with a `.message` toast type that carries an `onTap` action and NPC context.

**Tech Stack:** TypeScript (backend), SwiftUI (iOS), Vitest (tests)

---

### Task 1: Backend — Add push notifications to NPC-initiated messages

**Files:**
- Modify: `server/src/events/conversations/npc_initiative.ts:259-261`
- Modify: `server/src/events/conversations/npc_initiative.ts:1` (imports)

**Step 1: Add import for queueRealtimeNotification**

At the top of `npc_initiative.ts`, add to the existing imports:

```typescript
import { queueRealtimeNotification } from '../../services/notifications/notificationManager.js';
```

**Step 2: Add push notification after NPC message send**

After line 261 (`session.send(convo.toJSON());`), add:

```typescript
    // Push notification if app is backgrounded
    const npcName = character.firstname ?? 'Someone';
    const lastMsg = Array.isArray(convo.conversation) && convo.conversation.length > 0
      ? (convo.conversation[convo.conversation.length - 1] as any)?.message ?? ''
      : '';
    const preview = lastMsg.length > 80 ? lastMsg.substring(0, 80) + '...' : lastMsg;
    queueRealtimeNotification(player, {
      title: npcName,
      body: preview || 'sent you a message',
      type: 'relationship',
      id: convo.id,
    }).catch(err => {
      if (process.env.NODE_ENV !== 'test') {
        console.error('[NPC Initiative] Push notification error:', err);
      }
    });
```

**Step 3: Verify build**

Run: `cd server && npx tsc --noEmit`
Expected: No errors

**Step 4: Commit**

```bash
git add server/src/events/conversations/npc_initiative.ts
git commit -m "feat: push notification on NPC-initiated messages"
```

---

### Task 2: Backend — Add push notifications to message queue events

**Files:**
- Modify: `server/src/game/PlayerSession.ts:12` (imports)
- Modify: `server/src/game/PlayerSession.ts:148-169` (messageQueue dispatch)

**Step 1: Update import**

Change line 12 from:
```typescript
import { notifyRealtimeEvent, clearThrottle } from '../services/notifications/notificationManager.js';
```
to:
```typescript
import { notifyRealtimeEvent, queueRealtimeNotification, clearThrottle } from '../services/notifications/notificationManager.js';
```

**Step 2: Add push notification after messageQueue dispatch**

After the `this.send({type: 'messageEvent', ...})` block (around line 168), add:

```typescript
        // Push notification if app is backgrounded
        queueRealtimeNotification(this.player, {
          title: 'BaoLife',
          body: typeof message === 'string' ? (message.length > 80 ? message.substring(0, 80) + '...' : message) : 'New event',
          type: 'life_event',
        }).catch(err => console.error('[Push] messageQueue error:', err));
```

**Step 3: Verify build**

Run: `cd server && npx tsc --noEmit`
Expected: No errors

**Step 4: Run existing tests**

Run: `cd server && npx vitest run tests/services/notifications/`
Expected: All tests pass (existing mock prevents real sends)

**Step 5: Commit**

```bash
git add server/src/game/PlayerSession.ts
git commit -m "feat: push notification for message queue events"
```

---

### Task 3: Backend — Add push notifications to achievement unlocks

**Files:**
- Modify: `server/src/services/retention/integration.ts:32-47` (sendAchievementUnlocked)
- Modify: `server/src/services/retention/integration.ts` (imports)

**Step 1: Add import**

Add to the imports section at the top of `integration.ts`:

```typescript
import { queueRealtimeNotification } from '../notifications/notificationManager.js';
```

**Step 2: Add push notification in sendAchievementUnlocked**

After `session.send({type: 'achievementUnlocked', ...})` (line 46), add:

```typescript
  // Push notification if app is backgrounded
  queueRealtimeNotification(session.player, {
    title: 'Achievement Unlocked!',
    body: `${achievement.name}${achievement.reward ? ` — ${achievement.reward}` : ''}`,
    type: 'milestone',
    id: achievement.id,
  }).catch(err => console.error('[Push] achievement error:', err));
```

**Step 3: Verify build**

Run: `cd server && npx tsc --noEmit`
Expected: No errors

**Step 4: Commit**

```bash
git add server/src/services/retention/integration.ts
git commit -m "feat: push notification on achievement unlock"
```

---

### Task 4: Backend — Add push notifications to career events

**Files:**
- Modify: `server/src/services/retention/integration.ts:96-134` (onJobObtained, onPromotion, onFired)

**Step 1: Add push notification to onJobObtained**

After the `sendAchievementsUnlocked(session, unlocked)` call in `onJobObtained` (line 111), add:

```typescript
  queueRealtimeNotification(session.player, {
    title: 'Career Update',
    body: 'You got a new job!',
    type: 'life_event',
  }).catch(err => console.error('[Push] job error:', err));
```

**Step 2: Add push notification to onPromotion**

After `sendAchievementsUnlocked` in `onPromotion` (line 133), add:

```typescript
  queueRealtimeNotification(session.player, {
    title: 'Career Update',
    body: `You've been promoted to ${newTitle}!`,
    type: 'milestone',
  }).catch(err => console.error('[Push] promotion error:', err));
```

**Step 3: Add push notification to onFired**

Find `onFired` function and after its `sendAchievementsUnlocked` call, add:

```typescript
  queueRealtimeNotification(session.player, {
    title: 'Career Update',
    body: 'You have been fired from your job.',
    type: 'life_event',
  }).catch(err => console.error('[Push] fired error:', err));
```

**Step 4: Verify build**

Run: `cd server && npx tsc --noEmit`
Expected: No errors

**Step 5: Commit**

```bash
git add server/src/services/retention/integration.ts
git commit -m "feat: push notifications for career events (job, promotion, fired)"
```

---

### Task 5: Backend — Write tests for new notification triggers

**Files:**
- Create: `server/tests/services/notifications/notification-triggers.test.ts`

**Step 1: Write the test file**

```typescript
/**
 * Tests that new event dispatch points properly call queueRealtimeNotification
 */
import { describe, it, expect, beforeEach, vi } from 'vitest';

// Mock the notification manager
const mockQueueRealtimeNotification = vi.fn().mockResolvedValue({ sent: true });
vi.mock('../../../src/services/notifications/notificationManager.js', () => ({
  queueRealtimeNotification: mockQueueRealtimeNotification,
  notifyRealtimeEvent: vi.fn().mockResolvedValue({ sent: true }),
  clearThrottle: vi.fn(),
}));

// Mock push service (required by notificationManager)
vi.mock('../../../src/services/notifications/pushNotificationService.js', () => ({
  sendPushNotification: vi.fn().mockResolvedValue({ success: true }),
}));

import { sendAchievementUnlocked } from '../../../src/services/retention/integration.js';

// Minimal mock session
function createMockSession() {
  return {
    player: {
      userId: 'test-user',
      deviceToken: 'test-token',
      connection: 'disconnected',
    },
    send: vi.fn(),
  } as any;
}

describe('Notification Triggers', () => {
  beforeEach(() => {
    mockQueueRealtimeNotification.mockClear();
  });

  describe('Achievement Unlock', () => {
    it('should send push notification when achievement is unlocked', () => {
      const session = createMockSession();
      const achievement = {
        id: 'ach-1',
        key: 'first_job',
        name: 'First Job',
        description: 'Got your first job',
        icon: 'briefcase',
        reward: '50 diamonds',
      };

      sendAchievementUnlocked(session, achievement);

      // Should send to client
      expect(session.send).toHaveBeenCalledWith(
        expect.objectContaining({ type: 'achievementUnlocked' })
      );

      // Should queue push notification
      expect(mockQueueRealtimeNotification).toHaveBeenCalledWith(
        session.player,
        expect.objectContaining({
          title: 'Achievement Unlocked!',
          body: 'First Job — 50 diamonds',
          type: 'milestone',
        })
      );
    });
  });
});
```

**Step 2: Run the test**

Run: `cd server && npx vitest run tests/services/notifications/notification-triggers.test.ts`
Expected: PASS

**Step 3: Run full notification test suite**

Run: `cd server && npx vitest run tests/services/notifications/`
Expected: All pass

**Step 4: Commit**

```bash
git add server/tests/services/notifications/notification-triggers.test.ts
git commit -m "test: notification trigger tests for achievement unlock"
```

---

### Task 6: iOS — Add `.message` type to ToastType and tap action to ToastItem

**Files:**
- Modify: `ios/lichunWebsocket/Shared/Components/Overlays/ToastView.swift:10-33` (ToastType enum)
- Modify: `ios/lichunWebsocket/Shared/Managers/ToastManager.swift:103-112` (ToastItem struct)

**Step 1: Add `.message` case to ToastType**

In `ToastView.swift`, add to the `ToastType` enum:

```swift
enum ToastType {
    case success
    case error
    case info
    case warning
    case message  // NEW

    var color: Color {
        switch self {
        case .success: return AppColors.success
        case .error: return AppColors.error
        case .info: return AppColors.info
        case .warning: return AppColors.warning
        case .message: return AppColors.primary  // Soft rose pink
        }
    }

    var icon: String {
        switch self {
        case .success: return "checkmark.circle.fill"
        case .error: return "xmark.circle.fill"
        case .info: return "info.circle.fill"
        case .warning: return "exclamationmark.triangle.fill"
        case .message: return "bubble.left.fill"  // Chat bubble
        }
    }
}
```

**Step 2: Add tap action and metadata to ToastItem**

In `ToastManager.swift`, update the `ToastItem` struct:

```swift
struct ToastItem: Identifiable, Equatable {
    let id = UUID()
    let type: ToastType
    let message: String
    let duration: TimeInterval
    let title: String?           // NEW — e.g., NPC name
    let onTap: (() -> Void)?     // NEW — action when toast is tapped

    init(type: ToastType, message: String, duration: TimeInterval = 3.0, title: String? = nil, onTap: (() -> Void)? = nil) {
        self.type = type
        self.message = message
        self.duration = duration
        self.title = title
        self.onTap = onTap
    }

    static func == (lhs: ToastItem, rhs: ToastItem) -> Bool {
        lhs.id == rhs.id
    }
}
```

**Step 3: Add `showMessage` convenience method to ToastManager**

In `ToastManager.swift`, add after the existing convenience methods:

```swift
    /// Show a message toast (NPC message notification)
    func showMessage(from sender: String, preview: String, duration: TimeInterval = 4.0, onTap: (() -> Void)? = nil) {
        let toast = ToastItem(type: .message, message: preview, duration: duration, title: sender, onTap: onTap)

        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }

            if self.currentToast == nil {
                self.showToast(toast)
            } else {
                self.toastQueue.append(toast)
            }
        }
    }
```

**Step 4: Verify build in Xcode**

Open project in Xcode and build (Cmd+B) to confirm no compile errors.

**Step 5: Commit**

```bash
git add ios/lichunWebsocket/Shared/Components/Overlays/ToastView.swift
git add ios/lichunWebsocket/Shared/Managers/ToastManager.swift
git commit -m "feat: add .message toast type with tap action support"
```

---

### Task 7: iOS — Update toast UI to support tap action and message style

**Files:**
- Modify: `ios/lichunWebsocket/Shared/Managers/ToastManager.swift:115-138` (ToastOverlayView)
- Modify: `ios/lichunWebsocket/Shared/Managers/ToastManager.swift:141-178` (ManagedToastView)

**Step 1: Add tap gesture to ToastOverlayView**

Update `ToastOverlayView` to pass `onTap` through and wrap in a tap gesture:

```swift
struct ToastOverlayView: View {
    @StateObject private var manager = ToastManager.shared

    var body: some View {
        VStack {
            if let toast = manager.currentToast {
                ManagedToastView(
                    type: toast.type,
                    message: toast.message,
                    title: toast.title,
                    onTap: toast.onTap,
                    onDismiss: {
                        manager.dismissCurrent()
                    }
                )
                .padding(.horizontal, AppSpacing.md)
                .padding(.top, AppSpacing.md)
                .transition(.move(edge: .top).combined(with: .opacity))
                .zIndex(1000)
            }

            Spacer()
        }
        .animation(AppAnimations.standard, value: manager.currentToast?.id)
    }
}
```

**Step 2: Update ManagedToastView for message style and tap**

```swift
private struct ManagedToastView: View {
    let type: ToastType
    let message: String
    let title: String?
    let onTap: (() -> Void)?
    let onDismiss: () -> Void

    init(type: ToastType, message: String, title: String? = nil, onTap: (() -> Void)? = nil, onDismiss: @escaping () -> Void) {
        self.type = type
        self.message = message
        self.title = title
        self.onTap = onTap
        self.onDismiss = onDismiss
    }

    var body: some View {
        HStack(spacing: AppSpacing.sm) {
            Image(systemName: type.icon)
                .foregroundColor(type.color)
                .font(.system(size: 20))
                .accessibilityHidden(true)

            VStack(alignment: .leading, spacing: 2) {
                if let title = title {
                    Text(title)
                        .font(.appBodyBold)
                        .foregroundColor(AppColors.primaryText)
                        .lineLimit(1)
                }

                Text(message)
                    .font(.appBody)
                    .foregroundColor(title != nil ? AppColors.secondaryText : AppColors.primaryText)
                    .lineLimit(2)
            }
            .accessibilityElement(children: .combine)

            Spacer()

            Button(action: onDismiss) {
                Image(systemName: "xmark")
                    .foregroundColor(AppColors.secondaryText)
                    .font(.system(size: 12))
                    .padding(AppSpacing.xs)
            }
            .accessibilityLabel("Dismiss notification")
        }
        .padding(AppSpacing.md)
        .background(
            Color.black.opacity(0.9)
                .cornerRadius(AppSpacing.cornerRadius)
        )
        .shadow(color: Color.black.opacity(0.3), radius: 10, y: 5)
        .contentShape(Rectangle())
        .onTapGesture {
            if let onTap = onTap {
                onTap()
                onDismiss()
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityAddTraits(onTap != nil ? .isButton : .isStaticText)
    }
}
```

**Step 3: Verify build in Xcode**

Build (Cmd+B). Confirm no errors.

**Step 4: Commit**

```bash
git add ios/lichunWebsocket/Shared/Managers/ToastManager.swift
git commit -m "feat: toast UI supports title, message style, and tap action"
```

---

### Task 8: iOS — Wire conversationEvent to show message toast

**Files:**
- Modify: `ios/lichunWebsocket/WebSocketService.swift:628-671` (conversationEvent handler)

**Step 1: Add toast trigger after conversation update**

After the conversation is updated/appended in the `conversationEvent` handler (around line 656, after `self.player.activeConversations[index] = newConversation` and the append), add a toast notification for unread NPC messages:

```swift
                                                // Show in-app toast for unread NPC messages
                                                if newConversation.unread == true,
                                                   let characterId = newConversation.character,
                                                   let npc = self.player.r.first(where: { $0.id == characterId }) {
                                                    let lastMessage = newConversation.conversation.last
                                                    let preview = lastMessage?.message ?? "sent you a message"
                                                    let truncated = preview.count > 80 ? String(preview.prefix(80)) + "..." : preview
                                                    let npcName = npc.firstname ?? "Someone"
                                                    let convId = newConversation.id

                                                    ToastManager.shared.showMessage(
                                                        from: npcName,
                                                        preview: truncated,
                                                        duration: 4.0,
                                                        onTap: { [weak self] in
                                                            self?.openConversation(characterId: characterId, conversationId: convId)
                                                        }
                                                    )
                                                }
```

**Step 2: Add openConversation helper method to WebSocketService**

Add a new method to `WebSocketService` (near the bottom, before the closing brace):

```swift
    /// Open a specific conversation by navigating to ChatView
    func openConversation(characterId: String, conversationId: String) {
        // Set the conversation to open — ChatView/MessagesView will observe this
        DispatchQueue.main.async {
            self.pendingOpenConversation = characterId
        }
    }
```

**Step 3: Add the @Published property**

Add to WebSocketService's published properties (near the other @Published declarations):

```swift
    @Published var pendingOpenConversation: String? = nil
```

**Step 4: Verify build in Xcode**

Build (Cmd+B). Confirm no errors. Note: the navigation from `pendingOpenConversation` to actually presenting ChatView will be wired in the next task.

**Step 5: Commit**

```bash
git add ios/lichunWebsocket/WebSocketService.swift
git commit -m "feat: show message toast on NPC conversation arrival"
```

---

### Task 9: iOS — Wire toast tap to open ChatView directly

**Files:**
- Modify: `ios/lichunWebsocket/ContentView.swift` or `ios/lichunWebsocket/lichunWebsocketApp.swift`
- Check: How messages sheet is presented currently

This task wires the `pendingOpenConversation` property to actually open the conversation. The exact implementation depends on how the Messages UI is currently presented (sheet from header). The approach:

**Step 1: Find how messages/chat is presented**

Look at how `showMessagesFromHeader` works in the app. The toast tap should trigger the same flow but pre-select the conversation.

**Step 2: Add `.onChange` observer for pendingOpenConversation**

In `ContentView.swift`, add an `.onChange` modifier that watches `webSocketService.pendingOpenConversation`:

```swift
.onChange(of: webSocketService.pendingOpenConversation) { characterId in
    if let characterId = characterId {
        // Open messages sheet with this conversation selected
        appViewModel.showMessagesFromHeader = true
        // Give the sheet time to appear, then select the conversation
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            webSocketService.selectedConversationCharacterId = characterId
            webSocketService.pendingOpenConversation = nil
        }
    }
}
```

Note: The exact property names (`showMessagesFromHeader`, `selectedConversationCharacterId`) need to be verified against the actual codebase. Adjust to match the real navigation mechanism.

**Step 3: Verify build and test manually**

Build and run in simulator. Test the flow:
1. Receive an NPC message while on the Home tab
2. Verify toast appears with NPC name and message preview
3. Tap the toast
4. Verify it opens the conversation directly

**Step 4: Commit**

```bash
git add ios/lichunWebsocket/ContentView.swift
git add ios/lichunWebsocket/WebSocketService.swift
git commit -m "feat: toast tap opens conversation directly"
```

---

### Task 10: Verification — Full build and test

**Step 1: Backend — run full test suite**

Run: `cd server && npx vitest run`
Expected: All tests pass (962+ tests)

**Step 2: Backend — type check**

Run: `cd server && npx tsc --noEmit`
Expected: No errors

**Step 3: iOS — build**

Run: Build in Xcode (Cmd+B)
Expected: Clean build

**Step 4: Manual end-to-end test**

1. Start dev server: `cd server && npm run dev`
2. Run iOS app in simulator
3. Create character and play for a bit
4. Background the app (or close WebSocket)
5. Wait for events to fire on server
6. Check console for `[APNS Stub] Would send to ...` messages
7. Verify push notification payloads look correct

**Step 5: Commit any final fixes**

```bash
git add -A
git commit -m "fix: notification expansion final adjustments"
```
