diff --git a/app/screens/AnalyticsDashboard.tsx b/app/screens/AnalyticsDashboard.tsx index 68833ba0..96d53ef5 100644 --- a/app/screens/AnalyticsDashboard.tsx +++ b/app/screens/AnalyticsDashboard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { View, Text, @@ -17,6 +17,7 @@ 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 { WidgetCustomizationModal } from '../../src/components/analytics/WidgetCustomizationModal'; import { useThemeColors } from '../../src/hooks/useThemeColors'; import { spacing, typography } from '../../src/utils/constants'; import { formatCurrency } from '../../src/utils/formatting'; @@ -31,6 +32,9 @@ const AnalyticsDashboard: React.FC = () => { const { report, granularity, + forecastModel, + enabledWidgets, + widgetOrder, cohortBuckets, retentionCurve, churnBreakdown, @@ -42,11 +46,15 @@ const AnalyticsDashboard: React.FC = () => { exportCSV, exportCohortCsv, exportCohortPdf, + exportSummaryCsv, + exportSummaryText, } = useAnalyticsStore(); + const [modalVisible, setModalVisible] = useState(false); + useEffect(() => { compute(subscriptions); - }, [subscriptions, compute]); + }, [subscriptions, compute, forecastModel]); const handleSetGranularity = (next: CohortGranularity) => { setGranularity(next); @@ -78,6 +86,24 @@ const AnalyticsDashboard: React.FC = () => { } }; + const handleExportSummaryCsv = async () => { + try { + const csv = exportSummaryCsv(); + await Share.share({ message: csv, title: 'MRR & ARR Summary Report (CSV)' }); + } catch { + Alert.alert('Export Failed', 'Could not export summary report'); + } + }; + + const handleExportSummaryText = async () => { + try { + const txt = exportSummaryText(); + await Share.share({ message: txt, title: 'MRR & ARR Summary Report (PDF/Text)' }); + } catch { + Alert.alert('Export Failed', 'Could not export summary report'); + } + }; + const currency = preferredCurrency ?? 'USD'; if (!report) { @@ -88,189 +114,289 @@ const AnalyticsDashboard: React.FC = () => { ); } - return ( - - - - Analytics Dashboard - - {report.subscriberCount} active subscriber - {report.subscriberCount !== 1 ? 's' : ''} - - - - - - MRR - {formatCurrency(report.mrr, currency)} - - - ARR - {formatCurrency(report.arr, currency)} - - + const renderWidget = (widgetId: string) => { + if (!enabledWidgets.includes(widgetId)) return null; - - - ARPU - {formatCurrency(report.arpu, currency)} - - - LTV - {formatCurrency(report.ltv, currency)} - - + switch (widgetId) { + case 'overview': + return ( + + + + + MRR + = 0 ? styles.badgeSuccess : styles.badgeDanger, + ]} + > + = 0 ? styles.textSuccess : styles.textDanger, + ]} + > + {report.mrrGrowthRate >= 0 ? '+' : ''} + {report.mrrGrowthRate.toFixed(1)}% MoM + + + + {formatCurrency(report.mrr, currency)} + + + + ARR + = 0 ? styles.badgeSuccess : styles.badgeDanger, + ]} + > + = 0 ? styles.textSuccess : styles.textDanger, + ]} + > + {report.arrGrowthRate >= 0 ? '+' : ''} + {report.arrGrowthRate.toFixed(1)}% YoY + + + + {formatCurrency(report.arr, currency)} + + - - Churn - - Gross Churn Rate - - {(report.churn.grossChurnRate * 100).toFixed(1)}% - - - - Net Churn Rate - - {(report.churn.netChurnRate * 100).toFixed(1)}% - - - - Active Subscribers - {report.churn.activeSubscriptions} - - - Churned Subscribers - {report.churn.churnedSubscriptions} + + + ARPU + {formatCurrency(report.arpu, currency)} + + + LTV + {formatCurrency(report.ltv, currency)} + + - + ); + + case 'revenueTrend': + return ( + + Revenue Trend (last 6 months) + {revenueTrendWithAnomalies.length === 0 ? ( + No trend data yet + ) : ( + 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 + + )} + + ); - - Revenue Trend (last 6 months) - {revenueTrendWithAnomalies.length === 0 ? ( - No trend data yet - ) : ( - revenueTrendWithAnomalies.map((point, index, arr) => ( - - - {point.label} - {point.isAnomaly ? ' ⚠️' : ''} - - - {formatCurrency(point.value, currency)} - + case 'forecast': + return ( + + + Revenue Forecast + + {forecastModel.toUpperCase()} MODEL - )) - )} - {revenueTrendWithAnomalies.some((point) => point.isAnomaly) && ( - ⚠️ flagged points are statistical outliers vs. the rest of the trend - )} - + + {report.forecast.map((point, index) => ( + + {point.label} + + + {formatCurrency(point.expectedRevenue, currency)} + + + ({formatCurrency(point.lowerBound, currency)} -{' '} + {formatCurrency(point.upperBound, currency)}) + + + + ))} + + ); - - - Cohort Retention - - {(['week', 'month'] as CohortGranularity[]).map((option) => ( - handleSetGranularity(option)} - accessibilityRole="button" - accessibilityState={{ selected: granularity === option }}> - - {option === 'week' ? 'Week' : 'Month'} + case 'cohortHeatmap': + return ( + + + + 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) + + - - {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) - - + case 'churnBreakdown': + return ( + + Revenue vs. Logo Churn (last 30 days) + {!churnBreakdown || churnBreakdown.isEmpty ? ( + + No subscribers active at the start of this period yet. + + ) : ( + <> + + Logo churn (subscribers) + + {(churnBreakdown.logoChurnRate * 100).toFixed(1)}% ({churnBreakdown.churnedSubscribers} + /{churnBreakdown.startingSubscribers}) + + + + Revenue churn (MRR) + + {(churnBreakdown.revenueChurnRate * 100).toFixed(1)}% ( + {formatCurrency(churnBreakdown.churnedMrr, currency)}) + + + + )} + + ); - - Revenue vs. Logo Churn (last 30 days) - {!churnBreakdown || churnBreakdown.isEmpty ? ( - No subscribers active at the start of this period yet. - ) : ( - <> - - Logo churn (subscribers) - - {(churnBreakdown.logoChurnRate * 100).toFixed(1)}% ({churnBreakdown.churnedSubscribers}/ - {churnBreakdown.startingSubscribers}) - - - - Revenue churn (MRR) - - {(churnBreakdown.revenueChurnRate * 100).toFixed(1)}% ( - {formatCurrency(churnBreakdown.churnedMrr, currency)}) - - - - )} - + case 'planMigrations': + return ( + + Plan Migration & LTV by Channel + + + + 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 + + + )) + )} + + ); - - Plan Migration - - + default: + return null; + } + }; - - 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 - - - )) - )} - + return ( + + + + + Analytics Dashboard + + {report.subscriberCount} active subscriber + {report.subscriberCount !== 1 ? 's' : ''} + + + setModalVisible(true)}> + ⚙️ Customize + + - - Revenue Forecast - {report.forecast.map((point, index) => ( - - {point.label} - - {formatCurrency(point.expectedRevenue, currency)} - - - ))} - + {widgetOrder.map((widgetId) => renderWidget(widgetId))} -