diff --git a/BACKLOG.md b/BACKLOG.md index 5110a33..4be9466 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -408,7 +408,72 @@ npm install --save-dev @welldone-software/why-did-you-render ## MEDIUM Priority -### 6. Bundle Size Monitoring +### 6. App Telemetry + +**Confidence:** 90% +**Effort:** 2-3 days + +**Issue:** No telemetry/analytics to understand user behavior, feature usage, or performance metrics in production. + +**Action Items:** + +1. **Choose telemetry provider:** + - Expo Analytics (built-in, simple) + - Segment (multi-destination) + - Mixpanel (product analytics) + - PostHog (open source, self-hostable) + +2. **Track key events:** + - User sessions (start, end, duration) + - Feature usage (sessions viewed, notifications checked, actions taken) + - Performance metrics (screen load times, API response times) + - Errors and crashes (complement Sentry) + - User flows (navigation paths, drop-off points) + +3. **Implementation example with Expo Analytics:** + +```typescript +// utils/analytics.ts +import * as Analytics from 'expo-analytics' + +export const analytics = { + trackEvent: (name: string, properties?: Record) => { + if (!__DEV__) { + Analytics.logEvent(name, properties) + } + }, + trackScreen: (screenName: string) => { + if (!__DEV__) { + Analytics.setCurrentScreen(screenName) + } + }, + setUserProperties: (properties: Record) => { + if (!__DEV__) { + Analytics.setUserProperties(properties) + } + }, +} + +// Usage in components +analytics.trackEvent('session_viewed', { sessionId, status }) +analytics.trackScreen('SessionDetail') +``` + +4. **Privacy considerations:** + - Update Privacy Manifest with data collection disclosure + - Implement opt-out mechanism + - Anonymize sensitive data (no tokens, no PII) + - GDPR/CCPA compliance + +**Benefits:** +- Understand which features users actually use +- Identify performance bottlenecks in production +- Data-driven product decisions +- Track adoption and retention metrics + +--- + +### 7. Bundle Size Monitoring **Confidence:** 80% **Effort:** 4 hours @@ -440,7 +505,7 @@ npm install --save-dev react-native-bundle-visualizer --- -### 7. Inconsistent Type Imports +### 8. Inconsistent Type Imports **Confidence:** 80% **Effort:** 1 hour @@ -458,7 +523,7 @@ This should auto-fix most instances. Review and commit. --- -### 8. Security Audit Completion +### 9. Security Audit Completion **Confidence:** 95% **Effort:** 2-3 weeks diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index f5e01e9..f7a0d5d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -14,6 +14,7 @@ import { useAuth } from '@/hooks/useAuth' import { useSessions } from '@/hooks/useSessions' import { useOffline } from '@/hooks/useOffline' import { useRealtimeSession } from '@/hooks/useRealtimeSession' +import { useNotifications } from '@/hooks/useNotifications' import { Header } from '@/components/layout/Header' import { SessionCard } from '@/components/session/SessionCard' import { SessionStatus, type Session } from '@/types/session' @@ -33,11 +34,12 @@ interface QuickAction { interface QuickActionButtonProps { action: QuickAction - colors: any + colors: ReturnType['colors'] } // Memoized Quick Action Button component -const QuickActionButton = memo(({ action, colors }: QuickActionButtonProps) => { +const QuickActionButton = memo( + ({ action, colors }: QuickActionButtonProps) => { const dynamicText = action.count !== undefined ? `${action.count} ${action.text}` : action.text return ( @@ -53,12 +55,14 @@ const QuickActionButton = memo(({ action, colors }: QuickActionButtonProps) => { accessibilityState={{ disabled: action.disabled }} > [0]['name']} size={28} color={action.disabled ? colors.textSecondary : '#fff'} /> {dynamicText} @@ -69,7 +73,10 @@ const QuickActionButton = memo(({ action, colors }: QuickActionButtonProps) => { )} ) -}) +} +) + +QuickActionButton.displayName = 'QuickActionButton' export default function DashboardScreen() { const { colors } = useTheme() @@ -77,6 +84,7 @@ export default function DashboardScreen() { const { data: sessions, isLoading, refetch, isRefetching } = useSessions() const { isOffline } = useOffline() const { retry, isConnected, isError } = useRealtimeSession() + const { unreadCount } = useNotifications() const router = useRouter() // Filter sessions by status - optimized with single-pass filter and memoization @@ -126,12 +134,19 @@ export default function DashboardScreen() { count: runningSessions.length, onPress: () => router.push('/sessions/?filter=running'), }, + { + id: 'notifications', + icon: 'bell.fill', + text: 'GitHub Notifications', + count: unreadCount > 0 ? unreadCount : undefined, + onPress: () => router.push('/notifications/'), + }, { id: 'lucky', icon: 'dice.fill', text: "I'm Feeling Lucky" }, { id: 'inspire', icon: 'lightbulb.fill', text: 'Inspire Me' }, { id: 'invent', icon: 'sparkles', text: 'Go Invent' }, { id: 'add', icon: 'plus.circle.fill', text: 'Add Action', disabled: true, badge: 'Soon' }, ], - [runningSessions.length, router] + [runningSessions.length, unreadCount, router] ) // Render callback for Quick Action buttons @@ -204,20 +219,16 @@ export default function DashboardScreen() { )} - {/* Quick Actions - Optimized with FlatList and memoization */} + {/* Quick Actions - 2 rows of 3 */} Quick Actions - + + {quickActions.map((action) => ( + + + + ))} + {/* My Reviews */} @@ -378,32 +389,31 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: '600', }, - quickActions: { + quickActionsGrid: { flexDirection: 'row', + flexWrap: 'wrap', gap: 12, + marginTop: 12, }, - quickActionsScroll: { - marginHorizontal: -16, - paddingHorizontal: 16, - }, - quickActionsContent: { - paddingHorizontal: 16, - gap: 12, + quickActionWrapper: { + width: '31%', // 3 columns with gaps }, quickActionButton: { - width: 110, - paddingVertical: 16, + width: '100%', + height: 100, + paddingVertical: 12, paddingHorizontal: 8, borderRadius: 12, alignItems: 'center', - marginRight: 12, + justifyContent: 'center', gap: 8, }, quickActionText: { color: '#fff', - fontSize: 13, + fontSize: 11, fontWeight: '600', textAlign: 'center', + lineHeight: 14, }, soonBadge: { position: 'absolute', diff --git a/app/_layout.tsx b/app/_layout.tsx index e225b74..4c0a9be 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -6,18 +6,11 @@ import { ThemeProvider, useTheme } from '@/hooks/useTheme' import { ToastProvider, useToast } from '@/hooks/useToast' import { Toast } from '@/components/ui/Toast' import { ErrorBoundary } from '@/components/ErrorBoundary' -import { PerformanceToggle } from '@/components/PerformanceMonitor' +import { CreateFAB } from '@/components/layout/CreateFAB' import { errorHandler } from '@/utils/errorHandler' import { useEffect, useState } from 'react' import { View, Text, TouchableOpacity, StyleSheet } from 'react-native' -// Performance monitoring (dev only) -if (__DEV__) { - // Initialize why-did-you-render for React render tracking - const { initializeWhyDidYouRender } = require('@/utils/renderTracker') - initializeWhyDidYouRender() -} - // Singleton QueryClient instance (prevents memory leaks and cache loss) let queryClient: QueryClient | null = null @@ -89,17 +82,25 @@ function RootLayoutNav() { }, headerTintColor: colors.text, headerShadowVisible: false, + headerBackTitle: '', contentStyle: { backgroundColor: colors.bg, }, }} > - + - + ) } @@ -126,7 +127,7 @@ export default function RootLayout() { }) const fpsMonitor = startFPSMonitoring({ - slowFrameThreshold: 50, // Warn if FPS drops below 50 + slowFrameThreshold: 30, // Warn if FPS drops below 30 (more reasonable for mobile) }) console.log('🔍 Performance monitoring active') diff --git a/app/notifications/index.tsx b/app/notifications/index.tsx new file mode 100644 index 0000000..37edeb2 --- /dev/null +++ b/app/notifications/index.tsx @@ -0,0 +1,254 @@ +import React, { useState, useCallback } from 'react' +import { + View, + Text, + FlatList, + StyleSheet, + TouchableOpacity, + ScrollView, + Alert, +} from 'react-native' +import { Feather } from '@expo/vector-icons' +import { useTheme } from '@/hooks/useTheme' +import { useNotifications, useMarkAllAsRead } from '@/hooks/useNotifications' +import { useNotificationActions } from '@/components/notifications/NotificationActions' +import { NotificationCard } from '@/components/notifications/NotificationCard' +import type { GitHubNotification } from '@/types/notification' + +type FilterType = 'all' | 'unread' + +export default function NotificationsScreen() { + const { colors } = useTheme() + const [filter, setFilter] = useState('all') + const { notifications, unreadCount, isLoading, refetch } = useNotifications( + filter === 'unread' + ) + const { showActions } = useNotificationActions() + const markAllAsRead = useMarkAllAsRead() + + const filters: { label: string; value: FilterType; badge?: number }[] = [ + { label: 'All', value: 'all' }, + { label: 'Unread', value: 'unread', badge: unreadCount }, + ] + + const handleNotificationPress = useCallback( + (notification: GitHubNotification) => { + showActions(notification, () => { + // Refetch after action completes + refetch() + }) + }, + [showActions, refetch] + ) + + const handleMarkAllAsRead = useCallback(() => { + if (unreadCount === 0) { + Alert.alert('No Unread Notifications', 'All notifications are already marked as read.') + return + } + + Alert.alert( + 'Mark All as Read', + `Mark all ${unreadCount} notification${unreadCount === 1 ? '' : 's'} as read?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Mark All', + onPress: async () => { + await markAllAsRead.mutateAsync() + refetch() + }, + }, + ] + ) + }, [unreadCount, markAllAsRead, refetch]) + + return ( + + {/* Header with Mark All Read button */} + + GitHub Notifications + {unreadCount > 0 && ( + + + Mark all read + + )} + + + {/* Filter Chips */} + + {filters.map((f) => { + const isActive = filter === f.value + return ( + setFilter(f.value)} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={`Filter by ${f.label}`} + accessibilityState={{ selected: isActive }} + accessibilityHint={`Double tap to show ${f.label.toLowerCase()} notifications`} + > + + {f.label} + + {f.badge !== undefined && f.badge > 0 && ( + + + {f.badge > 99 ? '99+' : f.badge} + + + )} + + ) + })} + + + {/* Notifications List */} + ( + + ), + [handleNotificationPress] + )} + keyExtractor={useCallback((item: GitHubNotification) => item.id, [])} + contentContainerStyle={styles.scrollContent} + ListEmptyComponent={ + isLoading ? ( + + Loading notifications... + + ) : ( + + + + No notifications + + + {filter === 'unread' + ? 'All caught up! No unread notifications.' + : "You're all caught up! Check back later."} + + + ) + } + ListFooterComponent={} + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={5} + removeClippedSubviews={true} + onRefresh={refetch} + refreshing={isLoading} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + }, + headerTitle: { + fontSize: 24, + fontWeight: '700', + }, + markAllButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + }, + markAllText: { + fontSize: 13, + fontWeight: '600', + }, + filtersContainer: { + flexGrow: 0, + }, + filtersContent: { + paddingHorizontal: 16, + paddingBottom: 12, + gap: 8, + }, + filterChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + marginRight: 8, + }, + filterText: { + fontSize: 14, + fontWeight: '600', + }, + badge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 10, + minWidth: 20, + alignItems: 'center', + justifyContent: 'center', + }, + badgeText: { + fontSize: 11, + fontWeight: '700', + }, + scrollContent: { + paddingHorizontal: 16, + }, + loadingText: { + textAlign: 'center', + marginTop: 40, + fontSize: 14, + }, + emptyState: { + padding: 60, + borderRadius: 12, + alignItems: 'center', + marginTop: 20, + gap: 12, + }, + emptyStateText: { + fontSize: 18, + fontWeight: '700', + marginTop: 8, + }, + emptyStateSubtext: { + fontSize: 14, + textAlign: 'center', + }, +}) diff --git a/app/sessions/_layout.tsx b/app/sessions/_layout.tsx index 0a84c81..2d4f653 100644 --- a/app/sessions/_layout.tsx +++ b/app/sessions/_layout.tsx @@ -6,20 +6,11 @@ export default function SessionsLayout() { screenOptions={{ headerShown: true, headerBackTitle: '', + headerTitle: '', }} > - - + + ) } diff --git a/components/layout/CreateFAB.tsx b/components/layout/CreateFAB.tsx new file mode 100644 index 0000000..74fd3e7 --- /dev/null +++ b/components/layout/CreateFAB.tsx @@ -0,0 +1,225 @@ +import React, { useState } from 'react' +import { + View, + Text, + TouchableOpacity, + StyleSheet, + Modal, + Pressable, + Platform, +} from 'react-native' +import { Feather } from '@expo/vector-icons' +import { useTheme } from '@/hooks/useTheme' +import { useRouter } from 'expo-router' + +interface CreateOption { + id: string + label: string + icon: keyof typeof Feather.glyphMap + route?: string + soon?: boolean +} + +/** + * Floating Action Button for creating new items + * Shows a modal with creation options when tapped + */ +export function CreateFAB() { + const { colors } = useTheme() + const router = useRouter() + const [modalVisible, setModalVisible] = useState(false) + + const createOptions: CreateOption[] = [ + { id: 'agent', label: 'Agent', icon: 'user', soon: false }, + { id: 'scheduled-task', label: 'Scheduled Task', icon: 'clock', soon: false }, + { id: 'session', label: 'Session', icon: 'zap', route: '/sessions/new' }, + { id: 'skill', label: 'Skill', icon: 'target', soon: false }, + { id: 'workflow', label: 'Workflow', icon: 'git-branch', soon: true }, + ].sort((a, b) => a.label.localeCompare(b.label)) + + const handleOptionPress = (option: CreateOption) => { + setModalVisible(false) + + if (option.soon) { + // TODO: Show "Coming Soon" toast + console.log(`${option.label} coming soon!`) + return + } + + if (option.route) { + router.push(option.route as any) + } else { + // TODO: Navigate to specific creation screens when implemented + console.log(`Create ${option.label} - Not implemented yet`) + } + } + + return ( + <> + {/* FAB Button */} + setModalVisible(true)} + activeOpacity={0.8} + accessibilityRole="button" + accessibilityLabel="Create new item" + accessibilityHint="Double tap to see creation options" + > + + + + {/* Creation Options Modal */} + setModalVisible(false)} + > + setModalVisible(false)}> + e.stopPropagation()}> + + {/* Header */} + + Create New + setModalVisible(false)} + style={styles.closeButton} + accessibilityRole="button" + accessibilityLabel="Close" + > + + + + + {/* Options */} + + {createOptions.map((option) => ( + handleOptionPress(option)} + activeOpacity={0.7} + disabled={option.soon} + > + + + + + {option.label} + + {option.soon && ( + + Soon + + )} + + ))} + + + + + + + ) +} + +const styles = StyleSheet.create({ + fab: { + position: 'absolute', + bottom: Platform.OS === 'ios' ? 90 : 80, + right: 20, + width: 60, + height: 60, + borderRadius: 30, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + zIndex: 1000, + }, + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + modalContent: { + width: '85%', + maxWidth: 400, + }, + modalContainer: { + borderRadius: 16, + padding: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 10, + elevation: 5, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + modalTitle: { + fontSize: 22, + fontWeight: '700', + }, + closeButton: { + padding: 4, + }, + optionsContainer: { + gap: 12, + }, + optionButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + borderWidth: 1, + gap: 12, + }, + optionIconContainer: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', + }, + optionLabel: { + fontSize: 16, + fontWeight: '600', + flex: 1, + }, + soonBadge: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + }, + soonText: { + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + }, +}) diff --git a/components/notifications/NotificationActions.tsx b/components/notifications/NotificationActions.tsx new file mode 100644 index 0000000..1edf83a --- /dev/null +++ b/components/notifications/NotificationActions.tsx @@ -0,0 +1,166 @@ +import React from 'react' +import { Platform, ActionSheetIOS, Alert, Linking } from 'react-native' +import type { GitHubNotification } from '@/types/notification' +import { NOTIFICATION_WORKFLOW_MAP } from '@/utils/constants' +import { useMarkAsRead, useMuteThread } from '@/hooks/useNotifications' + +export interface NotificationActionsProps { + notification: GitHubNotification + onActionComplete?: () => void +} + +/** + * Hook to show notification action sheet + * Provides actions: preview, start workflow (soon), mark as read, open in browser, mute + */ +export function useNotificationActions() { + const markAsRead = useMarkAsRead() + const muteThread = useMuteThread() + + const showActions = React.useCallback( + (notification: GitHubNotification, onActionComplete?: () => void) => { + const suggestedWorkflow = NOTIFICATION_WORKFLOW_MAP[notification.type] || 'review' + const workflowLabel = suggestedWorkflow.charAt(0).toUpperCase() + suggestedWorkflow.slice(1) + + const options = [ + 'Preview', + `Start ${workflowLabel} (Soon)`, + notification.isUnread ? 'Mark as Read' : 'Mark as Unread', + 'Open in Browser', + 'Mute Thread', + 'Cancel', + ] + + const destructiveButtonIndex = 4 // Mute Thread + const cancelButtonIndex = 5 + + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { + options, + cancelButtonIndex, + destructiveButtonIndex, + title: notification.title, + message: `${notification.repository} #${notification.itemNumber}`, + }, + async (buttonIndex) => { + switch (buttonIndex) { + case 0: // Preview + // TODO: Implement preview in future iteration + Alert.alert('Preview', 'Preview feature coming soon!') + break + + case 1: // Start Workflow + Alert.alert( + 'Coming Soon', + `Starting ${workflowLabel} workflow from notifications will be available soon!` + ) + break + + case 2: // Mark as Read/Unread + if (notification.isUnread) { + await markAsRead.mutateAsync([notification.id]) + onActionComplete?.() + } else { + // TODO: Implement mark as unread in API + Alert.alert('Mark as Unread', 'This feature will be available soon!') + } + break + + case 3: // Open in Browser + await Linking.openURL(notification.url) + // Mark as read when opening in browser + if (notification.isUnread) { + await markAsRead.mutateAsync([notification.id]) + onActionComplete?.() + } + break + + case 4: // Mute Thread + Alert.alert('Mute Thread', 'Are you sure you want to mute this thread?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Mute', + style: 'destructive', + onPress: async () => { + await muteThread.mutateAsync(notification.id) + onActionComplete?.() + }, + }, + ]) + break + + case 5: // Cancel + break + } + } + ) + } else { + // Android: Use Alert with options (simplified UX) + Alert.alert( + notification.title, + `${notification.repository} #${notification.itemNumber}\n\nChoose an action:`, + [ + { + text: 'Preview', + onPress: () => { + Alert.alert('Preview', 'Preview feature coming soon!') + }, + }, + { + text: `Start ${workflowLabel} (Soon)`, + onPress: () => { + Alert.alert( + 'Coming Soon', + `Starting ${workflowLabel} workflow from notifications will be available soon!` + ) + }, + }, + { + text: notification.isUnread ? 'Mark as Read' : 'Mark as Unread', + onPress: async () => { + if (notification.isUnread) { + await markAsRead.mutateAsync([notification.id]) + onActionComplete?.() + } else { + Alert.alert('Mark as Unread', 'This feature will be available soon!') + } + }, + }, + { + text: 'Open in Browser', + onPress: async () => { + await Linking.openURL(notification.url) + if (notification.isUnread) { + await markAsRead.mutateAsync([notification.id]) + onActionComplete?.() + } + }, + }, + { + text: 'Mute Thread', + style: 'destructive', + onPress: () => { + Alert.alert('Mute Thread', 'Are you sure you want to mute this thread?', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Mute', + style: 'destructive', + onPress: async () => { + await muteThread.mutateAsync(notification.id) + onActionComplete?.() + }, + }, + ]) + }, + }, + { text: 'Cancel', style: 'cancel' }, + ] + ) + } + }, + [markAsRead, muteThread] + ) + + return { showActions } +} diff --git a/components/notifications/NotificationCard.tsx b/components/notifications/NotificationCard.tsx new file mode 100644 index 0000000..012ba8f --- /dev/null +++ b/components/notifications/NotificationCard.tsx @@ -0,0 +1,192 @@ +import React, { memo } from 'react' +import { View, TouchableOpacity, StyleSheet, Text } from 'react-native' +import { Feather } from '@expo/vector-icons' +import type { GitHubNotification, NotificationType } from '@/types/notification' +import { useTheme } from '@/hooks/useTheme' + +interface NotificationCardProps { + notification: GitHubNotification + onPress: (notification: GitHubNotification) => void +} + +/** + * Get icon name for notification type + */ +function getNotificationIcon(type: NotificationType): keyof typeof Feather.glyphMap { + switch (type) { + case 'pull_request': + return 'git-pull-request' + case 'pull_request_review': + return 'eye' + case 'issue': + return 'alert-circle' + case 'issue_comment': + return 'message-circle' + case 'commit_comment': + return 'git-commit' + case 'mention': + return 'at-sign' + case 'release': + return 'tag' + case 'security_alert': + return 'shield' + default: + return 'bell' + } +} + +/** + * Format timestamp to relative time + */ +function formatRelativeTime(date: Date): string { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return 'just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + + // Format as date for older notifications + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +function NotificationCardComponent({ notification, onPress }: NotificationCardProps) { + const { colors } = useTheme() + + return ( + onPress(notification)} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={`${notification.type} notification from ${notification.repository}: ${notification.title}`} + accessibilityHint="Double tap to view notification actions" + > + {/* Unread indicator dot */} + {notification.isUnread && ( + + )} + + {/* Icon */} + + + + + {/* Content */} + + {/* Repository */} + + {notification.repository} + + + {/* Title */} + + {notification.title} + + + {/* Metadata */} + + + #{notification.itemNumber} by {notification.author} + + • + + {formatRelativeTime(notification.timestamp)} + + + + + {/* Chevron */} + + + ) +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderRadius: 12, + marginBottom: 8, + gap: 12, + borderLeftWidth: 3, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + unreadDot: { + position: 'absolute', + top: 12, + left: 8, + width: 8, + height: 8, + borderRadius: 4, + }, + iconContainer: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + content: { + flex: 1, + gap: 4, + }, + repository: { + fontSize: 12, + fontWeight: '500', + }, + title: { + fontSize: 15, + lineHeight: 20, + }, + metadata: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + metadataText: { + fontSize: 12, + }, + dot: { + fontSize: 12, + }, +}) + +/** + * Memoized NotificationCard to prevent unnecessary re-renders + * Only re-renders when notification ID or isUnread status changes + */ +export const NotificationCard = memo( + NotificationCardComponent, + (prevProps, nextProps) => + prevProps.notification.id === nextProps.notification.id && + prevProps.notification.isUnread === nextProps.notification.isUnread +) diff --git a/hooks/useNotifications.ts b/hooks/useNotifications.ts new file mode 100644 index 0000000..c156028 --- /dev/null +++ b/hooks/useNotifications.ts @@ -0,0 +1,179 @@ +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { AppState } from 'react-native' +import { NotificationsAPI } from '@/services/api/notifications' +import type { GitHubNotification } from '@/types/notification' +import { POLLING_INTERVALS, CACHE_TTL } from '@/utils/constants' + +/** + * Hook for fetching and managing GitHub notifications + * - Polls every 30 seconds when app is active + * - Refreshes when app comes to foreground + * - Provides unread count for badge display + */ +export function useNotifications(unreadOnly = false) { + const queryClient = useQueryClient() + const appState = useRef(AppState.currentState) + + const query = useQuery({ + queryKey: ['notifications', unreadOnly ? 'unread' : 'all'], + queryFn: () => NotificationsAPI.fetchNotifications(unreadOnly), + staleTime: CACHE_TTL.NOTIFICATIONS, + gcTime: CACHE_TTL.NOTIFICATIONS, + // Poll every 30 seconds for new notifications + refetchInterval: POLLING_INTERVALS.NOTIFICATIONS, + refetchIntervalInBackground: false, + }) + + // Setup app foreground refresh + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + const wasBackground = appState.current.match(/inactive|background/) + const isActive = nextAppState === 'active' + + if (wasBackground && isActive) { + // App has come to foreground, refetch immediately + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + } + + appState.current = nextAppState + }) + + return () => { + subscription.remove() + } + }, [queryClient]) + + return { + notifications: query.data?.notifications ?? [], + unreadCount: query.data?.unreadCount ?? 0, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + refetch: query.refetch, + } +} + +/** + * Hook for marking notifications as read + * - Optimistic update for instant UI feedback + * - Invalidates queries on success + */ +export function useMarkAsRead() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (notificationIds: string[]) => NotificationsAPI.markAsRead(notificationIds), + onMutate: async (notificationIds) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey: ['notifications'] }) + + // Snapshot previous values + const previousAll = queryClient.getQueryData(['notifications', 'all']) + const previousUnread = queryClient.getQueryData(['notifications', 'unread']) + + // Optimistically update to mark as read + queryClient.setQueryData( + ['notifications', 'all'], + (old: { notifications: GitHubNotification[]; unreadCount: number } | undefined) => { + if (!old) return old + return { + notifications: old.notifications.map((n) => + notificationIds.includes(n.id) ? { ...n, isUnread: false } : n + ), + unreadCount: Math.max(0, old.unreadCount - notificationIds.length), + } + } + ) + + queryClient.setQueryData( + ['notifications', 'unread'], + (old: { notifications: GitHubNotification[]; unreadCount: number } | undefined) => { + if (!old) return old + return { + notifications: old.notifications.filter((n) => !notificationIds.includes(n.id)), + unreadCount: Math.max(0, old.unreadCount - notificationIds.length), + } + } + ) + + return { previousAll, previousUnread } + }, + onError: (_err, _notificationIds, context) => { + // Rollback on error + if (context?.previousAll) { + queryClient.setQueryData(['notifications', 'all'], context.previousAll) + } + if (context?.previousUnread) { + queryClient.setQueryData(['notifications', 'unread'], context.previousUnread) + } + }, + onSettled: () => { + // Refetch to ensure consistency + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + }, + }) +} + +/** + * Hook for marking all notifications as read + * - Optimistic update for instant UI feedback + */ +export function useMarkAllAsRead() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => NotificationsAPI.markAllAsRead(), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ['notifications'] }) + + const previousAll = queryClient.getQueryData(['notifications', 'all']) + const previousUnread = queryClient.getQueryData(['notifications', 'unread']) + + // Mark all as read + queryClient.setQueryData( + ['notifications', 'all'], + (old: { notifications: GitHubNotification[]; unreadCount: number } | undefined) => { + if (!old) return old + return { + notifications: old.notifications.map((n) => ({ ...n, isUnread: false })), + unreadCount: 0, + } + } + ) + + queryClient.setQueryData(['notifications', 'unread'], { + notifications: [], + unreadCount: 0, + }) + + return { previousAll, previousUnread } + }, + onError: (_err, _variables, context) => { + if (context?.previousAll) { + queryClient.setQueryData(['notifications', 'all'], context.previousAll) + } + if (context?.previousUnread) { + queryClient.setQueryData(['notifications', 'unread'], context.previousUnread) + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + }, + }) +} + +/** + * Hook for muting a notification thread + */ +export function useMuteThread() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (notificationId: string) => NotificationsAPI.muteThread(notificationId), + onSuccess: () => { + // Refetch notifications after muting + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + }, + }) +} diff --git a/hooks/useRealtimeSession.ts b/hooks/useRealtimeSession.ts index cfef9b4..b73ac81 100644 --- a/hooks/useRealtimeSession.ts +++ b/hooks/useRealtimeSession.ts @@ -11,6 +11,8 @@ import { type SessionUpdatedData, type SessionProgressData, type SessionStatusData, + type NotificationNewData, + type NotificationReadData, } from '@/types/realtime' import { FEATURE_FLAGS } from '@/utils/constants' import { useToast } from '@/hooks/useToast' @@ -284,6 +286,13 @@ export function useRealtimeSession() { handleStatusChange(event.data as SessionStatusData) break + case RealtimeEventType.NOTIFICATION_NEW: + case RealtimeEventType.NOTIFICATION_READ: + // Invalidate notifications query to trigger refetch + queryClient.invalidateQueries({ queryKey: ['notifications'] }) + logger.debug('[Realtime] Notification event, invalidating cache') + break + default: console.warn('[Realtime] Unknown event type:', event.type) } diff --git a/services/api/notifications.ts b/services/api/notifications.ts new file mode 100644 index 0000000..7a17cca --- /dev/null +++ b/services/api/notifications.ts @@ -0,0 +1,90 @@ +import { apiClient } from './client' +import type { GitHubNotification } from '@/types/notification' +import { NotificationType } from '@/types/notification' +import { z } from 'zod' +import { validateResponse } from './schemas' +import { MOCK_NOTIFICATIONS } from '@/utils/mockData' + +/** + * GitHub Notification schema for API validation + */ +const notificationSchema = z.object({ + id: z.string(), + type: z.nativeEnum(NotificationType), + repository: z.string(), + itemNumber: z.number().int().positive(), + title: z.string(), + author: z.string(), + timestamp: z.string().transform((str) => new Date(str)), + isUnread: z.boolean(), + suggestedWorkflow: z.string(), + url: z.string().url(), +}) + +const notificationsResponseSchema = z.object({ + notifications: z.array(notificationSchema), + unreadCount: z.number().int().min(0), +}) + +export interface MarkAsReadRequest { + notificationIds: string[] +} + +export interface MuteThreadRequest { + notificationId: string +} + +export class NotificationsAPI { + /** + * Fetch GitHub notifications + * @param unreadOnly - If true, only return unread notifications + * @returns Array of GitHub notifications with unread count + */ + static async fetchNotifications( + unreadOnly = false + ): Promise<{ notifications: GitHubNotification[]; unreadCount: number }> { + // Use mock data in development + if (__DEV__) { + const notifications = unreadOnly + ? MOCK_NOTIFICATIONS.filter((n) => n.isUnread) + : MOCK_NOTIFICATIONS + const unreadCount = MOCK_NOTIFICATIONS.filter((n) => n.isUnread).length + + return { notifications, unreadCount } + } + + const params = unreadOnly ? { unread: 'true' } : {} + const response = await apiClient.get('/notifications/github', { + params, + }) + + // Validate response with Zod + return validateResponse<{ notifications: GitHubNotification[]; unreadCount: number }>( + notificationsResponseSchema, + response + ) + } + + /** + * Mark one or more notifications as read + * @param notificationIds - Array of notification IDs to mark as read + */ + static async markAsRead(notificationIds: string[]): Promise { + await apiClient.patch('/notifications/read', { notificationIds }) + } + + /** + * Mark all notifications as read + */ + static async markAllAsRead(): Promise { + await apiClient.patch('/notifications/read-all') + } + + /** + * Mute a notification thread + * @param notificationId - The notification ID to mute + */ + static async muteThread(notificationId: string): Promise { + await apiClient.post('/notifications/mute', { notificationId }) + } +} diff --git a/specs/001-acp-mobile/tasks.md b/specs/001-acp-mobile/tasks.md index 25d5359..e3d44db 100644 --- a/specs/001-acp-mobile/tasks.md +++ b/specs/001-acp-mobile/tasks.md @@ -155,7 +155,7 @@ This task list breaks down the ACP mobile implementation into phases organized b --- -## Phase 5: User Story 3 - Respond to GitHub Notifications (Priority: P2) (10 tasks) +## ✅ Phase 5: User Story 3 - Respond to GitHub Notifications (Priority: P2) (10 tasks) - COMPLETE **Story Goal**: Receive and act on GitHub notifications from phone to quickly respond to PRs, issues, mentions @@ -176,22 +176,22 @@ This task list breaks down the ACP mobile implementation into phases organized b ### Data Layer [US3] -- [ ] T046 [P] [US3] Implement notifications API service in services/api/notifications.ts with fetchNotifications(), markAsRead(), muteThread() -- [ ] T047 [P] [US3] Create useNotifications hook in hooks/useNotifications.ts with polling every 30s and unread count +- [x] T046 [P] [US3] Implement notifications API service in services/api/notifications.ts with fetchNotifications(), markAsRead(), muteThread() +- [x] T047 [P] [US3] Create useNotifications hook in hooks/useNotifications.ts with polling every 30s and unread count ### UI Components [US3] -- [ ] T048 [P] [US3] Create NotificationCard component in components/notifications/NotificationCard.tsx with type icon, repo, title, author, time, unread indicator (blue border + dot) -- [ ] T049 [P] [US3] Create NotificationActions component in components/notifications/NotificationActions.tsx with action sheet: preview, start workflow, mark read, open browser, mute -- [ ] T050 [P] [US3] Create workflow suggestion mapping in utils/constants.ts: NotificationType → WorkflowType (PR→Review, Issue→Bugfix, etc.) +- [x] T048 [P] [US3] Create NotificationCard component in components/notifications/NotificationCard.tsx with type icon, repo, title, author, time, unread indicator (blue border + dot) +- [x] T049 [P] [US3] Create NotificationActions component in components/notifications/NotificationActions.tsx with action sheet: preview, start workflow, mark read, open browser, mute +- [x] T050 [P] [US3] Create workflow suggestion mapping in utils/constants.ts: NotificationType → WorkflowType (PR→Review, Issue→Bugfix, etc.) ### Screens & Integration [US3] -- [ ] T051 [US3] Implement GitHub Notifications screen in app/notifications/index.tsx with filter tabs (All/Unread), unread count badge, "Mark all read" action -- [ ] T052 [US3] Implement notification tap handler to show NotificationActions action sheet -- [ ] T053 [US3] Implement "Mark as Read" action with optimistic update and API call -- [ ] T054 [US3] Implement "Open in Browser" action using Linking.openURL() -- [ ] T055 [US3] Add "GitHub Notifications" quick action to Dashboard with unread count badge +- [x] T051 [US3] Implement GitHub Notifications screen in app/notifications/index.tsx with filter tabs (All/Unread), unread count badge, "Mark all read" action +- [x] T052 [US3] Implement notification tap handler to show NotificationActions action sheet +- [x] T053 [US3] Implement "Mark as Read" action with optimistic update and API call +- [x] T054 [US3] Implement "Open in Browser" action using Linking.openURL() +- [x] T055 [US3] Add "GitHub Notifications" quick action to Dashboard with unread count badge --- diff --git a/types/realtime.ts b/types/realtime.ts index c26a0f7..c750a0c 100644 --- a/types/realtime.ts +++ b/types/realtime.ts @@ -1,4 +1,5 @@ import { Session, SessionStatus } from './session' +import { GitHubNotification } from './notification' /** * Real-time event types from the SSE endpoint @@ -7,6 +8,8 @@ export enum RealtimeEventType { SESSION_UPDATED = 'session.updated', SESSION_PROGRESS = 'session.progress', SESSION_STATUS = 'session.status', + NOTIFICATION_NEW = 'notification.new', + NOTIFICATION_READ = 'notification.read', CONNECTION_OPENED = 'connection.opened', CONNECTION_ERROR = 'connection.error', } @@ -79,6 +82,25 @@ export interface ConnectionErrorData { code?: string } +/** + * Notification events + */ +export interface NotificationNewEvent extends RealtimeEvent { + type: RealtimeEventType.NOTIFICATION_NEW +} + +export interface NotificationNewData { + notification: GitHubNotification +} + +export interface NotificationReadEvent extends RealtimeEvent { + type: RealtimeEventType.NOTIFICATION_READ +} + +export interface NotificationReadData { + notificationId: string +} + /** * Union type of all possible SSE events */ @@ -86,6 +108,8 @@ export type RealtimeEventUnion = | SessionUpdatedEvent | SessionProgressEvent | SessionStatusEvent + | NotificationNewEvent + | NotificationReadEvent | ConnectionOpenedEvent | ConnectionErrorEvent diff --git a/utils/mockData.ts b/utils/mockData.ts index ae0eb49..963d746 100644 --- a/utils/mockData.ts +++ b/utils/mockData.ts @@ -6,6 +6,8 @@ import { SessionStatusData, SessionUpdatedData, } from '@/types/realtime' +import { GitHubNotification, NotificationType } from '@/types/notification' +import { NOTIFICATION_WORKFLOW_MAP } from '@/utils/constants' import { logger } from '@/utils/logger' export const MOCK_SESSIONS: Session[] = [ @@ -122,6 +124,93 @@ export const MOCK_SESSIONS: Session[] = [ }, ] +export const MOCK_NOTIFICATIONS: GitHubNotification[] = [ + { + id: 'notif-1', + type: NotificationType.PULL_REQUEST, + repository: 'ambient-code/platform', + itemNumber: 1247, + title: 'Add real-time session monitoring to mobile dashboard', + author: 'sarah-dev', + timestamp: new Date(Date.now() - 1800000), // 30 minutes ago + isUnread: true, + suggestedWorkflow: NOTIFICATION_WORKFLOW_MAP[NotificationType.PULL_REQUEST], + url: 'https://github.com/ambient-code/platform/pull/1247', + }, + { + id: 'notif-2', + type: NotificationType.ISSUE, + repository: 'ambient-code/acp-mobile', + itemNumber: 89, + title: 'Notifications not refreshing when app comes to foreground', + author: 'mike-qa', + timestamp: new Date(Date.now() - 3600000), // 1 hour ago + isUnread: true, + suggestedWorkflow: NOTIFICATION_WORKFLOW_MAP[NotificationType.ISSUE], + url: 'https://github.com/ambient-code/acp-mobile/issues/89', + }, + { + id: 'notif-3', + type: NotificationType.MENTION, + repository: 'ambient-code/platform', + itemNumber: 1245, + title: '@jeder Can you review the OAuth implementation?', + author: 'alex-backend', + timestamp: new Date(Date.now() - 7200000), // 2 hours ago + isUnread: true, + suggestedWorkflow: NOTIFICATION_WORKFLOW_MAP[NotificationType.MENTION], + url: 'https://github.com/ambient-code/platform/pull/1245#issuecomment-12345', + }, + { + id: 'notif-4', + type: NotificationType.PULL_REQUEST_REVIEW, + repository: 'ambient-code/backend-api', + itemNumber: 567, + title: 'Improve API response caching strategy', + author: 'emma-sre', + timestamp: new Date(Date.now() - 10800000), // 3 hours ago + isUnread: false, + suggestedWorkflow: NOTIFICATION_WORKFLOW_MAP[NotificationType.PULL_REQUEST_REVIEW], + url: 'https://github.com/ambient-code/backend-api/pull/567', + }, + { + id: 'notif-5', + type: NotificationType.SECURITY_ALERT, + repository: 'ambient-code/acp-mobile', + itemNumber: 3, + title: 'Dependabot alert: axios has a potential security vulnerability', + author: 'dependabot[bot]', + timestamp: new Date(Date.now() - 14400000), // 4 hours ago + isUnread: true, + suggestedWorkflow: NOTIFICATION_WORKFLOW_MAP[NotificationType.SECURITY_ALERT], + url: 'https://github.com/ambient-code/acp-mobile/security/dependabot/3', + }, + { + id: 'notif-6', + type: NotificationType.ISSUE_COMMENT, + repository: 'tools/feature-planner', + itemNumber: 42, + title: 'Great idea! We should definitely prioritize this', + author: 'jordan-pm', + timestamp: new Date(Date.now() - 21600000), // 6 hours ago + isUnread: false, + suggestedWorkflow: NOTIFICATION_WORKFLOW_MAP[NotificationType.ISSUE_COMMENT], + url: 'https://github.com/tools/feature-planner/issues/42#issuecomment-67890', + }, + { + id: 'notif-7', + type: NotificationType.RELEASE, + repository: 'facebook/react-native', + itemNumber: 0, + title: 'React Native 0.76.0 released with new architecture improvements', + author: 'react-native-bot', + timestamp: new Date(Date.now() - 86400000), // 1 day ago + isUnread: false, + suggestedWorkflow: NOTIFICATION_WORKFLOW_MAP[NotificationType.RELEASE], + url: 'https://github.com/facebook/react-native/releases/tag/v0.76.0', + }, +] + /** * Mock SSE Service for development/testing * @@ -198,15 +287,21 @@ export class MockSSEService { */ private randomEventType(): RealtimeEventType { const rand = Math.random() - if (rand < 0.6) { - // 60% progress updates + if (rand < 0.5) { + // 50% progress updates return RealtimeEventType.SESSION_PROGRESS - } else if (rand < 0.85) { - // 25% session updates + } else if (rand < 0.7) { + // 20% session updates return RealtimeEventType.SESSION_UPDATED - } else { - // 15% status changes + } else if (rand < 0.85) { + // 15% new notifications + return RealtimeEventType.NOTIFICATION_NEW + } else if (rand < 0.95) { + // 10% status changes return RealtimeEventType.SESSION_STATUS + } else { + // 5% notification read + return RealtimeEventType.NOTIFICATION_READ } } @@ -214,54 +309,76 @@ export class MockSSEService { * Create a mock event of the specified type */ private createEvent(type: RealtimeEventType): RealtimeEventUnion | null { - // Only generate events for running sessions - const runningSessions = MOCK_SESSIONS.filter((s) => s.status === SessionStatus.RUNNING) - - if (runningSessions.length === 0) { - return null - } - - const session = runningSessions[Math.floor(Math.random() * runningSessions.length)] - switch (type) { case RealtimeEventType.SESSION_PROGRESS: - return { - type, - data: { - sessionId: session.id, - progress: Math.min(100, session.progress + Math.floor(Math.random() * 10)), - currentTask: this.randomTask(), - } as SessionProgressData, - timestamp: Date.now(), + case RealtimeEventType.SESSION_UPDATED: + case RealtimeEventType.SESSION_STATUS: { + // Only generate session events for running sessions + const runningSessions = MOCK_SESSIONS.filter((s) => s.status === SessionStatus.RUNNING) + if (runningSessions.length === 0) return null + + const session = runningSessions[Math.floor(Math.random() * runningSessions.length)] + + if (type === RealtimeEventType.SESSION_PROGRESS) { + return { + type, + data: { + sessionId: session.id, + progress: Math.min(100, session.progress + Math.floor(Math.random() * 10)), + currentTask: this.randomTask(), + } as SessionProgressData, + timestamp: Date.now(), + } + } else if (type === RealtimeEventType.SESSION_UPDATED) { + return { + type, + data: { + sessionId: session.id, + changes: { + updatedAt: new Date(), + progress: Math.min(100, session.progress + Math.floor(Math.random() * 5)), + currentTask: this.randomTask(), + }, + } as SessionUpdatedData, + timestamp: Date.now(), + } + } else { + // SESSION_STATUS + const newStatus = Math.random() < 0.5 ? SessionStatus.DONE : SessionStatus.AWAITING_REVIEW + return { + type, + data: { + sessionId: session.id, + status: newStatus, + completedAt: new Date().toISOString(), + } as SessionStatusData, + timestamp: Date.now(), + } } + } - case RealtimeEventType.SESSION_UPDATED: + case RealtimeEventType.NOTIFICATION_NEW: { + // Generate a new notification + const notification = this.generateRandomNotification() return { type, - data: { - sessionId: session.id, - changes: { - updatedAt: new Date(), - progress: Math.min(100, session.progress + Math.floor(Math.random() * 5)), - currentTask: this.randomTask(), - }, - } as SessionUpdatedData, + data: { notification }, timestamp: Date.now(), } + } - case RealtimeEventType.SESSION_STATUS: - // Randomly transition to completed or awaiting review - const newStatus = Math.random() < 0.5 ? SessionStatus.DONE : SessionStatus.AWAITING_REVIEW + case RealtimeEventType.NOTIFICATION_READ: { + // Mark a random unread notification as read + const unreadNotifs = MOCK_NOTIFICATIONS.filter((n) => n.isUnread) + if (unreadNotifs.length === 0) return null + const notif = unreadNotifs[Math.floor(Math.random() * unreadNotifs.length)] return { type, - data: { - sessionId: session.id, - status: newStatus, - completedAt: new Date().toISOString(), - } as SessionStatusData, + data: { notificationId: notif.id }, timestamp: Date.now(), } + } default: return null @@ -286,6 +403,88 @@ export class MockSSEService { ] return tasks[Math.floor(Math.random() * tasks.length)] } + + /** + * Generate a random GitHub notification + */ + private generateRandomNotification(): GitHubNotification { + const types = Object.values(NotificationType) + const type = types[Math.floor(Math.random() * types.length)] + + const repos = [ + 'ambient-code/platform', + 'ambient-code/acp-mobile', + 'ambient-code/backend-api', + 'facebook/react-native', + 'expo/expo', + ] + + const titles = { + [NotificationType.PULL_REQUEST]: [ + 'Add new authentication flow', + 'Fix memory leak in session manager', + 'Update dependencies to latest versions', + 'Improve error handling in API client', + ], + [NotificationType.ISSUE]: [ + 'App crashes on iOS when opening notifications', + 'Session progress not updating correctly', + 'Dark mode colors inconsistent', + 'Performance degradation with large datasets', + ], + [NotificationType.MENTION]: [ + '@jeder thoughts on this approach?', + 'Can you review this when you get a chance?', + 'What do you think about this implementation?', + ], + [NotificationType.PULL_REQUEST_REVIEW]: [ + 'Reviewed: Add notification system', + 'Approved with suggestions', + 'Requested changes on API implementation', + ], + [NotificationType.SECURITY_ALERT]: [ + 'Dependabot alert: vulnerability in dependencies', + 'Code scanning found potential security issue', + 'Secret scanning detected exposed token', + ], + [NotificationType.ISSUE_COMMENT]: [ + 'Great suggestion! Let me try that', + 'I think we should consider the performance impact', + 'This is working well in my testing', + ], + [NotificationType.RELEASE]: [ + 'v2.0.0 released with breaking changes', + 'Security patch released', + 'New features available in latest release', + ], + [NotificationType.COMMIT_COMMENT]: [ + 'Nice refactoring here', + 'Should we add tests for this?', + 'This might need error handling', + ], + } + + const authors = ['sarah-dev', 'mike-qa', 'alex-backend', 'emma-sre', 'jordan-pm'] + + const titleOptions = titles[type] || ['New notification'] + const title = titleOptions[Math.floor(Math.random() * titleOptions.length)] + const repo = repos[Math.floor(Math.random() * repos.length)] + const author = authors[Math.floor(Math.random() * authors.length)] + const itemNumber = Math.floor(Math.random() * 1000) + 1 + + return { + id: `notif-${Date.now()}-${Math.random()}`, + type, + repository: repo, + itemNumber, + title, + author, + timestamp: new Date(), + isUnread: true, + suggestedWorkflow: NOTIFICATION_WORKFLOW_MAP[type] || 'review', + url: `https://github.com/${repo}/${type.includes('pull') ? 'pull' : 'issues'}/${itemNumber}`, + } + } } // Singleton instance for mock SSE service