# PROMPT 03 — Angular Signals State Management
## Prerequisites: Prompts 01–02 complete

---

## Your Task
Implement the three Angular Signals state services for CollabSpace.
No NgRx, no BehaviorSubject stores — pure Signals only.

---

## Pattern to Follow Strictly

```typescript
@Injectable({ providedIn: 'root' })
export class ExampleState {
  // 1. Private writable signals — NEVER exposed directly
  private readonly _data = signal<DataType>(initialValue);

  // 2. Public readonly — what templates and services consume
  readonly data = this._data.asReadonly();
  readonly derived = computed(() => this._data().someProperty);

  // 3. Mutations only through named methods — no setters
  setData(value: DataType): void      { this._data.set(value); }
  updateData(fn: (d: DataType) => DataType): void { this._data.update(fn); }
}
```

**Rule**: If a signal is `WritableSignal<T>` it must be private.
**Rule**: `Map<K,V>` signals must be replaced entirely on update — never mutate.

---

## File: `core/state/session.state.ts`

```typescript
@Injectable({ providedIn: 'root' })
export class SessionState {

  // ── Private signals ─────────────────────────────────────────────
  private readonly _activeSessions    = signal<CollabSession[]>([]);
  private readonly _scheduledSessions = signal<CollabSessionSummary[]>([]);
  private readonly _currentSession    = signal<CollabSession | null>(null);
  private readonly _isLoading         = signal<boolean>(false);
  private readonly _error             = signal<string | null>(null);

  // ── Public readonly signals ──────────────────────────────────────
  readonly activeSessions    = this._activeSessions.asReadonly();
  readonly scheduledSessions = this._scheduledSessions.asReadonly();
  readonly currentSession    = this._currentSession.asReadonly();
  readonly isLoading         = this._isLoading.asReadonly();
  readonly error             = this._error.asReadonly();

  // ── Computed (derived state) ─────────────────────────────────────
  readonly hasActiveSession    = computed(() => this._currentSession() !== null);
  readonly activeSessionCount  = computed(() => this._activeSessions().length);
  readonly currentSessionId    = computed(() => this._currentSession()?.sessionId ?? null);
  readonly currentSessionCode  = computed(() => this._currentSession()?.sessionCode ?? null);
  readonly currentSessionMode  = computed(() => this._currentSession()?.mode ?? null);
  readonly currentSessionTitle = computed(() => this._currentSession()?.title ?? '');
  readonly currentParticipants = computed(() => this._currentSession()?.participants ?? []);
  readonly participantCount    = computed(() => this.currentParticipants().length);

  // Scheduled sessions due within next 15 minutes
  readonly sessionsDueSoon = computed(() =>
    this._scheduledSessions().filter(s => {
      if (!s.scheduledAt) return false;
      const diffMs = s.scheduledAt.getTime() - Date.now();
      return diffMs >= 0 && diffMs <= 15 * 60 * 1000;
    })
  );

  // ── Session mutations ────────────────────────────────────────────
  setActiveSessions(sessions: CollabSession[]): void
  addActiveSession(session: CollabSession): void
  removeActiveSession(sessionId: string): void
  updateActiveSession(sessionId: string, changes: Partial<CollabSession>): void
  setScheduledSessions(sessions: CollabSessionSummary[]): void
  setCurrentSession(session: CollabSession): void
  clearCurrentSession(): void
  updateCurrentSessionStatus(status: SessionStatus): void

  // ── Participant mutations on currentSession ──────────────────────
  addParticipant(participant: CollabParticipant): void
  // Immutably adds to currentSession.participants array

  removeParticipant(userId: string): void
  // Marks participant isActive: false (don't splice — preserve history)

  updateParticipant(userId: string, changes: Partial<CollabParticipant>): void
  // Finds by userId, merges changes immutably

  transferControl(fromUserId: string, toUserId: string): void
  // Sets hasControl: false on fromUserId, hasControl: true on toUserId
  // Both in a single update (atomic)

  // ── UI/loading mutations ─────────────────────────────────────────
  setLoading(loading: boolean): void
  setError(error: string): void
  clearError(): void

  // ── Full reset ───────────────────────────────────────────────────
  reset(): void
  // Resets ALL signals to initial values
  // Called on session end or user logout
}
```

---

## File: `core/state/participant.state.ts`

