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
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 ? (
+
+ ) : (
+ 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;