From ce64fd5534bd372c89b025274a4227bffa23d563 Mon Sep 17 00:00:00 2001 From: topboy192 Date: Thu, 25 Jun 2026 16:11:37 +0100 Subject: [PATCH 1/2] chore: update .gitignore with test snapshot exclusions --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index fc097f2f..10866ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -82,6 +82,10 @@ load-tests/reports/* .expo-shared/ +# Test snapshots +__snapshots__/ +*.snap + # Build artifacts build_errors*.txt *.log From c80ecc56e2c24d1f56065a3ac95b2dccf8dd4a77 Mon Sep 17 00:00:00 2001 From: topboy192 Date: Thu, 25 Jun 2026 16:40:34 +0100 Subject: [PATCH 2/2] feat: add customer health scoring for proactive retention (#559) --- src/navigation/AppNavigator.tsx | 6 + src/navigation/types.ts | 1 + src/screens/CustomerHealthScreen.tsx | 431 +++++++++++++++++++++++++++ src/services/healthService.ts | 61 ++++ src/store/healthStore.ts | 210 +++++++++++++ src/store/index.ts | 1 + src/store/settingsStore.ts | 7 +- src/types/health.ts | 86 ++++++ 8 files changed, 801 insertions(+), 2 deletions(-) create mode 100644 src/screens/CustomerHealthScreen.tsx create mode 100644 src/services/healthService.ts create mode 100644 src/store/healthStore.ts create mode 100644 src/types/health.ts diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 16c0e5d8..e4898940 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -69,6 +69,7 @@ const PerformanceDashboardScreen = lazyScreen( const EditSubscriptionScreen = lazyScreen(() => import('../screens/EditSubscriptionScreen')); const ChangePlanScreen = lazyScreen(() => import('../screens/ChangePlanScreen')); const BillingSettingsScreen = lazyScreen(() => import('../screens/BillingSettingsScreen')); +const CustomerHealthScreen = lazyScreen(() => import('../screens/CustomerHealthScreen')); const PaymentMethodsScreen = lazyScreen(() => import('../../app/screens/PaymentMethodsScreen').then((m) => ({ default: m.PaymentMethodsScreen, @@ -338,6 +339,11 @@ const SettingsStack = () => ( component={PerformanceDashboardScreen} options={{ title: 'Performance', headerShown: true }} /> + { + const colors = useThemeColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const { healthScores, history, interventions } = useHealthStore(); + const { healthScoreWeights, setHealthScoreWeights } = useSettingsStore(); + const [dateRange, setDateRange] = useState('30d'); + const [selectedSubscriptionId, setSelectedSubscriptionId] = useState(null); + + const weights = (healthScoreWeights as typeof DEFAULT_WEIGHTS) ?? DEFAULT_WEIGHTS; + + const selectedScore = selectedSubscriptionId + ? healthScores.find((h) => h.subscriptionId === selectedSubscriptionId) + : healthScores[0] ?? undefined; + + useEffect(() => { + if (healthScores.length === 0) { + HealthScoreService.calculate('sub-1', 'user-1', { + loginFrequency: 70, + featureUsage: 65, + paymentSuccessRate: 90, + supportTickets: 2, + npsResponse: 60, + }); + } + }, []); + + const recentHistory = useMemo(() => { + if (!selectedScore) return []; + const cutoff = new Date(); + const days = dateRange === '30d' ? 30 : dateRange === '60d' ? 60 : 90; + cutoff.setDate(cutoff.getDate() - days); + return history + .filter((h) => h.healthScoreId === selectedScore.id && new Date(h.calculatedAt) >= cutoff) + .sort((a, b) => new Date(a.calculatedAt).getTime() - new Date(b.calculatedAt).getTime()); + }, [selectedScore, history, dateRange]); + + const scoreInterventions = useMemo(() => { + if (!selectedScore) return []; + return interventions.filter((i) => i.healthScoreId === selectedScore.id); + }, [selectedScore, interventions]); + + const getStatusColor = (status: HealthScoreStatus): string => { + switch (status) { + case HealthScoreStatus.GREEN: + return colors.success; + case HealthScoreStatus.YELLOW: + return colors.warning; + case HealthScoreStatus.RED: + return colors.error; + default: + return colors.textSecondary; + } + }; + + const chartPoints = useMemo(() => { + if (recentHistory.length < 2) return []; + const maxScore = 100; + const minScore = 0; + const stepX = CHART_WIDTH / Math.max(recentHistory.length - 1, 1); + return recentHistory.map((entry, idx) => ({ + x: idx * stepX, + y: CHART_HEIGHT - ((entry.score - minScore) / (maxScore - minScore)) * CHART_HEIGHT, + score: entry.score, + })); + }, [recentHistory]); + + const pathD = useMemo(() => { + if (chartPoints.length < 2) return ''; + return chartPoints + .map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)) + .join(' '); + }, [chartPoints]); + + function handleWeightChange(factor: keyof typeof DEFAULT_WEIGHTS, value: number) { + const next = { ...weights, [factor]: value }; + setHealthScoreWeights(next); + } + + return ( + + Customer Health + Proactive retention scoring + + {selectedScore && ( + + + {selectedScore.score} + + + {selectedScore.status.toUpperCase()} + + + + + Trend: {selectedScore.trend.charAt(0).toUpperCase() + selectedScore.trend.slice(1)} + + {selectedScore.manualOverride && ( + + Manual override: {selectedScore.manualOverride} ({selectedScore.manualOverrideReason}) + + )} + + )} + + + Score Breakdown + + + Login + {selectedScore?.breakdown.loginFrequency ?? '-'} + + + Usage + {selectedScore?.breakdown.featureUsage ?? '-'} + + + Payment + {selectedScore?.breakdown.paymentSuccessRate ?? '-'} + + + Support + {selectedScore?.breakdown.supportTickets ?? '-'} + + + NPS + {selectedScore?.breakdown.npsResponse ?? '-'} + + + + + + Score Trend + + {(['30d', '60d', '90d'] as DateRange[]).map((range) => ( + setDateRange(range)} + style={[ + styles.rangeChip, + dateRange === range && styles.rangeChipActive, + ]} + > + + {range} + + + ))} + + {chartPoints.length >= 2 ? ( + + + + {chartPoints.map((p, idx) => ( + + ))} + + + {chartPoints.map((p, idx) => ( + + {p.score} + + ))} + + + ) : ( + Not enough history to display trend. + )} + + + + Weights + {(Object.keys(DEFAULT_WEIGHTS) as Array).map((factor) => ( + + {factor.replace(/([A-Z])/g, ' $1').trim()} + + handleWeightChange(factor, Math.max(0, weights[factor] - 0.05))} + style={styles.weightButton} + > + - + + {weights[factor].toFixed(2)} + handleWeightChange(factor, Math.min(1, weights[factor] + 0.05))} + style={styles.weightButton} + > + + + + + + ))} + + + {scoreInterventions.length > 0 && ( + + Interventions + {scoreInterventions.map((intervention) => ( + + {intervention.type} + + {new Date(intervention.triggeredAt).toLocaleString()} + + + ))} + + )} + + ); +}; + +const createStyles = (colors: ReturnType) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.primary, + }, + content: { + padding: spacing.lg, + }, + title: { + ...typography.h2, + color: colors.text, + marginBottom: spacing.xs, + }, + subtitle: { + ...typography.body2, + color: colors.textSecondary, + marginBottom: spacing.lg, + }, + scoreCard: { + backgroundColor: colors.background.secondary, + borderRadius: borderRadius.lg, + padding: spacing.lg, + marginBottom: spacing.md, + borderWidth: 1, + borderColor: colors.border.default, + }, + scoreRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.sm, + }, + scoreValue: { + ...typography.h1, + color: colors.text, + }, + statusBadge: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + }, + statusText: { + ...typography.caption, + fontWeight: '700', + }, + trendText: { + ...typography.body2, + color: colors.textSecondary, + }, + overrideText: { + ...typography.body2, + color: colors.warning, + marginTop: spacing.xs, + }, + card: { + marginBottom: spacing.md, + }, + sectionTitle: { + ...typography.h3, + color: colors.text, + marginBottom: spacing.sm, + }, + breakdownRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + breakdownItem: { + alignItems: 'center', + flex: 1, + }, + breakdownLabel: { + ...typography.caption, + color: colors.textSecondary, + marginBottom: spacing.xs, + }, + breakdownValue: { + ...typography.body, + color: colors.text, + fontWeight: '600', + }, + chartRow: { + flexDirection: 'row', + gap: spacing.sm, + marginBottom: spacing.md, + }, + rangeChip: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: colors.border.default, + backgroundColor: colors.background.secondary, + }, + rangeChipActive: { + borderColor: colors.primary, + backgroundColor: colors.primary + '20', + }, + rangeChipText: { + ...typography.body2, + color: colors.textSecondary, + }, + rangeChipTextActive: { + color: colors.primary, + fontWeight: '600', + }, + chart: { + alignSelf: 'center', + }, + emptyText: { + ...typography.body2, + color: colors.textSecondary, + textAlign: 'center', + paddingVertical: spacing.md, + }, + weightRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + weightLabel: { + ...typography.body, + color: colors.text, + flex: 1, + }, + weightControls: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + weightButton: { + width: 28, + height: 28, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: colors.border.default, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.background.secondary, + }, + weightButtonText: { + ...typography.body, + color: colors.text, + }, + weightValue: { + ...typography.body, + color: colors.text, + width: 40, + textAlign: 'center', + }, + interventionRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + interventionType: { + ...typography.body, + color: colors.text, + textTransform: 'capitalize', + }, + interventionDate: { + ...typography.body2, + color: colors.textSecondary, + }, + }); + +export default CustomerHealthScreen; diff --git a/src/services/healthService.ts b/src/services/healthService.ts new file mode 100644 index 00000000..c4470257 --- /dev/null +++ b/src/services/healthService.ts @@ -0,0 +1,61 @@ +import { useHealthStore } from '../store/healthStore'; +import { HealthScoreBreakdown, HealthScoreStatus, InterventionType } from '../types/health'; + +export class HealthScoreService { + static calculate( + subscriptionId: string, + userId: string, + factors: Partial, + weights?: Parameters[3] + ) { + return useHealthStore.getState().calculateScore(subscriptionId, userId, factors, weights); + } + + static getScore(subscriptionId: string) { + return useHealthStore.getState().getScore(subscriptionId); + } + + static getScoresByUser(userId: string) { + return useHealthStore.getState().getScoresByUser(userId); + } + + static overrideScore( + subscriptionId: string, + newScore: number, + reason: string, + overriddenBy: string + ) { + return useHealthStore.getState().overrideScore(subscriptionId, newScore, reason, overriddenBy); + } + + static triggerIntervention(healthScoreId: string, type: InterventionType) { + return useHealthStore.getState().recordIntervention(healthScoreId, type); + } + + static getHistory(healthScoreId: string) { + return useHealthStore.getState().getHistory(healthScoreId); + } + + static getInterventions(healthScoreId: string) { + return useHealthStore.getState().getInterventions(healthScoreId); + } + + static updateWeights(weights: Parameters[0]) { + useHealthStore.getState().updateWeights(weights); + } + + static getWeights() { + return useHealthStore.getState().getWeights(); + } + + static getTrend(healthScoreId: string): 'improving' | 'stable' | 'declining' { + const history = useHealthStore.getState().getHistory(healthScoreId); + if (history.length < 2) return 'stable'; + const recent = history.slice(-3); + const first = recent[0].score; + const last = recent[recent.length - 1].score; + if (last > first + 5) return 'improving'; + if (last < first - 5) return 'declining'; + return 'stable'; + } +} diff --git a/src/store/healthStore.ts b/src/store/healthStore.ts new file mode 100644 index 00000000..fc5e545c --- /dev/null +++ b/src/store/healthStore.ts @@ -0,0 +1,210 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import { debouncedAsyncStorageAdapter } from '../utils/storage'; +import { + HealthScore, + HealthScoreHistory, + Intervention, + HealthScoreStatus, + HealthScoreWeights, + DEFAULT_WEIGHTS, + SCORE_THRESHOLDS, + HealthScoreBreakdown, +} from '../types/health'; +import { errorHandler, AppError } from '../services/errorHandler'; +import { useSettingsStore } from './settingsStore'; + +const STORAGE_KEY = 'subtrackr-health-scores'; +const HISTORY_STORAGE_KEY = 'subtrackr-health-score-history'; +const INTERVENTION_STORAGE_KEY = 'subtrackr-interventions'; + +const generateId = (): string => `hs-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + +interface HealthState { + healthScores: HealthScore[]; + history: HealthScoreHistory[]; + interventions: Intervention[]; + isLoading: boolean; + error: AppError | null; + + calculateScore: ( + subscriptionId: string, + userId: string, + factors: Partial, + weights?: HealthScoreWeights + ) => HealthScore; + + getScore: (subscriptionId: string) => HealthScore | undefined; + getScoresByUser: (userId: string) => HealthScore[]; + + overrideScore: ( + subscriptionId: string, + newScore: number, + reason: string, + overriddenBy: string + ) => HealthScore | undefined; + + recordIntervention: (healthScoreId: string, type: Intervention['type']) => Intervention; + + getHistory: (healthScoreId: string) => HealthScoreHistory[]; + getInterventions: (healthScoreId: string) => Intervention[]; + + updateWeights: (weights: HealthScoreWeights) => void; + getWeights: () => HealthScoreWeights; +} + +export const useHealthStore = create()( + persist( + (set, get) => ({ + healthScores: [], + history: [], + interventions: [], + isLoading: false, + error: null, + + calculateScore: (subscriptionId, userId, factors, weights = DEFAULT_WEIGHTS) => { + const state = get(); + const existing = state.healthScores.find( + (h) => h.subscriptionId === subscriptionId && h.userId === userId + ); + + const loginScore = factors.loginFrequency ?? 0; + const featureScore = factors.featureUsage ?? 0; + const paymentScore = factors.paymentSuccessRate ?? 0; + const supportScore = Math.max(0, 100 - (factors.supportTickets ?? 0)); + const npsScore = factors.npsResponse ?? 50; + + const overall = + loginScore * weights.loginFrequency + + featureScore * weights.featureUsage + + paymentScore * weights.paymentSuccessRate + + supportScore * weights.supportTickets + + npsScore * weights.npsResponse; + + const clampedOverall = Math.round(Math.min(100, Math.max(0, overall))); + + let status: HealthScoreStatus; + if (clampedOverall >= SCORE_THRESHOLDS.GREEN_MIN) status = HealthScoreStatus.GREEN; + else if (clampedOverall >= SCORE_THRESHOLDS.YELLOW_MIN) status = HealthScoreStatus.YELLOW; + else status = HealthScoreStatus.RED; + + const now = new Date(); + const score: HealthScore = { + id: existing?.id ?? generateId(), + subscriptionId, + userId, + score: clampedOverall, + status, + breakdown: { + overall: clampedOverall, + loginFrequency: Math.round(loginScore), + featureUsage: Math.round(featureScore), + paymentSuccessRate: Math.round(paymentScore), + supportTickets: Math.round(supportScore), + npsResponse: Math.round(npsScore), + }, + weights: { ...weights }, + calculatedAt: now, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }; + + const historyEntry: HealthScoreHistory = { + id: `hsh-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, + healthScoreId: score.id, + score: clampedOverall, + status, + calculatedAt: now, + }; + + set((state) => ({ + healthScores: state.healthScores.some((h) => h.id === score.id) + ? state.healthScores.map((h) => (h.id === score.id ? score : h)) + : [...state.healthScores, score], + history: [...state.history, historyEntry], + })); + + if (status === HealthScoreStatus.RED && (!existing || existing.status !== HealthScoreStatus.RED)) { + get().recordIntervention(score.id, InterventionType.PRIORITY_EMAIL); + get().recordIntervention(score.id, InterventionType.ACCOUNT_MANAGER_ALERT); + } + + return score; + }, + + getScore: (subscriptionId) => { + return get().healthScores.find((h) => h.subscriptionId === subscriptionId); + }, + + getScoresByUser: (userId) => { + return get().healthScores.filter((h) => h.userId === userId); + }, + + overrideScore: (subscriptionId, newScore, reason, overriddenBy) => { + const state = get(); + const existing = state.healthScores.find((h) => h.subscriptionId === subscriptionId); + if (!existing) return undefined; + + const now = new Date(); + const updated: HealthScore = { + ...existing, + score: Math.round(Math.min(100, Math.max(0, newScore))), + status: + existing.score >= SCORE_THRESHOLDS.GREEN_MIN + ? HealthScoreStatus.GREEN + : existing.score >= SCORE_THRESHOLDS.YELLOW_MIN + ? HealthScoreStatus.YELLOW + : HealthScoreStatus.RED, + manualOverride: newScore, + manualOverrideReason: reason, + manualOverrideBy: overriddenBy, + manualOverrideAt: now, + updatedAt: now, + }; + + set((state) => ({ + healthScores: state.healthScores.map((h) => (h.id === existing.id ? updated : h)), + })); + + return updated; + }, + + recordIntervention: (healthScoreId, type) => { + const intervention: Intervention = { + id: `int-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, + healthScoreId, + type, + triggeredAt: new Date(), + }; + + set((state) => ({ + interventions: [...state.interventions, intervention], + })); + + return intervention; + }, + + getHistory: (healthScoreId) => { + return get().history.filter((h) => h.healthScoreId === healthScoreId); + }, + + getInterventions: (healthScoreId) => { + return get().interventions.filter((i) => i.healthScoreId === healthScoreId); + }, + + updateWeights: (weights) => { + useSettingsStore.setState({ healthScoreWeights: weights }); + }, + + getWeights: () => { + const settings = useSettingsStore.getState(); + return (settings.healthScoreWeights as HealthScoreWeights) ?? DEFAULT_WEIGHTS; + }, + }), + { + name: STORAGE_KEY, + storage: debouncedAsyncStorageAdapter, + version: 0, + } + ) +); diff --git a/src/store/index.ts b/src/store/index.ts index 8d98cc10..ec7c3007 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -12,4 +12,5 @@ export { useTaxStore } from './taxStore'; export { useSupportStore } from './supportStore'; export { useAuthStore } from './authStore'; export { useCancellationStore } from './cancellationStore'; +export { useHealthStore } from './healthStore'; diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index c975bd3d..c8d35ce3 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -7,11 +7,13 @@ interface SettingsState { preferredCurrency: string; notificationsEnabled: boolean; exchangeRates: ExchangeRates | null; + healthScoreWeights: Record | null; isLoading: boolean; // Actions setPreferredCurrency: (currency: string) => void; setNotificationsEnabled: (enabled: boolean) => void; + setHealthScoreWeights: (weights: Record) => void; updateExchangeRates: () => Promise; initializeSettings: () => Promise; } @@ -22,17 +24,18 @@ export const useSettingsStore = create()( preferredCurrency: 'USD', notificationsEnabled: true, exchangeRates: null, + healthScoreWeights: null, isLoading: false, setPreferredCurrency: (currency) => { set({ preferredCurrency: currency }); - // Optionally update rates immediately if base changed, - // but here we keep USD as base for rates to simplify conversion void get().updateExchangeRates(); }, setNotificationsEnabled: (enabled) => set({ notificationsEnabled: enabled }), + setHealthScoreWeights: (weights) => set({ healthScoreWeights: weights }), + updateExchangeRates: async () => { set({ isLoading: true }); const rates = await currencyService.fetchRates('USD'); diff --git a/src/types/health.ts b/src/types/health.ts new file mode 100644 index 00000000..375a08c4 --- /dev/null +++ b/src/types/health.ts @@ -0,0 +1,86 @@ +export enum HealthScoreStatus { + GREEN = 'green', + YELLOW = 'yellow', + RED = 'red', + NEUTRAL = 'neutral', +} + +export enum HealthScoreFactor { + LOGIN_FREQUENCY = 'login_frequency', + FEATURE_USAGE = 'feature_usage', + PAYMENT_SUCCESS_RATE = 'payment_success_rate', + SUPPORT_TICKETS = 'support_tickets', + NPS_RESPONSE = 'nps_response', +} + +export enum InterventionType { + PRIORITY_EMAIL = 'priority_email', + ACCOUNT_MANAGER_ALERT = 'account_manager_alert', + DISCOUNT_OFFER = 'discount_offer', +} + +export interface HealthScoreWeights { + loginFrequency: number; + featureUsage: number; + paymentSuccessRate: number; + supportTickets: number; + npsResponse: number; +} + +export interface HealthScoreBreakdown { + overall: number; + loginFrequency: number; + featureUsage: number; + paymentSuccessRate: number; + supportTickets: number; + npsResponse: number; +} + +export interface HealthScore { + id: string; + subscriptionId: string; + userId: string; + score: number; + status: HealthScoreStatus; + breakdown: HealthScoreBreakdown; + weights: HealthScoreWeights; + manualOverride?: number; + manualOverrideReason?: string; + manualOverrideBy?: string; + manualOverrideAt?: Date; + trend: 'improving' | 'stable' | 'declining'; + calculatedAt: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface HealthScoreHistory { + id: string; + healthScoreId: string; + score: number; + status: HealthScoreStatus; + calculatedAt: Date; +} + +export interface Intervention { + id: string; + healthScoreId: string; + type: InterventionType; + triggeredAt: Date; + acknowledgedAt?: Date; + acknowledgedBy?: string; + metadata?: Record; +} + +export const DEFAULT_WEIGHTS: HealthScoreWeights = { + loginFrequency: 0.2, + featureUsage: 0.25, + paymentSuccessRate: 0.3, + supportTickets: 0.15, + npsResponse: 0.1, +}; + +export const SCORE_THRESHOLDS = { + GREEN_MIN: 80, + YELLOW_MIN: 50, +} as const;