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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ logs/
coverage/
.nyc_output/

# Test snapshots & generated test/lint artifacts
**/__snapshots__/
*.snap
junit.xml
jest-results.json
*_output.txt
*_output_*.txt

# Load test reports (generated) — keep the dir so k6 can write into it
load-tests/reports/*
!load-tests/reports/.gitkeep
Expand Down
173 changes: 151 additions & 22 deletions app/screens/AnalyticsDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,52 @@ import {
ScrollView,
Share,
Alert,
TouchableOpacity,
} from 'react-native';
import { useSubscriptionStore } from '../../src/store/subscriptionStore';
import { useAnalyticsStore } from '../stores/analyticsStore';
import { useSettingsStore } from '../../src/store/settingsStore';
import { Card } from '../../src/components/common/Card';
import { Button } from '../../src/components/common/Button';
import { CohortChart } from '../../src/components/analytics/CohortChart';
import { RetentionHeatmap } from '../../src/components/analytics/RetentionHeatmap';
import { SankeyDiagram } from '../../src/components/analytics/SankeyDiagram';
import { useThemeColors } from '../../src/hooks/useThemeColors';
import { spacing, typography } from '../../src/utils/constants';
import { formatCurrency } from '../../src/utils/formatting';
import type { CohortGranularity } from '../../src/types/cohortAnalytics';

const AnalyticsDashboard: React.FC = () => {
const colors = useThemeColors();
const styles = useMemo(() => createStyles(colors), [colors]);

const { subscriptions } = useSubscriptionStore();
const { preferredCurrency } = useSettingsStore();
const { report, compute, exportCSV } = useAnalyticsStore();
const {
report,
granularity,
cohortBuckets,
retentionCurve,
churnBreakdown,
planMigrationFlows,
ltvBySource,
revenueTrendWithAnomalies,
setGranularity,
compute,
exportCSV,
exportCohortCsv,
exportCohortPdf,
} = useAnalyticsStore();

useEffect(() => {
compute(subscriptions);
}, [subscriptions, compute]);

const handleSetGranularity = (next: CohortGranularity) => {
setGranularity(next);
compute(subscriptions);
};

const handleExportCSV = async () => {
try {
const csv = exportCSV(subscriptions);
Expand All @@ -38,6 +62,22 @@ const AnalyticsDashboard: React.FC = () => {
}
};

const handleExportCohortCsv = async () => {
try {
await Share.share({ message: exportCohortCsv(), title: 'Cohort Report (CSV)' });
} catch {
Alert.alert('Export Failed', 'Could not export cohort report');
}
};

const handleExportCohortPdf = async () => {
try {
await Share.share({ message: exportCohortPdf(), title: 'Cohort Report (PDF)' });
} catch {
Alert.alert('Export Failed', 'Could not export cohort report');
}
};

const currency = preferredCurrency ?? 'USD';

if (!report) {
Expand Down Expand Up @@ -107,36 +147,104 @@ const AnalyticsDashboard: React.FC = () => {

<Card style={styles.card}>
<Text style={styles.sectionTitle}>Revenue Trend (last 6 months)</Text>
{report.revenueTrend.length === 0 ? (
{revenueTrendWithAnomalies.length === 0 ? (
<Text style={styles.emptyText}>No trend data yet</Text>
) : (
report.revenueTrend.map((point, index) => (
<View
key={point.label}
style={[
styles.statRow,
index === report.revenueTrend.length - 1 && styles.lastRow,
]}>
<Text style={styles.statLabel}>{point.label}</Text>
<Text style={styles.statValue}>{formatCurrency(point.mrr, currency)}</Text>
revenueTrendWithAnomalies.map((point, index, arr) => (
<View key={point.label} style={[styles.statRow, index === arr.length - 1 && styles.lastRow]}>
<Text style={styles.statLabel}>
{point.label}
{point.isAnomaly ? ' ⚠️' : ''}
</Text>
<Text style={[styles.statValue, point.isAnomaly && styles.anomalyValue]}>
{formatCurrency(point.value, currency)}
</Text>
</View>
))
)}
{revenueTrendWithAnomalies.some((point) => point.isAnomaly) && (
<Text style={styles.anomalyNote}>⚠️ flagged points are statistical outliers vs. the rest of the trend</Text>
)}
</Card>

<Card style={styles.card}>
<Text style={styles.sectionTitle}>Cohorts</Text>
{report.cohorts.length === 0 ? (
<Text style={styles.emptyText}>No cohort data yet</Text>
<View style={styles.rowBetween}>
<Text style={styles.sectionTitle}>Cohort Retention</Text>
<View style={styles.granularityToggle}>
{(['week', 'month'] as CohortGranularity[]).map((option) => (
<TouchableOpacity
key={option}
style={[styles.granularityButton, granularity === option && styles.granularityButtonActive]}
onPress={() => handleSetGranularity(option)}
accessibilityRole="button"
accessibilityState={{ selected: granularity === option }}>
<Text
style={[
styles.granularityButtonText,
granularity === option && styles.granularityButtonTextActive,
]}>
{option === 'week' ? 'Week' : 'Month'}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<CohortChart buckets={cohortBuckets} />
{cohortBuckets.slice(-4).map((bucket, index, arr) => (
<View key={bucket.cohortKey} style={[styles.statRow, index === arr.length - 1 && styles.lastRow]}>
<Text style={styles.statLabel}>{bucket.cohortKey}</Text>
<Text style={styles.statValue}>
{bucket.size} signups · {(bucket.retentionRate * 100).toFixed(0)}% retained ·{' '}
{formatCurrency(bucket.currentMrr, currency)}
</Text>
</View>
))}
</Card>

<Card style={styles.card}>
<Text style={styles.sectionTitle}>Retention Curve (Day 1 / 7 / 30 / 60 / 90)</Text>
<RetentionHeatmap points={retentionCurve} />
</Card>

<Card style={styles.card}>
<Text style={styles.sectionTitle}>Revenue vs. Logo Churn (last 30 days)</Text>
{!churnBreakdown || churnBreakdown.isEmpty ? (
<Text style={styles.emptyText}>No subscribers active at the start of this period yet.</Text>
) : (
report.cohorts.slice(-4).map((cohort, index, arr) => (
<View
key={cohort.cohort}
style={[styles.statRow, index === arr.length - 1 && styles.lastRow]}>
<Text style={styles.statLabel}>{cohort.cohort}</Text>
<>
<View style={styles.statRow}>
<Text style={styles.statLabel}>Logo churn (subscribers)</Text>
<Text style={styles.statValue}>
{(churnBreakdown.logoChurnRate * 100).toFixed(1)}% ({churnBreakdown.churnedSubscribers}/
{churnBreakdown.startingSubscribers})
</Text>
</View>
<View style={[styles.statRow, styles.lastRow]}>
<Text style={styles.statLabel}>Revenue churn (MRR)</Text>
<Text style={styles.statValue}>
{(cohort.retentionRate * 100).toFixed(0)}% retained ·{' '}
{formatCurrency(cohort.revenue, currency)}
{(churnBreakdown.revenueChurnRate * 100).toFixed(1)}% (
{formatCurrency(churnBreakdown.churnedMrr, currency)})
</Text>
</View>
</>
)}
</Card>

<Card style={styles.card}>
<Text style={styles.sectionTitle}>Plan Migration</Text>
<SankeyDiagram flows={planMigrationFlows} />
</Card>

<Card style={styles.card}>
<Text style={styles.sectionTitle}>LTV by Acquisition Source</Text>
{ltvBySource.length === 0 ? (
<Text style={styles.emptyText}>No acquisition source data yet</Text>
) : (
ltvBySource.map((row, index, arr) => (
<View key={row.acquisitionChannel} style={[styles.statRow, index === arr.length - 1 && styles.lastRow]}>
<Text style={styles.statLabel}>{row.acquisitionChannel}</Text>
<Text style={styles.statValue}>
{formatCurrency(row.ltv, currency)} LTV · {row.subscriberCount} subs
</Text>
</View>
))
Expand All @@ -159,6 +267,8 @@ const AnalyticsDashboard: React.FC = () => {

<View style={styles.exportContainer}>
<Button title="Export CSV" onPress={handleExportCSV} variant="secondary" />
<Button title="Export Cohort CSV" onPress={handleExportCohortCsv} variant="secondary" />
<Button title="Export Cohort PDF" onPress={handleExportCohortPdf} variant="secondary" />
</View>
</ScrollView>
</SafeAreaView>
Expand Down Expand Up @@ -194,8 +304,27 @@ function createStyles(colors: ReturnType<typeof useThemeColors>) {
statLabel: { ...typography.body, color: colors.textSecondary },
statValue: { ...typography.body, color: colors.text.primary, fontWeight: '600' },
emptyText: { ...typography.body, color: colors.textSecondary, textAlign: 'center' },
exportContainer: { padding: spacing.lg, paddingTop: 0, marginBottom: spacing.xl },
exportContainer: { padding: spacing.lg, paddingTop: 0, marginBottom: spacing.xl, gap: spacing.sm },
loadingText: { ...typography.body, color: colors.textSecondary, padding: spacing.lg },
rowBetween: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
granularityToggle: { flexDirection: 'row', gap: spacing.xs },
granularityButton: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: 6,
borderWidth: 1,
borderColor: colors.border.default,
},
granularityButtonActive: { backgroundColor: colors.primary, borderColor: colors.primary },
granularityButtonText: { ...typography.caption, color: colors.textSecondary },
granularityButtonTextActive: { color: colors.text.inverse, fontWeight: '600' },
anomalyValue: { color: colors.status.warning },
anomalyNote: { ...typography.caption, color: colors.status.warning, marginTop: spacing.xs },
});
}

Expand Down
88 changes: 85 additions & 3 deletions app/stores/analyticsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,106 @@ import {
calculateSubscriptionAnalytics,
SubscriptionAnalyticsReport,
} from '../../src/services/analyticsService';
import { Subscription } from '../../src/types/subscription';
import { BillingCycle, Subscription } from '../../src/types/subscription';
import { generateCSV } from '../../src/utils/importExport';
import { CohortService } from '../../backend/services/analytics/cohortService';
import { cohortTableToCsv } from '../../backend/services/analytics/cohortReportExport';
import { cohortTableToPdfText } from '../../src/services/cohortPdfExport';
import type {
ChurnBreakdown,
CohortBucket,
CohortGranularity,
LtvSourceBreakdown,
PlanMigrationFlow,
RetentionCurvePoint,
SubscriberRecord,
AnomalyFlaggedPoint,
} from '../../src/types/cohortAnalytics';

const DAY_MS = 24 * 60 * 60 * 1_000;

/**
* Adapts the app's personal Subscription model into merchant-style
* SubscriberRecords so CohortService (built for the merchant analytics
* platform) can compute cohort/retention/churn/LTV metrics on it. Each
* tracked subscription stands in for a "subscriber" of this account.
*/
const toSubscriberRecords = (subscriptions: Subscription[]): SubscriberRecord[] =>
subscriptions.map((subscription) => ({
subscriberId: subscription.id,
merchantId: 'self',
planId: subscription.category,
planName: subscription.name,
region: subscription.timezone,
acquisitionChannel: subscription.isCryptoEnabled ? 'crypto' : 'card',
signupAt: new Date(subscription.createdAt).getTime(),
churnedAt: subscription.isActive ? undefined : new Date(subscription.updatedAt).getTime(),
lastActiveAt: new Date(subscription.updatedAt).getTime(),
mrr:
subscription.billingCycle === BillingCycle.YEARLY
? subscription.price / 12
: subscription.billingCycle === BillingCycle.WEEKLY
? subscription.price * 4.345
: subscription.price,
}));

