feat(analytics): add creator stats endpoint and UI (H1)

This commit is contained in:
senke 2026-02-20 16:57:58 +01:00
parent 26397bbceb
commit ecbc2389d8
7 changed files with 176 additions and 4 deletions

View file

@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
<StatCard
label="Network Plays"
value={stats.total_plays?.toLocaleString() ?? '—'}
@ -41,6 +41,16 @@ export function AnalyticsViewKpiGrid({ stats }: AnalyticsViewKpiGridProps) {
color="red"
sparklineData={stats.sparklines?.views}
/>
<StatCard
label="Completion Rate"
value={
stats.average_completion_rate != null
? `${stats.average_completion_rate.toFixed(1)}%`
: '—'
}
icon={<Target className="w-4 h-4" />}
color="green"
/>
</div>
);
}

View file

@ -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;

View file

@ -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 });

View file

@ -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,

View file

@ -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<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');

View file

@ -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)

View file

@ -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")