feat(analytics): add creator stats endpoint and UI (H1)
This commit is contained in:
parent
26397bbceb
commit
ecbc2389d8
7 changed files with 176 additions and 4 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue