@@ -7,28 +7,52 @@ import {
77 ScrollView ,
88 Share ,
99 Alert ,
10+ TouchableOpacity ,
1011} from 'react-native' ;
1112import { useSubscriptionStore } from '../../src/store/subscriptionStore' ;
1213import { useAnalyticsStore } from '../stores/analyticsStore' ;
1314import { useSettingsStore } from '../../src/store/settingsStore' ;
1415import { Card } from '../../src/components/common/Card' ;
1516import { Button } from '../../src/components/common/Button' ;
17+ import { CohortChart } from '../../src/components/analytics/CohortChart' ;
18+ import { RetentionHeatmap } from '../../src/components/analytics/RetentionHeatmap' ;
19+ import { SankeyDiagram } from '../../src/components/analytics/SankeyDiagram' ;
1620import { useThemeColors } from '../../src/hooks/useThemeColors' ;
1721import { spacing , typography } from '../../src/utils/constants' ;
1822import { formatCurrency } from '../../src/utils/formatting' ;
23+ import type { CohortGranularity } from '../../src/types/cohortAnalytics' ;
1924
2025const AnalyticsDashboard : React . FC = ( ) => {
2126 const colors = useThemeColors ( ) ;
2227 const styles = useMemo ( ( ) => createStyles ( colors ) , [ colors ] ) ;
2328
2429 const { subscriptions } = useSubscriptionStore ( ) ;
2530 const { preferredCurrency } = useSettingsStore ( ) ;
26- const { report, compute, exportCSV } = useAnalyticsStore ( ) ;
31+ const {
32+ report,
33+ granularity,
34+ cohortBuckets,
35+ retentionCurve,
36+ churnBreakdown,
37+ planMigrationFlows,
38+ ltvBySource,
39+ revenueTrendWithAnomalies,
40+ setGranularity,
41+ compute,
42+ exportCSV,
43+ exportCohortCsv,
44+ exportCohortPdf,
45+ } = useAnalyticsStore ( ) ;
2746
2847 useEffect ( ( ) => {
2948 compute ( subscriptions ) ;
3049 } , [ subscriptions , compute ] ) ;
3150
51+ const handleSetGranularity = ( next : CohortGranularity ) => {
52+ setGranularity ( next ) ;
53+ compute ( subscriptions ) ;
54+ } ;
55+
3256 const handleExportCSV = async ( ) => {
3357 try {
3458 const csv = exportCSV ( subscriptions ) ;
@@ -38,6 +62,22 @@ const AnalyticsDashboard: React.FC = () => {
3862 }
3963 } ;
4064
65+ const handleExportCohortCsv = async ( ) => {
66+ try {
67+ await Share . share ( { message : exportCohortCsv ( ) , title : 'Cohort Report (CSV)' } ) ;
68+ } catch {
69+ Alert . alert ( 'Export Failed' , 'Could not export cohort report' ) ;
70+ }
71+ } ;
72+
73+ const handleExportCohortPdf = async ( ) => {
74+ try {
75+ await Share . share ( { message : exportCohortPdf ( ) , title : 'Cohort Report (PDF)' } ) ;
76+ } catch {
77+ Alert . alert ( 'Export Failed' , 'Could not export cohort report' ) ;
78+ }
79+ } ;
80+
4181 const currency = preferredCurrency ?? 'USD' ;
4282
4383 if ( ! report ) {
@@ -107,36 +147,104 @@ const AnalyticsDashboard: React.FC = () => {
107147
108148 < Card style = { styles . card } >
109149 < Text style = { styles . sectionTitle } > Revenue Trend (last 6 months)</ Text >
110- { report . revenueTrend . length === 0 ? (
150+ { revenueTrendWithAnomalies . length === 0 ? (
111151 < Text style = { styles . emptyText } > No trend data yet</ Text >
112152 ) : (
113- report . revenueTrend . map ( ( point , index ) => (
114- < View
115- key = { point . label }
116- style = { [
117- styles . statRow ,
118- index === report . revenueTrend . length - 1 && styles . lastRow ,
119- ] } >
120- < Text style = { styles . statLabel } > { point . label } </ Text >
121- < Text style = { styles . statValue } > { formatCurrency ( point . mrr , currency ) } < /Text >
153+ revenueTrendWithAnomalies . map ( ( point , index , arr ) => (
154+ < View key = { point . label } style = { [ styles . statRow , index === arr . length - 1 && styles . lastRow ] } >
155+ < Text style = { styles . statLabel } >
156+ { point . label }
157+ { point . isAnomaly ? ' ⚠️' : '' }
158+ </ Text >
159+ < Text style = { [ styles . statValue , point . isAnomaly && styles . anomalyValue ] } >
160+ { formatCurrency ( point . value , currency ) }
161+ </ Text >
122162 </ View >
123163 ) )
124164 ) }
165+ { revenueTrendWithAnomalies . some ( ( point ) => point . isAnomaly ) && (
166+ < Text style = { styles . anomalyNote } > ⚠️ flagged points are statistical outliers vs. the rest of the trend</ Text >
167+ ) }
125168 </ Card >
126169
127170 < Card style = { styles . card } >
128- < Text style = { styles . sectionTitle } > Cohorts</ Text >
129- { report . cohorts . length === 0 ? (
130- < Text style = { styles . emptyText } > No cohort data yet</ Text >
171+ < View style = { styles . rowBetween } >
172+ < Text style = { styles . sectionTitle } > Cohort Retention</ Text >
173+ < View style = { styles . granularityToggle } >
174+ { ( [ 'week' , 'month' ] as CohortGranularity [ ] ) . map ( ( option ) => (
175+ < TouchableOpacity
176+ key = { option }
177+ style = { [ styles . granularityButton , granularity === option && styles . granularityButtonActive ] }
178+ onPress = { ( ) => handleSetGranularity ( option ) }
179+ accessibilityRole = "button"
180+ accessibilityState = { { selected : granularity === option } } >
181+ < Text
182+ style = { [
183+ styles . granularityButtonText ,
184+ granularity === option && styles . granularityButtonTextActive ,
185+ ] } >
186+ { option === 'week' ? 'Week' : 'Month' }
187+ </ Text >
188+ </ TouchableOpacity >
189+ ) ) }
190+ </ View >
191+ </ View >
192+ < CohortChart buckets = { cohortBuckets } />
193+ { cohortBuckets . slice ( - 4 ) . map ( ( bucket , index , arr ) => (
194+ < View key = { bucket . cohortKey } style = { [ styles . statRow , index === arr . length - 1 && styles . lastRow ] } >
195+ < Text style = { styles . statLabel } > { bucket . cohortKey } </ Text >
196+ < Text style = { styles . statValue } >
197+ { bucket . size } signups · { ( bucket . retentionRate * 100 ) . toFixed ( 0 ) } % retained ·{ ' ' }
198+ { formatCurrency ( bucket . currentMrr , currency ) }
199+ </ Text >
200+ </ View >
201+ ) ) }
202+ </ Card >
203+
204+ < Card style = { styles . card } >
205+ < Text style = { styles . sectionTitle } > Retention Curve (Day 1 / 7 / 30 / 60 / 90)</ Text >
206+ < RetentionHeatmap points = { retentionCurve } />
207+ </ Card >
208+
209+ < Card style = { styles . card } >
210+ < Text style = { styles . sectionTitle } > Revenue vs. Logo Churn (last 30 days)</ Text >
211+ { ! churnBreakdown || churnBreakdown . isEmpty ? (
212+ < Text style = { styles . emptyText } > No subscribers active at the start of this period yet.</ Text >
131213 ) : (
132- report . cohorts . slice ( - 4 ) . map ( ( cohort , index , arr ) => (
133- < View
134- key = { cohort . cohort }
135- style = { [ styles . statRow , index === arr . length - 1 && styles . lastRow ] } >
136- < Text style = { styles . statLabel } > { cohort . cohort } </ Text >
214+ < >
215+ < View style = { styles . statRow } >
216+ < Text style = { styles . statLabel } > Logo churn (subscribers)</ Text >
217+ < Text style = { styles . statValue } >
218+ { ( churnBreakdown . logoChurnRate * 100 ) . toFixed ( 1 ) } % ({ churnBreakdown . churnedSubscribers } /
219+ { churnBreakdown . startingSubscribers } )
220+ </ Text >
221+ </ View >
222+ < View style = { [ styles . statRow , styles . lastRow ] } >
223+ < Text style = { styles . statLabel } > Revenue churn (MRR)</ Text >
137224 < Text style = { styles . statValue } >
138- { ( cohort . retentionRate * 100 ) . toFixed ( 0 ) } % retained ·{ ' ' }
139- { formatCurrency ( cohort . revenue , currency ) }
225+ { ( churnBreakdown . revenueChurnRate * 100 ) . toFixed ( 1 ) } % (
226+ { formatCurrency ( churnBreakdown . churnedMrr , currency ) } )
227+ </ Text >
228+ </ View >
229+ </ >
230+ ) }
231+ </ Card >
232+
233+ < Card style = { styles . card } >
234+ < Text style = { styles . sectionTitle } > Plan Migration</ Text >
235+ < SankeyDiagram flows = { planMigrationFlows } />
236+ </ Card >
237+
238+ < Card style = { styles . card } >
239+ < Text style = { styles . sectionTitle } > LTV by Acquisition Source</ Text >
240+ { ltvBySource . length === 0 ? (
241+ < Text style = { styles . emptyText } > No acquisition source data yet</ Text >
242+ ) : (
243+ ltvBySource . map ( ( row , index , arr ) => (
244+ < View key = { row . acquisitionChannel } style = { [ styles . statRow , index === arr . length - 1 && styles . lastRow ] } >
245+ < Text style = { styles . statLabel } > { row . acquisitionChannel } </ Text >
246+ < Text style = { styles . statValue } >
247+ { formatCurrency ( row . ltv , currency ) } LTV · { row . subscriberCount } subs
140248 </ Text >
141249 </ View >
142250 ) )
@@ -159,6 +267,8 @@ const AnalyticsDashboard: React.FC = () => {
159267
160268 < View style = { styles . exportContainer } >
161269 < Button title = "Export CSV" onPress = { handleExportCSV } variant = "secondary" />
270+ < Button title = "Export Cohort CSV" onPress = { handleExportCohortCsv } variant = "secondary" />
271+ < Button title = "Export Cohort PDF" onPress = { handleExportCohortPdf } variant = "secondary" />
162272 </ View >
163273 </ ScrollView >
164274 </ SafeAreaView >
@@ -194,8 +304,27 @@ function createStyles(colors: ReturnType<typeof useThemeColors>) {
194304 statLabel : { ...typography . body , color : colors . textSecondary } ,
195305 statValue : { ...typography . body , color : colors . text . primary , fontWeight : '600' } ,
196306 emptyText : { ...typography . body , color : colors . textSecondary , textAlign : 'center' } ,
197- exportContainer : { padding : spacing . lg , paddingTop : 0 , marginBottom : spacing . xl } ,
307+ exportContainer : { padding : spacing . lg , paddingTop : 0 , marginBottom : spacing . xl , gap : spacing . sm } ,
198308 loadingText : { ...typography . body , color : colors . textSecondary , padding : spacing . lg } ,
309+ rowBetween : {
310+ flexDirection : 'row' ,
311+ justifyContent : 'space-between' ,
312+ alignItems : 'center' ,
313+ marginBottom : spacing . md ,
314+ } ,
315+ granularityToggle : { flexDirection : 'row' , gap : spacing . xs } ,
316+ granularityButton : {
317+ paddingVertical : spacing . xs ,
318+ paddingHorizontal : spacing . sm ,
319+ borderRadius : 6 ,
320+ borderWidth : 1 ,
321+ borderColor : colors . border . default ,
322+ } ,
323+ granularityButtonActive : { backgroundColor : colors . primary , borderColor : colors . primary } ,
324+ granularityButtonText : { ...typography . caption , color : colors . textSecondary } ,
325+ granularityButtonTextActive : { color : colors . text . inverse , fontWeight : '600' } ,
326+ anomalyValue : { color : colors . status . warning } ,
327+ anomalyNote : { ...typography . caption , color : colors . status . warning , marginTop : spacing . xs } ,
199328 } ) ;
200329}
201330
0 commit comments