interface AnalyticsStoreState {
report: SubscriptionAnalyticsReport | null;
granularity: CohortGranularity;
cohortBuckets: CohortBucket[];
retentionCurve: RetentionCurvePoint[];
churnBreakdown: ChurnBreakdown | null;
planMigrationFlows: PlanMigrationFlow[];
ltvBySource: LtvSourceBreakdown[];
revenueTrendWithAnomalies: AnomalyFlaggedPoint[];
setGranularity: (granularity: CohortGranularity) => void;
compute: (subscriptions: Subscription[]) => void;
exportCSV: (subscriptions: Subscription[]) => string;
exportCohortCsv: () => string;
exportCohortPdf: () => string;
}

export const useAnalyticsStore = create<AnalyticsStoreState>()((set) => ({
export const useAnalyticsStore = create<AnalyticsStoreState>()((set, get) => ({
report: null,
granularity: 'month',
cohortBuckets: [],
retentionCurve: [],
churnBreakdown: null,
planMigrationFlows: [],
ltvBySource: [],
revenueTrendWithAnomalies: [],

setGranularity: (granularity) => {
set({ granularity });
// Recompute is cheap (in-memory, no I/O) — callers re-run `compute` with
// the latest subscriptions list whenever granularity changes.
},

compute: (subscriptions) => {
const report = calculateSubscriptionAnalytics(subscriptions);
set({ report });
const records = toSubscriberRecords(subscriptions);
const granularity = get().granularity;
const now = Date.now();
const periodStart = now - 30 * DAY_MS;

set({
report,
cohortBuckets: CohortService.buildCohortTable(records, granularity),
retentionCurve: CohortService.retentionCurve(records),
churnBreakdown: CohortService.revenueChurnVsLogoChurn(records, periodStart, now),
planMigrationFlows: CohortService.planMigrationFlows(records, periodStart, now),
ltvBySource: CohortService.ltvByAcquisitionSource(records),
revenueTrendWithAnomalies: CohortService.filterAnomalousSpikes(
report.revenueTrend.map((point) => ({ label: point.label, value: point.mrr }))
),
});
},

exportCSV: (subscriptions) => {
return generateCSV(subscriptions);
},

exportCohortCsv: () => cohortTableToCsv(get().cohortBuckets),

exportCohortPdf: () => cohortTableToPdfText(get().cohortBuckets, 'Cohort Retention Report'),
}));
Loading