Repository: sivchari/gotwtr
Pull request #222 (2025-07-16 04:25:10)

feat: implement analytics endpoints and utilities
Add comprehensive analytics functionality including:
- Analytics types for Spaces, Lists, Media, and engagement metrics
- Utility functions for calculating engagement rates and performance metrics
- Client interface methods for analytics operations
- Comprehensive test coverage with proper floating point comparison
- Support for comparative analytics and top-performing content identification


Patch:
diff --git a/analytics.go b/analytics.go
new file mode 100644
index 0000000..c9e7966
--- /dev/null
+++ b/analytics.go
@@ -0,0 +1,166 @@
+package gotwtr
+
+import "time"
+
+// SpaceAnalytics represents extended analytics for Spaces
+type SpaceAnalytics struct {
+	LiveListenerCount    int    `json:"live_listener_count,omitempty"`
+	TotalListenerCount   int    `json:"total_listener_count,omitempty"`
+	SpeakerRequestCount  int    `json:"speaker_request_count,omitempty"`
+	RecordingDownloads   int    `json:"recording_downloads,omitempty"`
+	ShareCount           int    `json:"share_count,omitempty"`
+	Duration             string `json:"duration,omitempty"`
+}
+
+// ListAnalytics represents analytics for Lists
+type ListAnalytics struct {
+	ViewCount        int     `json:"view_count,omitempty"`
+	MemberGrowthRate float64 `json:"member_growth_rate,omitempty"`
+	TweetCount       int     `json:"tweet_count,omitempty"`
+	EngagementRate   float64 `json:"engagement_rate,omitempty"`
+}
+
+// MediaAnalytics represents analytics for Media objects
+type MediaAnalytics struct {
+	ViewCount      int `json:"view_count,omitempty"`
+	PlayCount      int `json:"play_count,omitempty"`
+	DownloadCount  int `json:"download_count,omitempty"`
+	ShareCount     int `json:"share_count,omitempty"`
+	CompletionRate int `json:"completion_rate,omitempty"` // Percentage for videos
+}
+
+// EngagementMetrics provides calculated engagement metrics
+type EngagementMetrics struct {
+	EngagementRate    float64 `json:"engagement_rate"`
+	LikeRate         float64 `json:"like_rate"`
+	RetweetRate      float64 `json:"retweet_rate"`
+	ReplyRate        float64 `json:"reply_rate"`
+	QuoteRate        float64 `json:"quote_rate"`
+	BookmarkRate     float64 `json:"bookmark_rate"`
+	ClickThroughRate float64 `json:"click_through_rate"`
+}
+
+// AnalyticsTimeframe represents time-based analytics filters
+type AnalyticsTimeframe struct {
+	StartTime time.Time `json:"start_time"`
+	EndTime   time.Time `json:"end_time"`
+	Granularity string  `json:"granularity"` // day, hour, minute
+}
+
+// AnalyticsComparison represents comparative analytics data
+type AnalyticsComparison struct {
+	Current  interface{} `json:"current"`
+	Previous interface{} `json:"previous"`
+	Change   float64     `json:"change_percentage"`
+}
+
+// TweetAnalyticsSummary provides aggregated tweet analytics
+type TweetAnalyticsSummary struct {
+	TotalTweets      int                `json:"total_tweets"`
+	TotalImpressions int                `json:"total_impressions"`
+	TotalEngagements int                `json:"total_engagements"`
+	AverageMetrics   *TweetMetrics      `json:"average_metrics"`
+	TopPerforming    []*Tweet          `json:"top_performing"`
+	Timeframe        *AnalyticsTimeframe `json:"timeframe"`
+}
+
+// UserAnalyticsSummary provides aggregated user analytics
+type UserAnalyticsSummary struct {
+	FollowerGrowth   *AnalyticsComparison `json:"follower_growth"`
+	EngagementTrends *EngagementMetrics   `json:"engagement_trends"`
+	TopTweets        []*Tweet            `json:"top_tweets"`
+	Timeframe        *AnalyticsTimeframe  `json:"timeframe"`
+}
+
+// Analytics utility functions
+
+// CalculateEngagementRate calculates engagement rate from public metrics
+func CalculateEngagementRate(metrics *TweetMetrics) float64 {
+	if metrics == nil || metrics.ImpressionCount == 0 {
+		return 0.0
+	}
+	
+	totalEngagements := metrics.LikeCount + metrics.RetweetCount + 
+					   metrics.ReplyCount + metrics.QuoteCount + metrics.BookmarkCount
+	
+	return (float64(totalEngagements) / float64(metrics.ImpressionCount)) * 100
+}
+
+// CalculateClickThroughRate calculates CTR from metrics
+func CalculateClickThroughRate(metrics *TweetMetrics) float64 {
+	if metrics == nil || metrics.ImpressionCount == 0 {
+		return 0.0
+	}
+	
+	totalClicks := metrics.URLLinkClicks + metrics.UserProfileClicks
+	
+	return (float64(totalClicks) / float64(metrics.ImpressionCount)) * 100
+}
+
+// CalculateEngagementMetrics calculates detailed engagement metrics
+func CalculateEngagementMetrics(metrics *TweetMetrics) *EngagementMetrics {
+	if metrics == nil || metrics.ImpressionCount == 0 {
+		return &EngagementMetrics{}
+	}
+	
+	impressions := float64(metrics.ImpressionCount)
+	
+	return &EngagementMetrics{
+		EngagementRate:   CalculateEngagementRate(metrics),
+		LikeRate:         (float64(metrics.LikeCount) / impressions) * 100,
+		RetweetRate:      (float64(metrics.RetweetCount) / impressions) * 100,
+		ReplyRate:        (float64(metrics.ReplyCount) / impressions) * 100,
+		QuoteRate:        (float64(metrics.QuoteCount) / impressions) * 100,
+		BookmarkRate:     (float64(metrics.BookmarkCount) / impressions) * 100,
+		ClickThroughRate: CalculateClickThroughRate(metrics),
+	}
+}
+
+// CompareMetrics compares two sets of metrics and returns change percentage
+func CompareMetrics(current, previous *TweetMetrics) *AnalyticsComparison {
+	if current == nil || previous == nil {
+		return nil
+	}
+	
+	currentEngagement := CalculateEngagementRate(current)
+	previousEngagement := CalculateEngagementRate(previous)
+	
+	var change float64
+	if previousEngagement != 0 {
+		change = ((currentEngagement - previousEngagement) / previousEngagement) * 100
+	}
+	
+	return &AnalyticsComparison{
+		Current:  currentEngagement,
+		Previous: previousEngagement,
+		Change:   change,
+	}
+}
+
+// GetTopPerformingTweets sorts tweets by engagement rate
+func GetTopPerformingTweets(tweets []*Tweet, limit int) []*Tweet {
+	if len(tweets) == 0 {
+		return []*Tweet{}
+	}
+	
+	// Create a copy to avoid modifying the original slice
+	sortedTweets := make([]*Tweet, len(tweets))
+	copy(sortedTweets, tweets)
+	
+	// Simple sort by engagement rate (this could be optimized with proper sorting)
+	for i := 0; i < len(sortedTweets)-1; i++ {
+		for j := i + 1; j < len(sortedTweets); j++ {
+			rate1 := CalculateEngagementRate(sortedTweets[i].PublicMetrics)
+			rate2 := CalculateEngagementRate(sortedTweets[j].PublicMetrics)
+			if rate2 > rate1 {
+				sortedTweets[i], sortedTweets[j] = sortedTweets[j], sortedTweets[i]
+			}
+		}
+	}
+	
+	if limit > 0 && limit < len(sortedTweets) {
+		return sortedTweets[:limit]
+	}
+	
+	return sortedTweets
+}
\ No newline at end of file
diff --git a/client.go b/client.go
index 16101fd..cfcb753 100644
--- a/client.go
+++ b/client.go
@@ -166,6 +166,14 @@ type MediaUpload interface {
 	CheckUploadStatus(ctx context.Context, req *MediaUploadStatusRequest) (*MediaUploadResponse, error)
 }
 
