diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 3cf8fa8b..8fe8bf34 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import { Tabs } from 'expo-router'; -import React from 'react'; +import React, { useMemo } from 'react'; import { HapticTab } from '@/components/haptic-tab'; import { IconSymbol } from '@/components/ui/icon-symbol'; @@ -7,19 +7,24 @@ import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; import { ErrorBoundary } from '@/src/components'; -export default function TabLayout() { +const TabLayout = () => { const colorScheme = useColorScheme(); + const screenOptions = useMemo( + () => ({ + tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint, + headerShown: false, + tabBarButton: HapticTab, + }), + [colorScheme] + ); + return ( ); -} +}; + +export default TabLayout; diff --git a/app/_layout.tsx b/app/_layout.tsx index 63a26101..591b89ac 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,7 +10,7 @@ import { AnalyticsProvider, ErrorBoundary, OfflineIndicatorProvider } from '../s import { useAnalytics } from '../src/hooks'; import { useDeepLink } from '../src/hooks/useDeepLink'; import { sessionRestorationService } from '../src/services/sessionRestoration'; -import { useAppStore } from '../src/store'; +import { useTheme } from '../src/store/selectors'; import { getPathFromDeepLink } from '../src/utils/linkParser'; import { prefetchExternalResources } from '../src/utils/resourceHints'; @@ -40,7 +40,7 @@ const ScreenTracker = () => { // Sync global theme to NativeWind colorScheme const ThemeSync = () => { - const { theme } = useAppStore(); + const theme = useTheme(); const { setColorScheme } = useColorScheme(); useEffect(() => { diff --git a/src/components/common/AppText.tsx b/src/components/common/AppText.tsx index abfca2fd..f6894ddf 100644 --- a/src/components/common/AppText.tsx +++ b/src/components/common/AppText.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Text as RNText, TextProps, StyleSheet } from 'react-native'; + import { useDynamicFontSize } from '../../hooks'; interface AppTextProps extends TextProps { @@ -10,34 +11,25 @@ interface AppTextProps extends TextProps { fixed?: boolean; } -/** - * A wrapper around React Native's Text component that uses the useDynamicFontSize hook - * to ensure consistent scaling across the application. - */ -export const AppText: React.FC = ({ style, fixed = false, ...props }) => { - const { scale } = useDynamicFontSize(); - - // We flatten the style to easily extract and modify the fontSize - const flattenedStyle = StyleSheet.flatten(style) || {}; +const AppTextInner: React.FC = ({ style, fixed = false, ...props }) => { + const { fontScale } = useDynamicFontSize(); - const dynamicStyle = { ...flattenedStyle }; + const dynamicStyle = useMemo(() => { + const flat = StyleSheet.flatten(style) || {}; + if (fixed || !flat.fontSize) return flat; - if (!fixed && flattenedStyle.fontSize) { - dynamicStyle.fontSize = scale(flattenedStyle.fontSize); + return { + ...flat, + fontSize: flat.fontSize * fontScale, + ...(flat.lineHeight ? { lineHeight: flat.lineHeight * fontScale } : {}), + }; + }, [style, fixed, fontScale]); - // Also scale lineHeight if it exists to maintain proportions - if (flattenedStyle.lineHeight) { - dynamicStyle.lineHeight = scale(flattenedStyle.lineHeight); - } - } - - return ( - - ); + return ; }; + +/** + * A wrapper around React Native's Text component that uses the useDynamicFontSize hook + * to ensure consistent scaling across the application. + */ +export const AppText = React.memo(AppTextInner); diff --git a/src/components/mobile/LessonCarousel.tsx b/src/components/mobile/LessonCarousel.tsx index 20eaf025..c74a3616 100644 --- a/src/components/mobile/LessonCarousel.tsx +++ b/src/components/mobile/LessonCarousel.tsx @@ -9,11 +9,11 @@ import { TouchableOpacity, View, } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; + +import { useDebounceCallback } from '../../hooks'; import { useAnalytics } from '../../hooks/useAnalytics'; import { Lesson, CourseProgress } from '../../types/course'; import { AnalyticsEvent } from '../../utils/trackingEvents'; -import { useDebounceCallback } from '../../hooks'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); @@ -48,7 +48,7 @@ const LessonCarousel = ({ renderLessonContent, onLastLessonNext, isLastLessonInSection = false, -}: LessonCarouselProps) { +}: LessonCarouselProps) => { const { trackEvent } = useAnalytics(); const scrollViewRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(0); @@ -95,7 +95,7 @@ const LessonCarousel = ({ const debouncedScroll = useDebounceCallback((offsetX: number) => { const index = Math.round(offsetX / SCREEN_WIDTH); if (index >= 0 && index < lessons.length) { - setCurrentIndex((prevIndex) => { + setCurrentIndex(prevIndex => { if (index !== prevIndex) { const lesson = lessons[index]; onLessonChange(lesson.id, index); @@ -235,6 +235,7 @@ const LessonCarousel = ({ pagingEnabled showsHorizontalScrollIndicator={false} onMomentumScrollEnd={handleMomentumScrollEnd} + onScroll={handleScroll} scrollEventThrottle={16} decelerationRate="fast" snapToInterval={SCREEN_WIDTH} diff --git a/src/components/mobile/MobileCourseViewer.tsx b/src/components/mobile/MobileCourseViewer.tsx index 5fd27c63..09315185 100644 --- a/src/components/mobile/MobileCourseViewer.tsx +++ b/src/components/mobile/MobileCourseViewer.tsx @@ -9,19 +9,20 @@ import { TouchableOpacity, View, } from 'react-native'; -import { AppText as Text } from '../common/AppText'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import BookmarkButton from './BookmarkButton'; +import { CourseViewerSkeleton } from './CourseViewerSkeleton'; +import LessonCarousel from './LessonCarousel'; +import MobileSyllabus from './MobileSyllabus'; import { useCourseProgress, useDynamicFontSize } from '../../hooks'; -import { SafeAreaView } from "react-native-safe-area-context"; -import logger from "../../utils/logger"; -import PrimaryButton from "../common/PrimaryButton"; -import BookmarkButton from "./BookmarkButton"; -import LessonCarousel from "./LessonCarousel"; -import MobileSyllabus from "./MobileSyllabus"; -import { useAnalytics } from "../../hooks/useAnalytics"; -import { Course, Lesson, Note } from "../../types/course"; -import { AnalyticsEvent, ScreenName } from "../../utils/trackingEvents"; -import { ErrorBoundary } from "../common/ErrorBoundary"; -import { CourseViewerSkeleton } from "./CourseViewerSkeleton"; +import { useAnalytics } from '../../hooks/useAnalytics'; +import { Course, Lesson, Note } from '../../types/course'; +import { logger } from '../../utils/logger'; +import { AnalyticsEvent, ScreenName } from '../../utils/trackingEvents'; +import { AppText as Text } from '../common/AppText'; +import { ErrorBoundary } from '../common/ErrorBoundary'; +import PrimaryButton from '../common/PrimaryButton'; /** * Props for the MobileCourseViewer component @@ -41,13 +42,13 @@ interface MobileCourseViewerProps { type ViewMode = 'lesson' | 'syllabus' | 'notes'; -export default function MobileCourseViewer({ +const MobileCourseViewer: React.FC = ({ course, initialLessonId, initialViewMode, onBack, navigation, -}: MobileCourseViewerProps) { +}: MobileCourseViewerProps) => { const { scale } = useDynamicFontSize(); const { trackEvent, trackScreen } = useAnalytics(); const [viewMode, setViewMode] = useState(initialViewMode || 'lesson'); @@ -64,7 +65,6 @@ export default function MobileCourseViewer({ const { progress, isLoading, - updateLessonProgress, markLessonComplete, setCurrentLesson, addBookmark, @@ -80,8 +80,11 @@ export default function MobileCourseViewer({ autoSync: true, }); - // Get all lessons in order - const allLessons = course.sections.flatMap(section => section.lessons.map(lesson => lesson)); + // Memoized — recalculates only when the sections array reference changes (i.e. new course data). + const allLessons = useMemo( + () => course.sections.flatMap(section => section.lessons), + [course.sections] + ); // Helper to get section ID for a lesson const getSectionIdForLesson = useCallback( @@ -142,7 +145,7 @@ export default function MobileCourseViewer({ } catch (error) { logger.error('Error in MobileCourseViewer:', error); } - }, [course.id]); + }, [course.id, course.title, trackEvent, trackScreen]); // Track course completion useEffect(() => { @@ -341,7 +344,7 @@ export default function MobileCourseViewer({ ); }, - [progress, handleAddNote, handleEditNote, handleDeleteNote] + [progress, handleEditNote, handleDeleteNote] ); if (isLoading) { @@ -579,7 +582,9 @@ export default function MobileCourseViewer({ ); -} +}; + +export default MobileCourseViewer; const styles = StyleSheet.create({ container: { diff --git a/src/components/mobile/MobileProfile.tsx b/src/components/mobile/MobileProfile.tsx index 70d75f93..e873ceb7 100644 --- a/src/components/mobile/MobileProfile.tsx +++ b/src/components/mobile/MobileProfile.tsx @@ -17,10 +17,9 @@ import { Users, X, } from 'lucide-react-native'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { ActivityIndicator, - Animated, LayoutAnimation, Platform, SafeAreaView, @@ -31,17 +30,19 @@ import { View, } from 'react-native'; -// Enable LayoutAnimation on Android -if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { - UIManager.setLayoutAnimationEnabledExperimental(true); -} -import { AppText as Text } from '../common/AppText'; -import { CachedImage } from '../ui/CachedImage'; -import { Skeleton } from '../ui/Skeleton'; import { Achievement, AchievementBadges } from './AchievementBadges'; import { AvatarCamera } from './AvatarCamera'; import { MobileFormInput } from './MobileFormInput'; import { StatisticsDisplay } from './StatisticsDisplay'; +import { useUnlockedCount } from '../../store/achievementStore'; +import { AppText as Text } from '../common/AppText'; +import { CachedImage } from '../ui/CachedImage'; +import { Skeleton } from '../ui/Skeleton'; + +// Enable LayoutAnimation on Android +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} // ─── Types ─────────────────────────────────────────────────────────────────── @@ -106,6 +107,14 @@ interface ProfileData { type ProfileTab = 'overview' | 'stats' | 'achievements' | 'connections'; +// Stable module-level constant — no dependencies on props or state. +const TABS: { key: ProfileTab; label: string }[] = [ + { key: 'overview', label: 'Profile' }, + { key: 'stats', label: 'Stats' }, + { key: 'achievements', label: 'Badges' }, + { key: 'connections', label: 'Network' }, +]; + // ─── Mock data (replace with API call in production) ───────────────────────── const MOCK_PROFILE: ProfileData = { @@ -247,56 +256,13 @@ interface MobileProfileProps { isLoading?: boolean; } -import { useDynamicFontSize } from '../../hooks'; - -export const MobileProfile: React.FC = ({ +const MobileProfileInner: React.FC = ({ userId: _userId, isDark = false, isLoading = false, }) => { const [profile, setProfile] = useState(MOCK_PROFILE); - const { scale } = useDynamicFontSize(); - const { achievements, unlockedCount } = useAchievementStore(); - - if (isLoading) { - const bg = isDark ? '#0f172a' : '#f8fafc'; - const cardBg = isDark ? '#1e293b' : '#fff'; - const borderColor = isDark ? '#334155' : '#e2e8f0'; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - } + const unlockedCount = useUnlockedCount(); const [activeTab, setActiveTab] = useState('overview'); const [isEditing, setIsEditing] = useState(false); const [isCameraVisible, setIsCameraVisible] = useState(false); @@ -327,31 +293,31 @@ export const MobileProfile: React.FC = ({ .toUpperCase() .slice(0, 2); - const handleStartEdit = () => { + const handleStartEdit = useCallback(() => { setEditName(profile.name); setEditBio(profile.bio); setEditEmail(profile.email); setEditLocation(profile.location); setEditWebsite(profile.website); setFormErrors({}); - setShowAdvancedFields(false); // reset disclosure state on each edit session + setShowAdvancedFields(false); setIsEditing(true); - }; + }, [profile.name, profile.bio, profile.email, profile.location, profile.website]); - const handleToggleAdvancedFields = () => { + const handleToggleAdvancedFields = useCallback(() => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); setShowAdvancedFields(prev => !prev); - }; + }, []); - const validateForm = (): Record => { + const validateForm = useCallback((): Record => { const errors: Record = {}; if (!editName.trim()) errors.name = 'Name is required'; if (!editEmail.trim()) errors.email = 'Email is required'; else if (!/\S+@\S+\.\S+/.test(editEmail)) errors.email = 'Enter a valid email address'; return errors; - }; + }, [editName, editEmail]); - const handleSave = async () => { + const handleSave = useCallback(async () => { const errors = validateForm(); if (Object.keys(errors).length > 0) { setFormErrors(errors); @@ -370,64 +336,110 @@ export const MobileProfile: React.FC = ({ })); setIsSaving(false); setIsEditing(false); - }; + }, [validateForm, editName, editBio, editEmail, editLocation, editWebsite]); - const handleCancelEdit = () => { + const handleCancelEdit = useCallback(() => { setIsEditing(false); setFormErrors({}); - }; + }, []); - const handleAvatarConfirm = (uri: string) => { + const handleAvatarConfirm = useCallback((uri: string) => { setProfile(prev => ({ ...prev, avatar: uri })); - }; + }, []); - const handleToggleFollow = (connectionId: string) => { + const handleCloseCamera = useCallback(() => setIsCameraVisible(false), []); + + const handleToggleFollow = useCallback((connectionId: string) => { setProfile(prev => ({ ...prev, connections: prev.connections.map(c => c.id === connectionId ? { ...c, isFollowing: !c.isFollowing } : c ), })); - }; + }, []); - // Tab config - const TABS: { key: ProfileTab; label: string }[] = [ - { key: 'overview', label: 'Profile' }, - { key: 'stats', label: 'Stats' }, - { key: 'achievements', label: 'Badges' }, - { key: 'connections', label: 'Network' }, - ]; + const statsForDisplay = useMemo( + () => [ + { label: 'Courses Done', value: profile.stats.coursesCompleted }, + { label: 'Enrolled', value: profile.stats.coursesEnrolled }, + { label: 'Hours', value: profile.stats.totalHours }, + { label: 'Day Streak', value: `${profile.stats.streak} 🔥` }, + ], + [ + profile.stats.coursesCompleted, + profile.stats.coursesEnrolled, + profile.stats.totalHours, + profile.stats.streak, + ] + ); - const statsForDisplay = [ - { label: 'Courses Done', value: profile.stats.coursesCompleted }, - { label: 'Enrolled', value: profile.stats.coursesEnrolled }, - { label: 'Hours', value: profile.stats.totalHours }, - { label: 'Day Streak', value: `${profile.stats.streak} 🔥` }, - ]; + // stripItems holds JSX elements — memoize to avoid recreating React nodes on every render. + const stripItems = useMemo( + () => [ + { + icon: , + value: profile.stats.coursesCompleted, + label: 'Done', + }, + { + icon: , + value: profile.stats.connections, + label: 'Network', + }, + { + icon: , + value: unlockedCount, + label: 'Badges', + }, + { + icon: , + value: `${profile.stats.totalHours}h`, + label: 'Learning', + }, + ], + [ + profile.stats.coursesCompleted, + profile.stats.connections, + profile.stats.totalHours, + unlockedCount, + ] + ); - // ─── Header strip items ─────────────────────────────────────────────────── - const stripItems = [ - { - icon: , - value: profile.stats.coursesCompleted, - label: 'Done', - }, - { - icon: , - value: profile.stats.connections, - label: 'Network', - }, - { - icon: , - value: unlockedCount, - label: 'Badges', - }, - { - icon: , - value: `${profile.stats.totalHours}h`, - label: 'Learning', - }, - ]; + if (isLoading) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } return ( @@ -656,15 +668,19 @@ export const MobileProfile: React.FC = ({ onPress={handleToggleAdvancedFields} activeOpacity={0.7} accessibilityRole="button" - accessibilityLabel={showAdvancedFields ? 'Hide advanced details' : 'Show advanced details'} + accessibilityLabel={ + showAdvancedFields ? 'Hide advanced details' : 'Show advanced details' + } accessibilityState={{ expanded: showAdvancedFields }} > {showAdvancedFields ? 'Hide Advanced Details' : 'Advanced Details'} - {showAdvancedFields - ? - : } + {showAdvancedFields ? ( + + ) : ( + + )} {/* ── Advanced Fields (expandable) ── */} @@ -746,7 +762,7 @@ export const MobileProfile: React.FC = ({ 🔥 {profile.stats.streak} Day Streak - Keep it up! You're on fire. + {"Keep it up! You're on fire."} @@ -853,12 +869,14 @@ export const MobileProfile: React.FC = ({ visible={isCameraVisible} currentAvatar={profile.avatar} onConfirm={handleAvatarConfirm} - onClose={() => setIsCameraVisible(false)} + onClose={handleCloseCamera} /> ); }; +export const MobileProfile = React.memo(MobileProfileInner); + // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = StyleSheet.create({ diff --git a/src/components/mobile/MobileSearch.tsx b/src/components/mobile/MobileSearch.tsx index b6a8608e..d924752e 100644 --- a/src/components/mobile/MobileSearch.tsx +++ b/src/components/mobile/MobileSearch.tsx @@ -1,5 +1,5 @@ import { AlertCircle, Search, SlidersHorizontal } from 'lucide-react-native'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FlatList, KeyboardAvoidingView, @@ -106,7 +106,7 @@ export interface MobileSearchProps { placeholder?: string; } -export const MobileSearch = ({ +const MobileSearchInner = ({ onResultPress, placeholder = 'Search courses...', }: MobileSearchProps) => { @@ -200,6 +200,32 @@ export const MobileSearch = ({ setFilterSheetVisible(false); }, []); + const handleQueryChange = useCallback((text: string) => { + setQuery(text); + setQueryError(null); + }, []); + + const handleFocus = useCallback(() => setSuggestionsVisible(true), []); + + // Delay hiding so a tap on a suggestion registers before the list unmounts. + const blurTimer = useRef>(); + useEffect(() => () => clearTimeout(blurTimer.current), []); + const handleBlur = useCallback(() => { + blurTimer.current = setTimeout(() => setSuggestionsVisible(false), 180); + }, []); + + const handleOpenFilters = useCallback(() => setFilterSheetVisible(true), []); + const handleCloseFilters = useCallback(() => setFilterSheetVisible(false), []); + const handleResetFilters = useCallback(() => setFilterValues({}), []); + + // Stable renderItem — a new inline arrow would defeat React.memo on SearchResultCard. + const renderItem = useCallback( + ({ item }: { item: SearchResultItem }) => ( + onResultPress?.(item)} /> + ), + [onResultPress] + ); + const showSuggestions = suggestionsVisible && query.length > 0; const showHistory = suggestionsVisible && !query.trim(); const showResults = hasSearched; @@ -218,12 +244,9 @@ export const MobileSearch = ({ placeholder={placeholder} placeholderTextColor="#9CA3AF" value={query} - onChangeText={text => { - setQuery(text); - setQueryError(null); - }} - onFocus={() => setSuggestionsVisible(true)} - onBlur={() => setTimeout(() => setSuggestionsVisible(false), 180)} + onChangeText={handleQueryChange} + onFocus={handleFocus} + onBlur={handleBlur} onSubmitEditing={handleSubmit} returnKeyType="search" /> @@ -231,7 +254,7 @@ export const MobileSearch = ({ setFilterSheetVisible(true)} + onPress={handleOpenFilters} style={[ styles.filterBtn, Object.keys(filterValues).length > 0 && styles.filterBtnActive, @@ -284,30 +307,29 @@ export const MobileSearch = ({ item.id} - renderItem={({ item }) => ( - onResultPress?.(item)} /> - )} + renderItem={renderItem} contentContainerStyle={styles.resultsList} - ListEmptyComponent={ - Try a different query or adjust filters. - } + ListEmptyComponent={EMPTY_LIST_COMPONENT} /> )} setFilterSheetVisible(false)} + onClose={handleCloseFilters} filters={DEFAULT_FILTERS} values={filterValues} onApply={handleApplyFilters} - onReset={() => setFilterValues({})} + onReset={handleResetFilters} /> ); }; +export const MobileSearch = React.memo(MobileSearchInner); + const styles = StyleSheet.create({ + // NOTE: EMPTY_LIST_COMPONENT is defined after styles so it can reference styles.emptyText. container: { flex: 1, backgroundColor: '#F9FAFB', @@ -414,3 +436,10 @@ const styles = StyleSheet.create({ fontWeight: '500', }, }); + +// Defined after `styles` so styles.emptyText is in scope. +// A module-level constant avoids creating a new element reference on every render, +// which would prevent FlatList from short-circuiting ListEmptyComponent diffing. +const EMPTY_LIST_COMPONENT = ( + Try a different query or adjust filters. +); diff --git a/src/components/mobile/SearchResultCard.tsx b/src/components/mobile/SearchResultCard.tsx index 273d6823..0c88b6e9 100644 --- a/src/components/mobile/SearchResultCard.tsx +++ b/src/components/mobile/SearchResultCard.tsx @@ -1,6 +1,6 @@ +import { BookOpen, Clock } from 'lucide-react-native'; import React from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; -import { BookOpen, Clock } from 'lucide-react-native'; export interface SearchResultItem { id: string; @@ -19,7 +19,7 @@ export interface SearchResultCardProps { onPress: () => void; } -export function SearchResultCard({ item, onPress }: SearchResultCardProps) { +const SearchResultCardInner: React.FC = ({ item, onPress }) => { const metaParts = [item.category, item.level].filter(Boolean); const metaText = metaParts.join(' · '); const screenReaderDescription = [item.title, item.description || item.subtitle, metaText] @@ -59,7 +59,9 @@ export function SearchResultCard({ item, onPress }: SearchResultCardProps) { ); -} +}; + +export const SearchResultCard = React.memo(SearchResultCardInner); const styles = StyleSheet.create({ card: { diff --git a/src/store/achievementStore.ts b/src/store/achievementStore.ts index 9625f2a6..da5edacf 100644 --- a/src/store/achievementStore.ts +++ b/src/store/achievementStore.ts @@ -53,7 +53,7 @@ interface AchievementState { achievementProgress: Record; /** Number of unlocked achievements */ unlockedCount: number; - + // Actions /** Unlock an achievement by ID */ unlockAchievement: (id: string) => void; @@ -151,11 +151,13 @@ export const DEFAULT_ACHIEVEMENTS: Achievement[] = [ ]; const DEFAULT_ACHIEVEMENT_BY_ID = Object.fromEntries( - DEFAULT_ACHIEVEMENTS.map((achievement) => [achievement.id, achievement]), + DEFAULT_ACHIEVEMENTS.map(achievement => [achievement.id, achievement]) ) as Record; -function buildAchievementsFromProgress(progressById: Record): Achievement[] { - return DEFAULT_ACHIEVEMENTS.map((achievement) => { +function buildAchievementsFromProgress( + progressById: Record +): Achievement[] { + return DEFAULT_ACHIEVEMENTS.map(achievement => { const progress = progressById[achievement.id]; if (!progress) { return achievement; @@ -170,7 +172,9 @@ function buildAchievementsFromProgress(progressById: Record { +function snapshotAchievementProgress( + achievements: Achievement[] +): Record { return achievements.reduce>((snapshot, achievement) => { const defaultAchievement = DEFAULT_ACHIEVEMENT_BY_ID[achievement.id]; if (!defaultAchievement) { @@ -195,7 +199,7 @@ function snapshotAchievementProgress(achievements: Achievement[]): Record>((snapshot, [id, entry]) => { - if (!isRecord(entry)) { - return snapshot; - } + return Object.entries(value).reduce>( + (snapshot, [id, entry]) => { + if (!isRecord(entry)) { + return snapshot; + } - const progress: AchievementProgress = {}; + const progress: AchievementProgress = {}; - if (typeof entry.isLocked === 'boolean') { - progress.isLocked = entry.isLocked; - } + if (typeof entry.isLocked === 'boolean') { + progress.isLocked = entry.isLocked; + } - if (typeof entry.unlockedAt === 'string') { - progress.unlockedAt = entry.unlockedAt; - } + if (typeof entry.unlockedAt === 'string') { + progress.unlockedAt = entry.unlockedAt; + } - if (isRecord(entry.progress)) { - const current = entry.progress.current; - const total = entry.progress.total; - if (typeof current === 'number' && typeof total === 'number') { - progress.progress = { current, total }; + if (isRecord(entry.progress)) { + const current = entry.progress.current; + const total = entry.progress.total; + if (typeof current === 'number' && typeof total === 'number') { + progress.progress = { current, total }; + } } - } - if (Object.keys(progress).length > 0) { - snapshot[id] = progress; - } + if (Object.keys(progress).length > 0) { + snapshot[id] = progress; + } - return snapshot; - }, {}); + return snapshot; + }, + {} + ); } function normalizeAchievementState(rawState: unknown): { @@ -265,7 +272,7 @@ function normalizeAchievementState(rawState: unknown): { const unlockedCount = typeof persistedState.unlockedCount === 'number' ? persistedState.unlockedCount - : achievements.filter((achievement) => !achievement.isLocked).length; + : achievements.filter(achievement => !achievement.isLocked).length; return { achievements, @@ -274,6 +281,16 @@ function normalizeAchievementState(rawState: unknown): { }; } +// ─── Granular selector hooks ───────────────────────────────────────────────── +// Each hook re-renders its consumer only when that specific slice changes. +// Always prefer these over calling useAchievementStore() without a selector. + +/** Subscribes only to the achievements array. */ +export const useAchievements = () => useAchievementStore(state => state.achievements); + +/** Subscribes only to the unlocked badge count. */ +export const useUnlockedCount = () => useAchievementStore(state => state.unlockedCount); + export const useAchievementStore = create()( persist( (set, get) => ({ @@ -282,11 +299,11 @@ export const useAchievementStore = create()( unlockedCount: 0, unlockAchievement: (id: string) => - set((state) => { - const achievement = state.achievements.find((a) => a.id === id); + set(state => { + const achievement = state.achievements.find(a => a.id === id); if (!achievement || !achievement.isLocked) return state; - const updatedAchievements = state.achievements.map((a) => + const updatedAchievements = state.achievements.map(a => a.id === id ? { ...a, @@ -302,20 +319,20 @@ export const useAchievementStore = create()( return { achievements: updatedAchievements, achievementProgress: snapshotAchievementProgress(updatedAchievements), - unlockedCount: updatedAchievements.filter((a) => !a.isLocked).length, + unlockedCount: updatedAchievements.filter(a => !a.isLocked).length, }; }), updateProgress: (id: string, current: number) => - set((state) => { - const achievement = state.achievements.find((a) => a.id === id); + set(state => { + const achievement = state.achievements.find(a => a.id === id); if (!achievement || !achievement.isLocked) return state; - const updatedAchievements = state.achievements.map((a) => { + const updatedAchievements = state.achievements.map(a => { if (a.id !== id) return a; const progress = a.progress ? { ...a.progress, current } : { current, total: 1 }; - + // Auto-unlock if progress is complete if (progress.current >= progress.total) { return { @@ -335,17 +352,17 @@ export const useAchievementStore = create()( return { achievements: updatedAchievements, achievementProgress: snapshotAchievementProgress(updatedAchievements), - unlockedCount: updatedAchievements.filter((a) => !a.isLocked).length, + unlockedCount: updatedAchievements.filter(a => !a.isLocked).length, }; }), isAchievementUnlocked: (id: string) => { - const achievement = get().achievements.find((a) => a.id === id); + const achievement = get().achievements.find(a => a.id === id); return achievement ? !achievement.isLocked : false; }, getUnlockedAchievements: () => { - return get().achievements.filter((a) => !a.isLocked); + return get().achievements.filter(a => !a.isLocked); }, resetAchievements: () => @@ -359,18 +376,18 @@ export const useAchievementStore = create()( set({ achievements, achievementProgress: snapshotAchievementProgress(achievements), - unlockedCount: achievements.filter((a) => !a.isLocked).length, + unlockedCount: achievements.filter(a => !a.isLocked).length, }), }), { name: 'achievement-storage', version: 1, storage: asyncStorageJSONStorage, - partialize: (state) => ({ + partialize: state => ({ achievementProgress: state.achievementProgress, unlockedCount: state.unlockedCount, }), - migrate: (persistedState) => normalizeAchievementState(persistedState), + migrate: persistedState => normalizeAchievementState(persistedState), merge: (persistedState, currentState) => { const normalizedState = normalizeAchievementState(persistedState); return { @@ -378,6 +395,6 @@ export const useAchievementStore = create()( ...normalizedState, }; }, - }, + } ) );