Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/screens/AdvancedSearchScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useMemo, useState } from 'react';
import { View, Text, TextInput, Button, FlatList, StyleSheet } from 'react-native';
import { Subscription } from '../types/subscription';
import { search_subscriptions, SavedSearch, SearchQuery } from '../services/searchService';
import { useSubscriptionStore } from '../store/subscriptionStore';
import { useSubscriptionStore } from '../../src/store';
import { useSearchStore } from '../stores/searchStore';

const styles = StyleSheet.create({
Expand Down
3 changes: 1 addition & 2 deletions app/services/searchService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Subscription, SubscriptionCategory } from '../types/subscription';
import { useSubscriptionStore } from '../store/subscriptionStore';
import { useSubscriptionStore, useSettingsStore } from '../../src/store';
import { currencyService } from './currencyService';
import { useSettingsStore } from '../store/settingsStore';

export type SearchQuery = {
query: string;
Expand Down
40 changes: 15 additions & 25 deletions app/stores/searchStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { create } from 'zustand';
import { useSubscriptionStore } from './subscriptionStore';
import { Subscription } from '../types/subscription';
import { useStore } from '../../src/store';

type Facets = {
category?: string;
Expand All @@ -19,55 +18,46 @@ type SavedSearch = {
type SearchState = {
query: string;
facets: Facets;
results: Subscription[];
results: any[];
savedSearches: SavedSearch[];
setQuery: (q: string) => void;
setFacets: (f: Partial<Facets>) => void;
updateResults: (subs: Subscription[]) => void;
updateResults: (results: any[]) => void;
saveSearch: (name: string) => void;
loadSavedSearch: (id: string) => void;
clear: () => void;
};

export const useSearchStore = create<SearchState>()((set, get) => {
const { subscriptions } = require('../store/subscriptionStore').useSubscriptionStore.getState();
// Get initial subscriptions from the combined store
const subs = useStore.getState()?.subscriptions;

return {
query: '',
facets: {},
results: subscriptions?.length ? subscriptions : [],
results: subs?.length ? subs : [],
savedSearches: [],
setQuery: (q: string) => {
set({ query: q });
// Basic debounce-like refresh by recalculating results on demand
const subState = require('../store/subscriptionStore').useSubscriptionStore.getState();
set({ results: subState.subscriptions });
const subState = useStore.getState();
set({ results: subState?.subscriptions ?? [] });
},
setFacets: (f: Partial<Facets>) => {
set((state) => ({ facets: { ...state.facets, ...f } }));
// Refresh results when facets change
const subState = require('../store/subscriptionStore').useSubscriptionStore.getState();
set({ results: subState.subscriptions });
const subState = useStore.getState();
set({ results: subState?.subscriptions ?? [] });
},
updateResults: (subs: Subscription[]) => set({ results: subs }),
updateResults: (results) => set({ results }),
saveSearch: (name: string) => {
const current = get();
const id = `ss_${Date.now()}`;
const newSearch = {
id,
name,
query: current.query,
facets: current.facets,
} as SavedSearch;
const newSearch = { id, name, query: current.query, facets: current.facets } as SavedSearch;
set((s) => ({ savedSearches: [...s.savedSearches, newSearch] }));
},
loadSavedSearch: (id: string) => {
const s = get().savedSearches.find((ss) => ss.id === id);
if (s) {
set({ query: s.query, facets: s.facets || {} });
}
},
clear: () => {
set({ query: '', facets: {}, results: [] });
if (s) set({ query: s.query, facets: s.facets || {} });
},
clear: () => set({ query: '', facets: {}, results: [] }),
};
});
2 changes: 1 addition & 1 deletion app/tests/integration/contract-store.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { act } from 'react';
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as notificationService from '../../../src/services/notificationService';
import { useSubscriptionStore } from '../../../src/store/subscriptionStore';
import { useSubscriptionStore } from '../../../src/store';
import { SubscriptionCategory, BillingCycle } from '../../../src/types/subscription';
import { makeSubscription, makeSubscriptionFormData, resetIdCounter } from './factories';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { act } from 'react';
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useWalletStore } from '../../../src/store/walletStore';
import { useWalletStore } from '../../../src/store';
import { makeWallet, makeCryptoStream, resetIdCounter } from './factories';

// ── In-memory AsyncStorage ────────────────────────────────────────────────────
Expand Down
146 changes: 146 additions & 0 deletions docs/store-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Store Migration Guide

## Overview

The SubTrackr state management has been refactored from **~25 individual Zustand stores** to a **single combined store using Zustand's slices pattern**. This improves:

- **Modularity** – Each domain is a clean slice with typed interfaces
- **Cross-slice communication** – Slices access each other via `get()` without importing other stores
- **Performance** – Single store with optimized selectors
- **Testability** – Slices can be tested independently
- **Bundle size** – Single Zustand instance instead of many

## What Changed

### Before (individual stores)
```ts
// Each domain had its own store
import { useSubscriptionStore } from '../store/subscriptionStore';
import { useInvoiceStore } from '../store/invoiceStore';

const subscriptions = useSubscriptionStore((s) => s.subscriptions);
const invoices = useInvoiceStore((s) => s.invoices);

// Cross-store access required importing the other store
import { useCalendarStore } from '../store/calendarStore';
useCalendarStore.getState().syncSubscriptionToCalendars(sub);
```

### After (combined store)
```ts
// Single store import
import { useStore } from '../store';

// Same selector pattern – just changed the hook name
const subscriptions = useStore((s) => s.subscriptions);
const invoices = useStore((s) => s.invoices);

// Cross-store access is now built-in (same get())
useStore.getState().syncSubscriptionToCalendars(sub);
```

## Migration Steps

### 1. Update imports (optional but recommended)

**Current hooks still work** – all old store names are re-exported from `src/store/index.ts` as aliases to the combined store. However, you'll get a deprecation warning.

To migrate fully:

```diff
- import { useSubscriptionStore } from '../store';
+ import { useStore } from '../store/combinedStore';

- const subscriptions = useSubscriptionStore((s) => s.subscriptions);
+ const subscriptions = useStore((s) => s.subscriptions);
```

### 2. Replace cross-store access

```diff
- import { useCalendarStore } from '../store/calendarStore';
- import { useGamificationStore } from '../store/gamificationStore';
+ import { useStore } from '../store';

- useCalendarStore.getState().syncSubscriptionToCalendars(sub);
+ useStore.getState().syncSubscriptionToCalendars(sub);

- useGamificationStore.getState().addPoints(10);
+ useStore.getState().addPoints(10);
```

### 3. Testing changes

For tests, update the store import:

```diff
- import { useSubscriptionStore } from '../store/subscriptionStore';
+ import { useStore } from '../store';

// Reset state:
- useSubscriptionStore.setState({ subscriptions: [] });
+ useStore.setState({ subscriptions: [] });
```

## Understanding the Architecture

### Slice Organization

```
src/store/
├── slices/
│ ├── types.ts # Combined AppState type
│ ├── billingSlice.ts # Subscription, Invoice, Tax, Accounting, Usage, Cancellation
│ ├── walletSlice.ts # Wallet, TransactionQueue, Merchant
│ ├── settingsSlice.ts # Settings, User, Community
│ ├── engagementSlice.ts # Webhook, Gamification, Loyalty, Affiliate
│ ├── riskSlice.ts # Fraud, SLA
│ ├── devSlice.ts # Sandbox, DeveloperPortal
│ ├── marketingSlice.ts # Campaign, Segment, Group
│ ├── calendarSlice.ts # Calendar
│ ├── networkSlice.ts # Network
│ ├── supportSlice.ts # Support
│ ├── meteringSlice.ts # Metering, Credit, Batch, Search
│ ├── billingAccoutingTypes.ts # Shared accounting types
│ └── transactionQueueTypes.ts # Shared transaction queue types
├── combinedStore.ts # Combined store with persist
└── index.ts # Exports with backward-compatible aliases
```

### Adding a new slice

1. Create a new file in `slices/` with your slice interface and factory function
2. Add the interface to `slices/types.ts`
3. Import and compose the factory in `combinedStore.ts`
4. Add a re-export alias in `index.ts`

### Selector Optimization

For performance, always select the minimal data you need:

```ts
// ❌ Avoid – re-renders on any state change
const state = useStore();

// ✅ Better – only re-renders when subscriptions change
const subscriptions = useStore((s) => s.subscriptions);
const { addSubscription } = useStore((s) => s);

// ✅ Best – use multiple selectors or a shallow comparison
import { shallow } from 'zustand/shallow';
const [subscriptions, stats] = useStore(
(s) => [s.subscriptions, s.stats],
shallow
);
```

## Persistence

The combined store uses a single persisted key `subtrackr-root-store-v2` in AsyncStorage. Previous per-store persistence keys are no longer created, but existing stored data is migrated on first load via the `migrate` function in `combinedStore.ts`.

## Rollback

If issues arise, the old individual store files are preserved in the git history. To revert:
```bash
git checkout HEAD~1 -- src/store/
```
2 changes: 1 addition & 1 deletion src/components/subscription/SubscriptionPlans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { BillingCycle, SubscriptionTier, SubscriptionPlan } from '../../types/su
import { FeatureId } from '../../types/feature';
import { FEATURE_CONFIG } from '../../config/features';
import { featureFlagsService } from '../../services/featureFlags';
import { useUserStore } from '../../store/userStore';
import { useUserStore } from '../../store';
import { colors, spacing, typography, borderRadius, shadows } from '../../utils/constants';

const { width } = Dimensions.get('window');
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useCachedSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import { useCallback } from 'react';
import NetInfo from '@react-native-community/netinfo';
import { useSubscriptionStore } from '../store/subscriptionStore';
import { useSubscriptionStore } from '../store';
import { cacheService } from '../services/cache/cacheService';
import type { Subscription, SubscriptionFormData } from '../types/subscription';

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useFeatureAccess.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { FeatureId, FeatureAccessResult } from '../types/feature';
import { featureFlagsService } from '../services/featureFlags';
import { useUserStore } from '../store/userStore';
import { useUserStore } from '../store';

export interface UseFeatureAccessResult extends FeatureAccessResult {
loading: boolean;
Expand Down
4 changes: 2 additions & 2 deletions src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Notifications from 'expo-notifications';

import { useSubscriptionStore } from '../store';
import { useStore } from '../store';
import {
attachNotificationResponseListeners,
getPermissionStatus,
Expand All @@ -17,7 +17,7 @@ export function useNotifications(): {
permissionStatus: Notifications.PermissionStatus | null;
refreshPermission: () => Promise<void>;
} {
const subscriptions = useSubscriptionStore((s) => s.subscriptions);
const subscriptions = useStore((s) => s.subscriptions);
const [permissionStatus, setPermissionStatus] = useState<Notifications.PermissionStatus | null>(
null
);
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/useTransactionQueue.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useEffect } from 'react';

import { useTransactionQueueStore } from '../store/transactionQueueStore';
import { useStore } from '../store';

export function useTransactionQueue(): void {
useEffect(() => {
const unsubscribe = useTransactionQueueStore.getState().initializeConnectivityListener();
void useTransactionQueueStore.getState().refreshConnectivity();
void useTransactionQueueStore.getState().processQueue();
const unsubscribe = useStore.getState().initializeConnectivityListener();
void useStore.getState().refreshConnectivity();
void useStore.getState().processQueue();

return unsubscribe;
}, []);
Expand Down
2 changes: 1 addition & 1 deletion src/screens/AccountingExportScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import * as Clipboard from 'expo-clipboard';
import { Card } from '../components/common/Card';
import { colors, spacing, typography, borderRadius } from '../utils/constants';
import { useSubscriptionStore } from '../store/subscriptionStore';
import { useSubscriptionStore } from '../store';
import {
AccountingFieldMapping,
AccountingFormat,
Expand Down
5 changes: 2 additions & 3 deletions src/screens/AddSubscriptionScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../navigation/types';
import { useSubscriptionStore, useSettingsStore } from '../store';
import { useStore } from '../store';
import { Button } from '../components/common/Button';
import { getCurrencySymbol } from '../utils/formatting';
import { colors, spacing, typography, borderRadius } from '../utils/constants';
Expand All @@ -30,8 +30,7 @@ interface AddSubscriptionFormData extends SubscriptionFormData {

const AddSubscriptionScreen: React.FC = () => {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { addSubscription, isLoading, error } = useSubscriptionStore();
const { preferredCurrency } = useSettingsStore();
const { addSubscription, isLoading, error, preferredCurrency } = useStore();

const [formData, setFormData] = useState<AddSubscriptionFormData>({
name: '',
Expand Down
3 changes: 1 addition & 2 deletions src/screens/AffiliateDashboardScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import {
FlatList,
} from 'react-native';
import { colors, spacing, typography, borderRadius } from '../utils/constants';
import { useAffiliateStore } from '../store/affiliateStore';
import { useWalletStore } from '../store/walletStore';
import { useAffiliateStore, useWalletStore } from '../store';
import { Card } from '../components/common/Card';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
Expand Down
6 changes: 2 additions & 4 deletions src/screens/AnalyticsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ import {
} from 'react-native';
import Svg, { Rect, Text as SvgText, Line, G } from 'react-native-svg';
import { colors, spacing, typography, borderRadius } from '../utils/constants';
import { useSubscriptionStore } from '../store';
import { useStore } from '../store';
import { SubscriptionCategory, BillingCycle } from '../types/subscription';
import { Card } from '../components/common/Card';
import { useSettingsStore } from '../store/settingsStore';
import { currencyService } from '../services/currencyService';
import { calculateSubscriptionAnalytics } from '../services/analyticsService';
import { formatCurrency } from '../utils/formatting';
Expand All @@ -25,8 +24,7 @@ const CHART_HEIGHT = 200;
type DateRange = 'week' | 'month' | 'year';

const AnalyticsScreen: React.FC = () => {
const { subscriptions, stats, calculateStats } = useSubscriptionStore();
const { preferredCurrency, exchangeRates } = useSettingsStore();
const { subscriptions, stats, calculateStats, preferredCurrency, exchangeRates } = useStore();
const rates = exchangeRates?.rates || {};
const [dateRange, setDateRange] = useState<DateRange>('month');

Expand Down
4 changes: 2 additions & 2 deletions src/screens/ApiKeyManagementScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import {
} from 'react-native';
import { Card } from '../components/common/Card';
import { colors, spacing, typography, borderRadius } from '../utils/constants';
import { useSandboxStore } from '../store/sandboxStore';
import { useStore } from '../store';
import { ApiKeyStatus, SandboxEnvironment } from '../types/sandbox';
import { apiKeyService } from '../services/sandbox/apiKeyService';

const ApiKeyManagementScreen: React.FC = () => {
const { apiKeys, developerProfile, generateApiKey, revokeApiKey, deleteApiKey } =
useSandboxStore();
useStore();

const [newKeyName, setNewKeyName] = useState('');
const [showNewKey, setShowNewKey] = useState<string | null>(null);
Expand Down
Loading