From ecbc2389d8ac40d59f60a4f72bcaa49fd99ada4f Mon Sep 17 00:00:00 2001 From: senke Date: Fri, 20 Feb 2026 16:57:58 +0100 Subject: [PATCH] feat(analytics): add creator stats endpoint and UI (H1) --- .../analytics-page/AnalyticsViewKpiGrid.tsx | 14 ++- .../analytics/pages/analytics-page/types.ts | 2 + .../pages/analytics-page/useAnalyticsView.ts | 11 +- apps/web/src/mocks/handlers-misc.ts | 17 +++ apps/web/src/services/analyticsService.ts | 25 ++++ .../internal/api/routes_analytics.go | 1 + .../internal/core/analytics/handler.go | 110 ++++++++++++++++++ 7 files changed, 176 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewKpiGrid.tsx b/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewKpiGrid.tsx index f564614b3..bcefcdb99 100644 --- a/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewKpiGrid.tsx +++ b/apps/web/src/features/analytics/pages/analytics-page/AnalyticsViewKpiGrid.tsx @@ -1,5 +1,5 @@ import { StatCard } from '@/components/dashboard/StatCard'; -import { Play, DollarSign, Users, TrendingUp } from 'lucide-react'; +import { Play, DollarSign, Users, TrendingUp, Target } from 'lucide-react'; import type { GlobalStats } from './types'; interface AnalyticsViewKpiGridProps { @@ -8,7 +8,7 @@ interface AnalyticsViewKpiGridProps { export function AnalyticsViewKpiGrid({ stats }: AnalyticsViewKpiGridProps) { return ( -
+
+ } + color="green" + />
); } diff --git a/apps/web/src/features/analytics/pages/analytics-page/types.ts b/apps/web/src/features/analytics/pages/analytics-page/types.ts index f379ec2ef..c0b66c220 100644 --- a/apps/web/src/features/analytics/pages/analytics-page/types.ts +++ b/apps/web/src/features/analytics/pages/analytics-page/types.ts @@ -9,6 +9,8 @@ export interface GlobalStats { total_revenue?: number; followers?: number; profile_views?: number; + average_completion_rate?: number; + unique_listeners?: number; trends?: { plays?: number; revenue?: number; diff --git a/apps/web/src/features/analytics/pages/analytics-page/useAnalyticsView.ts b/apps/web/src/features/analytics/pages/analytics-page/useAnalyticsView.ts index bd6a68877..32d0fa1be 100644 --- a/apps/web/src/features/analytics/pages/analytics-page/useAnalyticsView.ts +++ b/apps/web/src/features/analytics/pages/analytics-page/useAnalyticsView.ts @@ -21,13 +21,20 @@ export function useAnalyticsView(dateRangeInitial: DateRangeKey = '30d') { setLoading(true); setError(null); try { - const [global, tracks, sources, devices] = await Promise.all([ + const days = parseInt(dateRange.replace('d', ''), 10) || 30; + const [global, creatorStats, tracks, sources, devices] = await Promise.all([ analyticsService.getGlobalStats(dateRange), + analyticsService.getCreatorStats({ days }), analyticsService.getTopTracks(dateRange), analyticsService.getTrafficSources(), analyticsService.getDeviceBreakdown(), ]); - setStats(global as GlobalStats); + const merged: GlobalStats = { + ...(global as GlobalStats), + average_completion_rate: (creatorStats as { average_completion_rate?: number })?.average_completion_rate, + unique_listeners: (creatorStats as { unique_listeners?: number })?.unique_listeners, + }; + setStats(merged); setTopTracks((tracks as TopTrackRow[]) ?? []); setTrafficSources((sources as TrafficSource[]) ?? []); setDeviceStats((devices as DeviceStats) ?? { mobile: 0, desktop: 0 }); diff --git a/apps/web/src/mocks/handlers-misc.ts b/apps/web/src/mocks/handlers-misc.ts index ff7577f86..1a5aae05d 100644 --- a/apps/web/src/mocks/handlers-misc.ts +++ b/apps/web/src/mocks/handlers-misc.ts @@ -85,6 +85,23 @@ function createQueueHandlers() { } export const handlersMisc = [ + http.get('*/api/v1/analytics/creator/stats', () => { + return HttpResponse.json({ + success: true, + data: { + total_plays: 420, + unique_listeners: 156, + average_completion_rate: 72.5, + plays_by_day: [12, 18, 15, 22, 25, 30, 28], + period: { + start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + end_date: new Date().toISOString(), + days: 30, + }, + }, + }); + }), + http.get('*/api/v1/analytics', () => { return HttpResponse.json({ success: true, diff --git a/apps/web/src/services/analyticsService.ts b/apps/web/src/services/analyticsService.ts index b9729a8d8..d6ee8b8b5 100644 --- a/apps/web/src/services/analyticsService.ts +++ b/apps/web/src/services/analyticsService.ts @@ -98,6 +98,31 @@ export const analyticsService = { } }, + 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 | 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'); diff --git a/veza-backend-api/internal/api/routes_analytics.go b/veza-backend-api/internal/api/routes_analytics.go index 1d0c9daae..0a07f2090 100644 --- a/veza-backend-api/internal/api/routes_analytics.go +++ b/veza-backend-api/internal/api/routes_analytics.go @@ -26,6 +26,7 @@ func (r *APIRouter) setupAnalyticsRoutes(router *gin.RouterGroup) { r.applyCSRFProtection(analytics) } { + analytics.GET("/creator/stats", analyticsHandler.GetCreatorStats) analytics.GET("", analyticsHandler.GetAnalytics) analytics.POST("/events", analyticsHandler.RecordEvent) analytics.GET("/tracks/:id", analyticsHandler.GetTrackAnalyticsDashboard) diff --git a/veza-backend-api/internal/core/analytics/handler.go b/veza-backend-api/internal/core/analytics/handler.go index 0de89843d..56fae5ff2 100644 --- a/veza-backend-api/internal/core/analytics/handler.go +++ b/veza-backend-api/internal/core/analytics/handler.go @@ -164,6 +164,116 @@ func (h *Handler) GetDeviceBreakdown(c *gin.Context) { }) } +// GetCreatorStats handles GET /api/v1/analytics/creator/stats +// Aggregates playback_analytics for the authenticated creator's tracks +func (h *Handler) GetCreatorStats(c *gin.Context) { + userIDInterface, exists := c.Get("user_id") + if !exists { + handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeUnauthorized, "authentication required")) + return + } + userID, ok := userIDInterface.(uuid.UUID) + if !ok { + handlers.RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "invalid user id")) + return + } + + analyticsSvc, ok := h.analyticsService.(AnalyticsServiceWithDB) + if !ok { + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "total_plays": int64(0), + "unique_listeners": int64(0), + "average_completion_rate": float64(0), + "plays_by_day": []int64{}, + "period": gin.H{"start_date": "", "end_date": "", "days": 0}, + }) + return + } + + daysStr := c.DefaultQuery("days", "30") + days, err := strconv.Atoi(daysStr) + if err != nil || days < 1 { + days = 30 + } + + var startDate, endDate *time.Time + if startDateStr := c.Query("start_date"); startDateStr != "" { + if parsed, err := time.Parse(time.RFC3339, startDateStr); err == nil { + startDate = &parsed + } + } + if endDateStr := c.Query("end_date"); endDateStr != "" { + if parsed, err := time.Parse(time.RFC3339, endDateStr); err == nil { + endDate = &parsed + } + } + if startDate == nil || endDate == nil { + now := time.Now() + if endDate == nil { + endDate = &now + } + if startDate == nil { + calculatedStart := endDate.AddDate(0, 0, -days) + startDate = &calculatedStart + } + } + + ctx := c.Request.Context() + db := analyticsSvc.GetDB() + + var totalPlays int64 + db.WithContext(ctx).Table("playback_analytics pa"). + Joins("JOIN tracks t ON t.id = pa.track_id"). + Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate). + Count(&totalPlays) + + var uniqueListeners int64 + db.WithContext(ctx).Table("playback_analytics pa"). + Joins("JOIN tracks t ON t.id = pa.track_id"). + Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate). + Select("COUNT(DISTINCT pa.user_id)"). + Scan(&uniqueListeners) + + var avgCompletion float64 + db.WithContext(ctx).Table("playback_analytics pa"). + Joins("JOIN tracks t ON t.id = pa.track_id"). + Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate). + Select("COALESCE(AVG(pa.completion_rate), 0)"). + Scan(&avgCompletion) + + var dayCounts []struct { + Day string `gorm:"column:day"` + Count int64 `gorm:"column:cnt"` + } + db.WithContext(ctx).Table("playback_analytics pa"). + Joins("JOIN tracks t ON t.id = pa.track_id"). + Select("DATE(pa.started_at) as day, COUNT(*) as cnt"). + Where("t.creator_id = ? AND pa.started_at >= ? AND pa.started_at <= ?", userID, startDate, endDate). + Group("DATE(pa.started_at)"). + Order("day ASC"). + Find(&dayCounts) + + playsByDay := make([]int64, len(dayCounts)) + for i, d := range dayCounts { + playsByDay[i] = d.Count + } + if len(playsByDay) == 0 { + playsByDay = []int64{0} + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "total_plays": totalPlays, + "unique_listeners": uniqueListeners, + "average_completion_rate": avgCompletion, + "plays_by_day": playsByDay, + "period": gin.H{ + "start_date": startDate.Format(time.RFC3339), + "end_date": endDate.Format(time.RFC3339), + "days": days, + }, + }) +} + // GetAnalytics handles GET /api/v1/analytics func (h *Handler) GetAnalytics(c *gin.Context) { userIDInterface, exists := c.Get("user_id")