```typescript
@Injectable({ providedIn: 'root' })
export class ParticipantState {

  // ── Private signals ─────────────────────────────────────────────
  // Map<userId, ParticipantPresence> — real-time cursor/presence only
  // NOT persisted — lives only in memory during session
  private readonly _presence        = signal<ReadonlyMap<string, ParticipantPresence>>(new Map());
  private readonly _myUserId        = signal<string | null>(null);
  private readonly _myRole          = signal<ParticipantRole | null>(null);
  private readonly _hasControl      = signal<boolean>(false);
  private readonly _isFollowingHost = signal<boolean>(true);

  // ── Public readonly ──────────────────────────────────────────────
  readonly presence         = this._presence.asReadonly();
  readonly myUserId         = this._myUserId.asReadonly();
  readonly myRole           = this._myRole.asReadonly();
  readonly hasControl       = this._hasControl.asReadonly();
  readonly isFollowingHost  = this._isFollowingHost.asReadonly();

  // ── Computed ─────────────────────────────────────────────────────
  readonly presenceList  = computed(() => Array.from(this._presence().values()));
  readonly onlineCount   = computed(() => this.presenceList().filter(p => p.isOnline).length);
  readonly isHost        = computed(() => this._myRole() === ParticipantRole.Host);
  readonly canEdit       = computed(() => canUserEdit(this._myRole()!, this._hasControl()));

  // Other participants' presence (exclude self for cursor overlay)
  readonly otherPresence = computed(() =>
    this.presenceList().filter(p => p.userId !== this._myUserId())
  );

  // ── Mutations ────────────────────────────────────────────────────
  setMyIdentity(userId: string, role: ParticipantRole): void
  setControl(hasControl: boolean): void
  toggleFollowHost(): void

  // Presence management — always create new Map (never mutate existing)
  upsertPresence(presence: ParticipantPresence): void
  removePresence(userId: string): void
  markOffline(userId: string): void

  updateCursor(userId: string, xPercent: number, yPercent: number): void
  // Efficient cursor update — updates only x/y on existing presence entry
  // Creates new Map to trigger signal reactivity

  clearAllPresence(): void

  // ── Full reset ───────────────────────────────────────────────────
  reset(): void
}
```

---

## File: `core/state/ui.state.ts`

```typescript
@Injectable({ providedIn: 'root' })
export class CollabUiState {

  // ── Private signals ─────────────────────────────────────────────
  private readonly _toolbarVisible       = signal<boolean>(false);
  private readonly _chatOpen             = signal<boolean>(false);
  private readonly _detailPanelOpen      = signal<boolean>(false);
  private readonly _inviteModalOpen      = signal<boolean>(false);
  private readonly _inviteTargetSession  = signal<string | null>(null); // sessionId
  private readonly _annotateMode         = signal<boolean>(false);
  private readonly _screenShareActive    = signal<boolean>(false);
  private readonly _unreadChatCount      = signal<number>(0);
  private readonly _controlRequest       = signal<{ userId: string; displayName: string } | null>(null);
  private readonly _toasts               = signal<CollabToast[]>([]);

  // ── Public readonly ──────────────────────────────────────────────
  readonly toolbarVisible      = this._toolbarVisible.asReadonly();
  readonly chatOpen            = this._chatOpen.asReadonly();
  readonly detailPanelOpen     = this._detailPanelOpen.asReadonly();
  readonly inviteModalOpen     = this._inviteModalOpen.asReadonly();
  readonly inviteTargetSession = this._inviteTargetSession.asReadonly();
  readonly annotateMode        = this._annotateMode.asReadonly();
  readonly screenShareActive   = this._screenShareActive.asReadonly();
  readonly unreadChatCount     = this._unreadChatCount.asReadonly();
  readonly controlRequest      = this._controlRequest.asReadonly();
  readonly toasts              = this._toasts.asReadonly();

  // ── Computed ─────────────────────────────────────────────────────
  readonly hasUnreadChat    = computed(() => this._unreadChatCount() > 0);
  readonly hasControlRequest = computed(() => this._controlRequest() !== null);

  // ── Mutations ────────────────────────────────────────────────────
  showToolbar(): void
  hideToolbar(): void
  toggleChat(): void
  openDetailPanel(): void
  closeDetailPanel(): void
  openInviteModal(sessionId: string): void
  closeInviteModal(): void
  toggleAnnotateMode(): void
  setScreenShareActive(active: boolean): void
  incrementUnreadChat(): void
  clearUnreadChat(): void
  setControlRequest(userId: string, displayName: string): void
  clearControlRequest(): void

  // Toast system
  showToast(toast: Omit<CollabToast, 'id' | 'createdAt'>): void
  dismissToast(id: string): void

  // ── Effects ─────────────────────────────────────────────────────
  // In constructor, set up this effect:
  // When chatOpen becomes true → clearUnreadChat()
  // Use: effect(() => { if (this._chatOpen()) { untracked(() => this.clearUnreadChat()); } })

  // ── Full reset ───────────────────────────────────────────────────
  reset(): void
}

// Toast model (define in this file)
export interface CollabToast {
  id: string;
  type: 'success' | 'info' | 'warning' | 'error';
  message: string;
  createdAt: Date;
  durationMs?: number;  // default 3500
}
```

---

## File: `core/state/index.ts`
Export all three state services and `CollabToast`.

---

## Additional: `core/state/collab-state-manager.service.ts`

```typescript
// Service that coordinates resetting all state on session end / logout
@Injectable({ providedIn: 'root' })
export class CollabStateManager {
  private readonly sessionState     = inject(SessionState);
  private readonly participantState = inject(ParticipantState);
  private readonly uiState          = inject(CollabUiState);

  resetAll(): void {
    this.sessionState.reset();
    this.participantState.reset();
    this.uiState.reset();
  }
}
```

---

## Verification Checklist
- [ ] All `WritableSignal` are `private readonly`
- [ ] All public signals are `Signal<T>` (via `.asReadonly()` or `computed()`)
- [ ] `Map` updates always create new `Map` instance (never `.set()` on existing)
- [ ] `effect()` in ui.state uses `untracked()` for writes to avoid infinite loop
- [ ] All three services have a `reset()` method that restores initial values
- [ ] `CollabStateManager.resetAll()` calls all three
