diff --git a/.gitignore b/.gitignore index fc097f2f..cf76729e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/screens/AnalyticsDashboard.tsx b/app/screens/AnalyticsDashboard.tsx index d8929172..68833ba0 100644 --- a/app/screens/AnalyticsDashboard.tsx +++ b/app/screens/AnalyticsDashboard.tsx @@ -7,15 +7,20 @@ 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(); @@ -23,12 +28,31 @@ const AnalyticsDashboard: React.FC = () => { 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); @@ -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) { @@ -107,36 +147,104 @@ const AnalyticsDashboard: React.FC = () => { Revenue Trend (last 6 months) - {report.revenueTrend.length === 0 ? ( + {revenueTrendWithAnomalies.length === 0 ? ( No trend data yet ) : ( - report.revenueTrend.map((point, index) => ( - - {point.label} - {formatCurrency(point.mrr, currency)} + revenueTrendWithAnomalies.map((point, index, arr) => ( + + + {point.label} + {point.isAnomaly ? ' ⚠️' : ''} + + + {formatCurrency(point.value, currency)} + )) )} + {revenueTrendWithAnomalies.some((point) => point.isAnomaly) && ( + ⚠️ flagged points are statistical outliers vs. the rest of the trend + )} - Cohorts - {report.cohorts.length === 0 ? ( - No cohort data yet + + Cohort Retention + + {(['week', 'month'] as CohortGranularity[]).map((option) => ( + handleSetGranularity(option)} + accessibilityRole="button" + accessibilityState={{ selected: granularity === option }}> + + {option === 'week' ? 'Week' : 'Month'} + + + ))} + + + + {cohortBuckets.slice(-4).map((bucket, index, arr) => ( + + {bucket.cohortKey} + + {bucket.size} signups · {(bucket.retentionRate * 100).toFixed(0)}% retained ·{' '} + {formatCurrency(bucket.currentMrr, currency)} + + + ))} + + + + Retention Curve (Day 1 / 7 / 30 / 60 / 90) + + + + + Revenue vs. Logo Churn (last 30 days) + {!churnBreakdown || churnBreakdown.isEmpty ? ( + No subscribers active at the start of this period yet. ) : ( - report.cohorts.slice(-4).map((cohort, index, arr) => ( - - {cohort.cohort} + <> + + Logo churn (subscribers) + + {(churnBreakdown.logoChurnRate * 100).toFixed(1)}% ({churnBreakdown.churnedSubscribers}/ + {churnBreakdown.startingSubscribers}) + + + + Revenue churn (MRR) - {(cohort.retentionRate * 100).toFixed(0)}% retained ·{' '} - {formatCurrency(cohort.revenue, currency)} + {(churnBreakdown.revenueChurnRate * 100).toFixed(1)}% ( + {formatCurrency(churnBreakdown.churnedMrr, currency)}) + + + + )} + + + + Plan Migration + + + + + LTV by Acquisition Source + {ltvBySource.length === 0 ? ( + No acquisition source data yet + ) : ( + ltvBySource.map((row, index, arr) => ( + + {row.acquisitionChannel} + + {formatCurrency(row.ltv, currency)} LTV · {row.subscriberCount} subs )) @@ -159,6 +267,8 @@ const AnalyticsDashboard: React.FC = () => {