- AnalyticsViewHeatmap: track listening heatmap visualization (F396) - AnalyticsViewComparison: period comparison with % changes (F397) - AnalyticsViewMarketplace: product conversion rates and revenue (F398) - AnalyticsViewAlerts: opt-in metric alerts with CRUD (F399) - Updated analytics service with new API methods - Extended tab navigation with 3 new tabs - All components have ARIA labels and keyboard navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
472 lines
19 KiB
TypeScript
472 lines
19 KiB
TypeScript
/**
|
|
* Monitor 3: User Analytics Service
|
|
* Tracks feature usage and user interactions
|
|
*/
|
|
|
|
import { apiClient } from './api/client';
|
|
import { logger } from '@/utils/logger';
|
|
|
|
export const analyticsService = {
|
|
/**
|
|
* Monitor 3: Record a user analytics event
|
|
* Tracks feature usage and user interactions
|
|
* @param eventName - Name of the event (e.g., 'feature_used', 'button_clicked')
|
|
* @param payload - Optional event data (e.g., { feature: 'search', query: '...' })
|
|
*/
|
|
recordEvent: async (eventName: string, payload?: Record<string, unknown>) => {
|
|
try {
|
|
// Send event to backend analytics endpoint
|
|
await apiClient.post('/analytics/events', {
|
|
event_name: eventName,
|
|
payload: payload || {},
|
|
});
|
|
|
|
// Log in development for debugging
|
|
if (import.meta.env.DEV) {
|
|
logger.debug('[Analytics] Event recorded', {
|
|
event_name: eventName,
|
|
payload,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
// Don't throw errors for analytics - fail silently to avoid disrupting user experience
|
|
if (import.meta.env.DEV) {
|
|
logger.warn('[Analytics] Failed to record event', {
|
|
event_name: eventName,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
},
|
|
|
|
getGlobalStats: async (range: string = '30d') => {
|
|
try {
|
|
const response = await apiClient.get<{ data?: Record<string, unknown> }>('/analytics', {
|
|
params: { days: range.replace('d', '') }
|
|
});
|
|
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown> | undefined;
|
|
if (!data || typeof data !== 'object') {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
total_tracks: (data.total_tracks as number | undefined) ?? 0,
|
|
total_plays: (data.total_plays as number | undefined) ?? 0,
|
|
total_revenue: (data.total_revenue as number | undefined) ?? 0,
|
|
followers: (data.followers as number | undefined) ?? 0,
|
|
profile_views: (data.profile_views as number | undefined) ?? 0,
|
|
trends: (data.trends as Record<string, number> | undefined) ?? { plays: 0, revenue: 0, followers: 0, views: 0 },
|
|
sparklines: (data.sparklines as Record<string, number[]> | undefined) ?? { plays: [0], revenue: [0], followers: [0], views: [0] },
|
|
};
|
|
} catch (error) {
|
|
logger.error('[Analytics] Failed to fetch global stats', { error });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getTopTracks: async (range: string = '30d') => {
|
|
try {
|
|
const response = await apiClient.get<{ data?: { tracks?: { top_tracks?: unknown[] } } }>('/analytics', {
|
|
params: { days: range.replace('d', '') }
|
|
});
|
|
|
|
const data = response.data?.data ?? response.data;
|
|
const topTracks = (data as Record<string, unknown>)?.tracks as { top_tracks?: Array<{ id?: string; title?: string; plays?: number; play_count?: number; change?: number; revenue?: number }> } | undefined;
|
|
const tracks = topTracks?.top_tracks ?? [];
|
|
|
|
return tracks.map((t) => ({
|
|
id: t.id ?? '',
|
|
title: t.title ?? '',
|
|
plays: t.plays ?? t.play_count ?? 0,
|
|
change: t.change ?? 0,
|
|
revenue: t.revenue ?? 0,
|
|
}));
|
|
} catch (error) {
|
|
logger.error('[Analytics] Failed to fetch top tracks', { error });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getTrafficSources: async () => {
|
|
try {
|
|
const response = await apiClient.get<{ sources?: unknown[] }>('/analytics/traffic-sources');
|
|
return (response.data?.sources ?? []) as unknown[];
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch traffic sources', { error });
|
|
return [];
|
|
}
|
|
},
|
|
|
|
getCreatorCharts: async (params?: { days?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const qs = searchParams.toString();
|
|
const url = `/analytics/creator/charts${qs ? `?${qs}` : ''}`;
|
|
const response = await apiClient.get<{
|
|
data?: {
|
|
plays_per_day?: Array<{ date: string; count: number }>;
|
|
top_tracks?: Array<{ track_id: string; title: string; plays: number }>;
|
|
period?: { start_date?: string; end_date?: string; days?: number };
|
|
};
|
|
}>(url);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown> | undefined;
|
|
return data ?? {};
|
|
} catch (error) {
|
|
logger.error('[Analytics] Failed to fetch creator charts', { error });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getCreatorExport: async (format: 'csv' | 'json', params?: { days?: number }) => {
|
|
const searchParams = new URLSearchParams({ format });
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const url = `/analytics/creator/export?${searchParams.toString()}`;
|
|
const response = await apiClient.get(url, { responseType: 'blob' });
|
|
return response.data as Blob;
|
|
},
|
|
|
|
getCreatorStats: async (params?: { start_date?: string; end_date?: string; days?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
if (params?.start_date) searchParams.set('start_date', params.start_date);
|
|
if (params?.end_date) searchParams.set('end_date', params.end_date);
|
|
const qs = searchParams.toString();
|
|
const url = `/analytics/creator/stats${qs ? `?${qs}` : ''}`;
|
|
const response = await apiClient.get<{
|
|
data?: {
|
|
total_plays?: number;
|
|
unique_listeners?: number;
|
|
average_completion_rate?: number;
|
|
plays_by_day?: number[];
|
|
period?: { start_date?: string; end_date?: string; days?: number };
|
|
};
|
|
}>(url);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown> | undefined;
|
|
return data ?? {};
|
|
} catch (error) {
|
|
logger.error('[Analytics] Failed to fetch creator stats', { error });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getDeviceBreakdown: async () => {
|
|
try {
|
|
const response = await apiClient.get<{ mobile?: number; desktop?: number }>('/analytics/device-breakdown');
|
|
return {
|
|
mobile: response.data?.mobile ?? 0,
|
|
desktop: response.data?.desktop ?? 0,
|
|
};
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch device breakdown', { error });
|
|
return { mobile: 0, desktop: 0 };
|
|
}
|
|
},
|
|
|
|
// v0.11.0: Creator Analytics (F381-F395)
|
|
|
|
getCreatorDashboard: async (params?: { days?: number; start_date?: string; end_date?: string }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
if (params?.start_date) searchParams.set('start_date', params.start_date);
|
|
if (params?.end_date) searchParams.set('end_date', params.end_date);
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/dashboard${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return data?.dashboard ?? data ?? {};
|
|
} catch (error) {
|
|
logger.error('[Analytics] Failed to fetch creator dashboard', { error });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getPlayEvolution: async (params?: { days?: number; interval?: string }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
if (params?.interval) searchParams.set('interval', params.interval);
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/plays${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.data ?? []) as Array<{
|
|
date: string;
|
|
total_plays: number;
|
|
complete_listens: number;
|
|
unique_listeners: number;
|
|
total_play_time: number;
|
|
avg_completion: number;
|
|
}>;
|
|
} catch (error) {
|
|
logger.error('[Analytics] Failed to fetch play evolution', { error });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getCreatorSales: async (params?: { days?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/sales${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.sales ?? data ?? {}) as {
|
|
total_revenue?: number;
|
|
total_sales?: number;
|
|
revenue_by_period?: Array<{ date: string; revenue: number; sales: number }>;
|
|
top_selling_tracks?: Array<{ track_id: string; title: string; revenue: number; sales: number }>;
|
|
};
|
|
} catch (error) {
|
|
logger.error('[Analytics] Failed to fetch creator sales', { error });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getDiscoverySources: async (params?: { days?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/discovery${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.sources ?? []) as Array<{ source: string; count: number; percentage: number }>;
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch discovery sources', { error });
|
|
return [];
|
|
}
|
|
},
|
|
|
|
getGeographicBreakdown: async (params?: { days?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/geographic${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.geographic ?? []) as Array<{
|
|
country_code: string;
|
|
play_count: number;
|
|
unique_listeners: number;
|
|
percentage: number;
|
|
}>;
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch geographic breakdown', { error });
|
|
return [];
|
|
}
|
|
},
|
|
|
|
getAudienceProfile: async (params?: { days?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/audience${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.audience ?? data ?? {}) as {
|
|
total_listeners?: number;
|
|
listeners_by_genre?: Array<{ genre: string; count: number; percentage: number }>;
|
|
top_listening_times?: Array<{ hour: number; play_count: number }>;
|
|
};
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch audience profile', { error });
|
|
return {};
|
|
}
|
|
},
|
|
|
|
getLiveMetrics: async (streamId: string) => {
|
|
try {
|
|
const response = await apiClient.get(`/creator/analytics/live/${streamId}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.metrics ?? data ?? {}) as {
|
|
stream_id?: string;
|
|
current_viewers?: number;
|
|
peak_viewers?: number;
|
|
is_live?: boolean;
|
|
duration_seconds?: number;
|
|
};
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch live metrics', { error });
|
|
return {};
|
|
}
|
|
},
|
|
|
|
getCreatorTrackStats: async (params?: { days?: number; page?: number; limit?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
if (params?.page != null) searchParams.set('page', String(params.page));
|
|
if (params?.limit != null) searchParams.set('limit', String(params.limit));
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/tracks${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return {
|
|
tracks: (data?.data ?? []) as Array<{
|
|
track_id: string;
|
|
title: string;
|
|
total_plays: number;
|
|
complete_listens: number;
|
|
unique_listeners: number;
|
|
avg_play_duration: number;
|
|
avg_completion: number;
|
|
}>,
|
|
pagination: (data?.pagination ?? {}) as {
|
|
page?: number;
|
|
limit?: number;
|
|
total?: number;
|
|
total_pages?: number;
|
|
},
|
|
};
|
|
} catch (error) {
|
|
logger.error('[Analytics] Failed to fetch creator track stats', { error });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
exportCreatorAnalytics: async (type: 'plays' | 'sales', params?: { days?: number }) => {
|
|
const searchParams = new URLSearchParams({ type });
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const response = await apiClient.get(`/creator/analytics/export?${searchParams.toString()}`, {
|
|
responseType: 'blob',
|
|
});
|
|
return response.data as Blob;
|
|
},
|
|
|
|
// v0.11.1: Advanced Analytics (F396-F399)
|
|
|
|
getTrackHeatmap: async (trackId: string, params?: { days?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/heatmap/${trackId}${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.heatmap ?? {}) as {
|
|
track_id?: string;
|
|
total_segments?: number;
|
|
segment_duration_ms?: number;
|
|
segments?: Array<{
|
|
segment_index: number;
|
|
segment_start_ms: number;
|
|
segment_end_ms: number;
|
|
listen_count: number;
|
|
drop_off_count: number;
|
|
replay_count: number;
|
|
intensity: number;
|
|
}>;
|
|
max_listens?: number;
|
|
avg_drop_off?: number;
|
|
};
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch track heatmap', { error });
|
|
return {};
|
|
}
|
|
},
|
|
|
|
comparePeriods: async (params?: { preset?: string; current_start?: string; current_end?: string; previous_start?: string; previous_end?: string }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.preset) searchParams.set('preset', params.preset);
|
|
if (params?.current_start) searchParams.set('current_start', params.current_start);
|
|
if (params?.current_end) searchParams.set('current_end', params.current_end);
|
|
if (params?.previous_start) searchParams.set('previous_start', params.previous_start);
|
|
if (params?.previous_end) searchParams.set('previous_end', params.previous_end);
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/compare${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.comparison ?? {}) as {
|
|
current_period?: {
|
|
start_date: string; end_date: string;
|
|
total_plays: number; unique_listeners: number; complete_listens: number;
|
|
total_play_time: number; avg_completion: number; total_revenue: number; new_followers: number;
|
|
};
|
|
previous_period?: {
|
|
start_date: string; end_date: string;
|
|
total_plays: number; unique_listeners: number; complete_listens: number;
|
|
total_play_time: number; avg_completion: number; total_revenue: number; new_followers: number;
|
|
};
|
|
changes?: {
|
|
plays_change: number; listeners_change: number; completion_change: number;
|
|
revenue_change: number; followers_change: number; play_time_change: number;
|
|
};
|
|
};
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to compare periods', { error });
|
|
return {};
|
|
}
|
|
},
|
|
|
|
getMarketplaceAnalytics: async (params?: { days?: number }) => {
|
|
try {
|
|
const searchParams = new URLSearchParams();
|
|
if (params?.days != null) searchParams.set('days', String(params.days));
|
|
const qs = searchParams.toString();
|
|
const response = await apiClient.get(`/creator/analytics/marketplace${qs ? `?${qs}` : ''}`);
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.marketplace ?? {}) as {
|
|
total_views?: number;
|
|
total_sales?: number;
|
|
total_revenue?: number;
|
|
overall_conversion?: number;
|
|
platform_commission?: number;
|
|
net_revenue?: number;
|
|
products?: Array<{
|
|
product_id: string; name: string; product_type: string;
|
|
views: number; sales: number; revenue: number; conversion_rate: number;
|
|
}>;
|
|
revenue_timeline?: Array<{ date: string; revenue: number; sales: number; views: number }>;
|
|
};
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch marketplace analytics', { error });
|
|
return {};
|
|
}
|
|
},
|
|
|
|
getMetricAlerts: async () => {
|
|
try {
|
|
const response = await apiClient.get('/creator/analytics/alerts');
|
|
const data = (response.data?.data ?? response.data) as Record<string, unknown>;
|
|
return (data?.alerts ?? {}) as {
|
|
preferences?: Array<{ metric_type: string; enabled: boolean }>;
|
|
alerts?: Array<{
|
|
id: string; metric_type: string; threshold: number;
|
|
is_triggered: boolean; triggered_at?: string; created_at: string;
|
|
}>;
|
|
pending?: Array<{
|
|
id: string; metric_type: string; threshold: number;
|
|
is_triggered: boolean; triggered_at?: string; created_at: string;
|
|
}>;
|
|
};
|
|
} catch (error) {
|
|
logger.warn('[Analytics] Failed to fetch metric alerts', { error });
|
|
return {};
|
|
}
|
|
},
|
|
|
|
createMetricAlert: async (metricType: string, threshold: number) => {
|
|
const response = await apiClient.post('/creator/analytics/alerts', {
|
|
metric_type: metricType,
|
|
threshold,
|
|
});
|
|
return response.data;
|
|
},
|
|
|
|
updateAlertPreference: async (metricType: string, enabled: boolean) => {
|
|
const response = await apiClient.put('/creator/analytics/alerts/preferences', {
|
|
metric_type: metricType,
|
|
enabled,
|
|
});
|
|
return response.data;
|
|
},
|
|
|
|
deleteMetricAlert: async (alertId: string) => {
|
|
const response = await apiClient.delete(`/creator/analytics/alerts/${alertId}`);
|
|
return response.data;
|
|
},
|
|
|
|
checkMetricAlerts: async () => {
|
|
const response = await apiClient.post('/creator/analytics/alerts/check');
|
|
return response.data;
|
|
},
|
|
};
|