+type Analytics interface {
+	// Analytics Utilities
+	CalculateEngagementRate(metrics *TweetMetrics) float64
+	CalculateEngagementMetrics(metrics *TweetMetrics) *EngagementMetrics
+	GetTopPerformingTweets(tweets []*Tweet, limit int) []*Tweet
+	CompareMetrics(current, previous *TweetMetrics) *AnalyticsComparison
+}
+
 // Twtr is a main interface for all Twitter API calls.
 type Twtr interface {
 	OAuth
@@ -178,6 +186,7 @@ type Twtr interface {
 	CommunityNotes
 	Trends
 	MediaUpload
+	Analytics
 }
 
 type client struct {
@@ -690,3 +699,23 @@ func (c *Client) PostDMBlocking(ctx context.Context, userID string, targetUserID
 func (c *Client) UndoDMBlocking(ctx context.Context, userID string, targetUserID string) (*UndoDMBlockingResponse, error) {
 	return undoDMBlocking(ctx, c.client, userID, targetUserID)
 }
+
+// CalculateEngagementRate calculates engagement rate from public metrics.
+func (c *Client) CalculateEngagementRate(metrics *TweetMetrics) float64 {
+	return CalculateEngagementRate(metrics)
+}
+
+// CalculateEngagementMetrics calculates detailed engagement metrics.
+func (c *Client) CalculateEngagementMetrics(metrics *TweetMetrics) *EngagementMetrics {
+	return CalculateEngagementMetrics(metrics)
+}
+
+// GetTopPerformingTweets sorts tweets by engagement rate and returns top performers.
+func (c *Client) GetTopPerformingTweets(tweets []*Tweet, limit int) []*Tweet {
+	return GetTopPerformingTweets(tweets, limit)
+}
+
+// CompareMetrics compares two sets of metrics and returns change percentage.
+func (c *Client) CompareMetrics(current, previous *TweetMetrics) *AnalyticsComparison {
+	return CompareMetrics(current, previous)
+}


Test patch:
diff --git a/analytics_test.go b/analytics_test.go
new file mode 100644
index 0000000..3b7681c
--- /dev/null
+++ b/analytics_test.go
@@ -0,0 +1,354 @@
+package gotwtr_test
+
+import (
+	"math"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	"github.com/sivchari/gotwtr"
+)
+
+func TestCalculateEngagementRate(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		metrics *gotwtr.TweetMetrics
+	}
+	tests := []struct {
+		name string
+		args args
+		want float64
+	}{
+		{
+			name: "normal engagement calculation",
+			args: args{
+				metrics: &gotwtr.TweetMetrics{
+					ImpressionCount: 1000,
+					LikeCount:       50,
+					RetweetCount:    20,
+					ReplyCount:      10,
+					QuoteCount:      5,
+					BookmarkCount:   3,
+				},
+			},
+			want: 8.8, // (50+20+10+5+3)/1000 * 100 = 8.8%
+		},
+		{
+			name: "zero impressions",
+			args: args{
+				metrics: &gotwtr.TweetMetrics{
+					ImpressionCount: 0,
+					LikeCount:       10,
+					RetweetCount:    5,
+				},
+			},
+			want: 0.0,
+		},
+		{
+			name: "nil metrics",
+			args: args{
+				metrics: nil,
+			},
+			want: 0.0,
+		},
+		{
+			name: "no engagements",
+			args: args{
+				metrics: &gotwtr.TweetMetrics{
+					ImpressionCount: 1000,
+					LikeCount:       0,
+					RetweetCount:    0,
+					ReplyCount:      0,
+					QuoteCount:      0,
+					BookmarkCount:   0,
+				},
+			},
+			want: 0.0,
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("key")
+			got := c.CalculateEngagementRate(tt.args.metrics)
+			if math.Abs(got-tt.want) > 1e-9 {
+				t.Errorf("CalculateEngagementRate() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestCalculateEngagementMetrics(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		metrics *gotwtr.TweetMetrics
+	}
+	tests := []struct {
+		name string
+		args args
+		want *gotwtr.EngagementMetrics
+	}{
+		{
+			name: "detailed engagement metrics",
+			args: args{
+				metrics: &gotwtr.TweetMetrics{
+					ImpressionCount:    1000,
+					LikeCount:          50,
+					RetweetCount:       20,
+					ReplyCount:         10,
+					QuoteCount:         5,
+					BookmarkCount:      3,
+					URLLinkClicks:      15,
+					UserProfileClicks:  8,
+				},
+			},
+			want: &gotwtr.EngagementMetrics{
+				EngagementRate:   8.8,  // (50+20+10+5+3)/1000 * 100
+				LikeRate:         5.0,  // 50/1000 * 100
+				RetweetRate:      2.0,  // 20/1000 * 100
+				ReplyRate:        1.0,  // 10/1000 * 100
+				QuoteRate:        0.5,  // 5/1000 * 100
+				BookmarkRate:     0.3,  // 3/1000 * 100
+				ClickThroughRate: 2.3,  // (15+8)/1000 * 100
+			},
+		},
+		{
+			name: "nil metrics",
+			args: args{
+				metrics: nil,
+			},
+			want: &gotwtr.EngagementMetrics{},
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("key")
+			got := c.CalculateEngagementMetrics(tt.args.metrics)
+			opt := cmp.Comparer(func(a, b float64) bool {
+				return math.Abs(a-b) < 1e-9
+			})
+			if diff := cmp.Diff(tt.want, got, opt); diff != "" {
+				t.Errorf("CalculateEngagementMetrics() mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestGetTopPerformingTweets(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		tweets []*gotwtr.Tweet
+		limit  int
+	}
+	tests := []struct {
+		name string
+		args args
+		want []*gotwtr.Tweet
+	}{
+		{
+			name: "sort tweets by engagement rate",
+			args: args{
+				tweets: []*gotwtr.Tweet{
+					{
+						ID:   "1",
+						Text: "Low engagement tweet",
+						PublicMetrics: &gotwtr.TweetMetrics{
+							ImpressionCount: 1000,
+							LikeCount:       10,
+							RetweetCount:    5,
+						},
+					},
+					{
+						ID:   "2",
+						Text: "High engagement tweet",
+						PublicMetrics: &gotwtr.TweetMetrics{
+							ImpressionCount: 1000,
+							LikeCount:       100,
+							RetweetCount:    50,
+						},
+					},
+					{
+						ID:   "3",
+						Text: "Medium engagement tweet",
+						PublicMetrics: &gotwtr.TweetMetrics{
+							ImpressionCount: 1000,
+							LikeCount:       50,
+							RetweetCount:    25,
+						},
+					},
+				},
+				limit: 2,
+			},
+			want: []*gotwtr.Tweet{
+				{
+					ID:   "2",
+					Text: "High engagement tweet",
+					PublicMetrics: &gotwtr.TweetMetrics{
+						ImpressionCount: 1000,
+						LikeCount:       100,
+						RetweetCount:    50,
+					},
+				},
+				{
+					ID:   "3",
+					Text: "Medium engagement tweet",
+					PublicMetrics: &gotwtr.TweetMetrics{
+						ImpressionCount: 1000,
+						LikeCount:       50,
+						RetweetCount:    25,
+					},
+				},
+			},
+		},
+		{
+			name: "empty tweets slice",
+			args: args{
+				tweets: []*gotwtr.Tweet{},
+				limit:  5,
+			},
+			want: []*gotwtr.Tweet{},
+		},
+		{
+			name: "limit larger than tweets count",
+			args: args{
+				tweets: []*gotwtr.Tweet{
+					{
+						ID:   "1",
+						Text: "Only tweet",
+						PublicMetrics: &gotwtr.TweetMetrics{
+							ImpressionCount: 1000,
+							LikeCount:       10,
+						},
+					},
+				},
+				limit: 5,
+			},
+			want: []*gotwtr.Tweet{
+				{
+					ID:   "1",
+					Text: "Only tweet",
+					PublicMetrics: &gotwtr.TweetMetrics{
+						ImpressionCount: 1000,
+						LikeCount:       10,
+					},
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("key")
+			got := c.GetTopPerformingTweets(tt.args.tweets, tt.args.limit)
+			if diff := cmp.Diff(tt.want, got); diff != "" {
+				t.Errorf("GetTopPerformingTweets() mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestCompareMetrics(t *testing.T) {
+	t.Parallel()
+	type args struct {
+		current  *gotwtr.TweetMetrics
+		previous *gotwtr.TweetMetrics
+	}
+	tests := []struct {
+		name string
+		args args
+		want *gotwtr.AnalyticsComparison
+	}{
+		{
+			name: "positive growth",
+			args: args{
+				current: &gotwtr.TweetMetrics{
+					ImpressionCount: 1000,
+					LikeCount:       60,  // 6% engagement rate
+					RetweetCount:    0,
+					ReplyCount:      0,
+					QuoteCount:      0,
+					BookmarkCount:   0,
+				},
+				previous: &gotwtr.TweetMetrics{
+					ImpressionCount: 1000,
+					LikeCount:       50,  // 5% engagement rate
+					RetweetCount:    0,
+					ReplyCount:      0,
+					QuoteCount:      0,
+					BookmarkCount:   0,
+				},
+			},
+			want: &gotwtr.AnalyticsComparison{
+				Current:  6.0,
+				Previous: 5.0,
+				Change:   20.0, // ((6-5)/5)*100 = 20%
+			},
+		},
+		{
+			name: "negative growth",
+			args: args{
+				current: &gotwtr.TweetMetrics{
+					ImpressionCount: 1000,
+					LikeCount:       40,  // 4% engagement rate
+					RetweetCount:    0,
+					ReplyCount:      0,
+					QuoteCount:      0,
+					BookmarkCount:   0,
+				},
+				previous: &gotwtr.TweetMetrics{
+					ImpressionCount: 1000,
+					LikeCount:       50,  // 5% engagement rate
+					RetweetCount:    0,
+					ReplyCount:      0,
+					QuoteCount:      0,
+					BookmarkCount:   0,
+				},
+			},
+			want: &gotwtr.AnalyticsComparison{
+				Current:  4.0,
+				Previous: 5.0,
+				Change:   -20.0, // ((4-5)/5)*100 = -20%
+			},
+		},
+		{
+			name: "nil current metrics",
+			args: args{
+				current: nil,
+				previous: &gotwtr.TweetMetrics{
+					ImpressionCount: 1000,
+					LikeCount:       50,
+				},
+			},
+			want: nil,
+		},
+		{
+			name: "nil previous metrics",
+			args: args{
+				current: &gotwtr.TweetMetrics{
+					ImpressionCount: 1000,
+					LikeCount:       50,
+				},
+				previous: nil,
+			},
+			want: nil,
+		},
+	}
+	for _, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("key")
+			got := c.CompareMetrics(tt.args.current, tt.args.previous)
+			opt := cmp.Comparer(func(a, b float64) bool {
+				return math.Abs(a-b) < 1e-9
+			})
+			if diff := cmp.Diff(tt.want, got, opt); diff != "" {
+				t.Errorf("CompareMetrics() mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}
\ No newline at end of file