Skip to main content

threads_rs/types/
insights.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4/// Earliest Unix timestamp usable for insights queries.
5pub const MIN_INSIGHT_TIMESTAMP: i64 = 1_712_991_600;
6
7// ---------------------------------------------------------------------------
8// Enums
9// ---------------------------------------------------------------------------
10
11/// Available post insight metrics.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub enum PostInsightMetric {
14    /// Post views count.
15    #[serde(rename = "views")]
16    Views,
17    /// Post likes count.
18    #[serde(rename = "likes")]
19    Likes,
20    /// Post replies count.
21    #[serde(rename = "replies")]
22    Replies,
23    /// Post reposts count.
24    #[serde(rename = "reposts")]
25    Reposts,
26    /// Post quotes count.
27    #[serde(rename = "quotes")]
28    Quotes,
29    /// Post shares count.
30    #[serde(rename = "shares")]
31    Shares,
32}
33
34impl PostInsightMetric {
35    /// Returns a slice of all variants.
36    pub fn all() -> &'static [Self] {
37        &[
38            Self::Views,
39            Self::Likes,
40            Self::Replies,
41            Self::Reposts,
42            Self::Quotes,
43            Self::Shares,
44        ]
45    }
46}
47
48/// Available account insight metrics.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub enum AccountInsightMetric {
51    /// Account views count.
52    #[serde(rename = "views")]
53    Views,
54    /// Account likes count.
55    #[serde(rename = "likes")]
56    Likes,
57    /// Account replies count.
58    #[serde(rename = "replies")]
59    Replies,
60    /// Account reposts count.
61    #[serde(rename = "reposts")]
62    Reposts,
63    /// Account quotes count.
64    #[serde(rename = "quotes")]
65    Quotes,
66    /// Account link clicks count.
67    #[serde(rename = "clicks")]
68    Clicks,
69    /// Total followers count.
70    #[serde(rename = "followers_count")]
71    FollowersCount,
72    /// Follower demographic breakdowns.
73    #[serde(rename = "follower_demographics")]
74    FollowerDemographics,
75}
76
77/// Available account insight metrics.
78impl AccountInsightMetric {
79    /// Returns a slice of all variants.
80    pub fn all() -> &'static [Self] {
81        &[
82            Self::Views,
83            Self::Likes,
84            Self::Replies,
85            Self::Reposts,
86            Self::Quotes,
87            Self::Clicks,
88            Self::FollowersCount,
89            Self::FollowerDemographics,
90        ]
91    }
92}
93
94/// Time period for insights.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub enum InsightPeriod {
97    /// Daily breakdown.
98    #[serde(rename = "day")]
99    Day,
100    /// Lifetime aggregate.
101    #[serde(rename = "lifetime")]
102    Lifetime,
103}
104
105impl InsightPeriod {
106    /// Returns a slice of all variants.
107    pub fn all() -> &'static [Self] {
108        &[Self::Day, Self::Lifetime]
109    }
110}
111
112/// Breakdown options for follower demographics.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub enum FollowerDemographicsBreakdown {
115    /// Breakdown by country.
116    #[serde(rename = "country")]
117    Country,
118    /// Breakdown by city.
119    #[serde(rename = "city")]
120    City,
121    /// Breakdown by age.
122    #[serde(rename = "age")]
123    Age,
124    /// Breakdown by gender.
125    #[serde(rename = "gender")]
126    Gender,
127}
128
129impl FollowerDemographicsBreakdown {
130    /// Returns a slice of all variants.
131    pub fn all() -> &'static [Self] {
132        &[Self::Country, Self::City, Self::Age, Self::Gender]
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Option structs
138// ---------------------------------------------------------------------------
139
140/// Options for post insights requests.
141#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142pub struct PostInsightsOptions {
143    /// Metrics to retrieve.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub metrics: Option<Vec<PostInsightMetric>>,
146    /// Time period granularity.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub period: Option<InsightPeriod>,
149    /// Start of the time range.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub since: Option<DateTime<Utc>>,
152    /// End of the time range.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub until: Option<DateTime<Utc>>,
155}
156
157/// Options for account insights requests.
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159pub struct AccountInsightsOptions {
160    /// Metrics to retrieve.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub metrics: Option<Vec<AccountInsightMetric>>,
163    /// Time period granularity.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub period: Option<InsightPeriod>,
166    /// Start of the time range.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub since: Option<DateTime<Utc>>,
169    /// End of the time range.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub until: Option<DateTime<Utc>>,
172    /// For `follower_demographics`: country, city, age, or gender.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub breakdown: Option<FollowerDemographicsBreakdown>,
175}
176
177// ---------------------------------------------------------------------------
178// Response structs
179// ---------------------------------------------------------------------------
180
181/// An individual analytics metric.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct Insight {
184    /// Metric name.
185    pub name: String,
186    /// Time period of the metric.
187    pub period: String,
188    /// Time-series values.
189    #[serde(default)]
190    pub values: Vec<Value>,
191    /// Human-readable title.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub title: Option<String>,
194    /// Human-readable description.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub description: Option<String>,
197    /// Insight ID.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub id: Option<String>,
200    /// Aggregated total value.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub total_value: Option<TotalValue>,
203    /// Per-link metric values (returned by the `clicks` account metric).
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub link_total_values: Option<Vec<LinkTotalValue>>,
206    /// Follower demographics total value with breakdowns.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub demographic_total_value: Option<DemographicTotalValue>,
209}
210
211/// A metric value with optional timestamp.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct Value {
214    /// The metric value.
215    pub value: i64,
216    /// End time of the measurement period.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub end_time: Option<String>,
219}
220
221/// An aggregated metric value.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct TotalValue {
224    /// The aggregated value.
225    pub value: i64,
226    /// Link URL associated with this metric value.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub link_url: Option<String>,
229}
230
231/// A per-link metric value returned by the `clicks` account insight metric.
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct LinkTotalValue {
234    /// The metric value for this link.
235    pub value: i64,
236    /// The link URL.
237    pub link_url: String,
238}
239
240/// A single demographic result entry.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct DemographicResult {
243    /// Values keyed by dimension (e.g. `{"country": "US"}`).
244    #[serde(default)]
245    pub dimension_values: Vec<String>,
246    /// The metric value for this demographic slice.
247    pub value: f64,
248}
249
250/// Breakdown data for follower demographics.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct DemographicBreakdown {
253    /// Dimension keys (e.g. `["country"]`, `["city"]`, `["age"]`, `["gender"]`).
254    pub dimension_keys: Vec<String>,
255    /// Results for each dimension value.
256    pub results: Vec<DemographicResult>,
257}
258
259/// Follower demographics total value with breakdowns.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct DemographicTotalValue {
262    /// Breakdown data for the demographic metric.
263    pub breakdowns: Vec<DemographicBreakdown>,
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_post_insight_metric_all() {
272        let all = PostInsightMetric::all();
273        assert_eq!(all.len(), 6);
274        assert_eq!(all[0], PostInsightMetric::Views);
275        assert_eq!(all[5], PostInsightMetric::Shares);
276    }
277
278    #[test]
279    fn test_account_insight_metric_all() {
280        let all = AccountInsightMetric::all();
281        assert_eq!(all.len(), 8);
282        assert_eq!(all[0], AccountInsightMetric::Views);
283        assert_eq!(all[7], AccountInsightMetric::FollowerDemographics);
284    }
285
286    #[test]
287    fn test_insight_period_all() {
288        let all = InsightPeriod::all();
289        assert_eq!(all.len(), 2);
290        assert_eq!(all[0], InsightPeriod::Day);
291        assert_eq!(all[1], InsightPeriod::Lifetime);
292    }
293
294    #[test]
295    fn test_follower_demographics_breakdown_all() {
296        let all = FollowerDemographicsBreakdown::all();
297        assert_eq!(all.len(), 4);
298        assert_eq!(all[0], FollowerDemographicsBreakdown::Country);
299        assert_eq!(all[3], FollowerDemographicsBreakdown::Gender);
300    }
301
302    #[test]
303    fn test_post_insight_metric_serde() {
304        let metric = PostInsightMetric::Views;
305        let json = serde_json::to_string(&metric).unwrap();
306        assert_eq!(json, r#""views""#);
307        let back: PostInsightMetric = serde_json::from_str(&json).unwrap();
308        assert_eq!(back, PostInsightMetric::Views);
309    }
310
311    #[test]
312    fn test_insight_period_serde() {
313        let period = InsightPeriod::Day;
314        let json = serde_json::to_string(&period).unwrap();
315        assert_eq!(json, r#""day""#);
316    }
317
318    #[test]
319    fn test_post_insights_options_default() {
320        let opts = PostInsightsOptions::default();
321        assert!(opts.metrics.is_none());
322        assert!(opts.period.is_none());
323        assert!(opts.since.is_none());
324        assert!(opts.until.is_none());
325    }
326
327    #[test]
328    fn test_account_insights_options_default() {
329        let opts = AccountInsightsOptions::default();
330        assert!(opts.metrics.is_none());
331        assert!(opts.breakdown.is_none());
332    }
333
334    #[test]
335    fn test_total_value_with_link_url() {
336        let json = r#"{"value": 42, "link_url": "https://example.com"}"#;
337        let tv: TotalValue = serde_json::from_str(json).unwrap();
338        assert_eq!(tv.value, 42);
339        assert_eq!(tv.link_url.as_deref(), Some("https://example.com"));
340    }
341
342    #[test]
343    fn test_total_value_without_link_url() {
344        let json = r#"{"value": 42}"#;
345        let tv: TotalValue = serde_json::from_str(json).unwrap();
346        assert_eq!(tv.value, 42);
347        assert!(tv.link_url.is_none());
348    }
349
350    #[test]
351    fn test_link_total_value_deserialize() {
352        let json = r#"{"value": 11, "link_url": "https://example.com"}"#;
353        let ltv: LinkTotalValue = serde_json::from_str(json).unwrap();
354        assert_eq!(ltv.value, 11);
355        assert_eq!(ltv.link_url, "https://example.com");
356    }
357
358    #[test]
359    fn test_insight_with_link_total_values() {
360        let json = r#"{
361            "name": "clicks",
362            "period": "lifetime",
363            "link_total_values": [
364                {"value": 11, "link_url": "https://example.com"},
365                {"value": 5, "link_url": "https://other.com"}
366            ]
367        }"#;
368        let insight: Insight = serde_json::from_str(json).unwrap();
369        assert_eq!(insight.name, "clicks");
370        let ltv = insight.link_total_values.unwrap();
371        assert_eq!(ltv.len(), 2);
372        assert_eq!(ltv[0].value, 11);
373        assert_eq!(ltv[1].link_url, "https://other.com");
374    }
375
376    #[test]
377    fn test_demographic_breakdown_deserialize() {
378        let json = r#"{
379            "name": "follower_demographics",
380            "period": "lifetime",
381            "demographic_total_value": {
382                "breakdowns": [{
383                    "dimension_keys": ["country"],
384                    "results": [
385                        {"dimension_values": ["US"], "value": 100.0},
386                        {"dimension_values": ["GB"], "value": 50.0}
387                    ]
388                }]
389            }
390        }"#;
391        let insight: Insight = serde_json::from_str(json).unwrap();
392        assert_eq!(insight.name, "follower_demographics");
393        let demo = insight.demographic_total_value.unwrap();
394        assert_eq!(demo.breakdowns.len(), 1);
395        assert_eq!(demo.breakdowns[0].dimension_keys, vec!["country"]);
396        assert_eq!(demo.breakdowns[0].results.len(), 2);
397        assert_eq!(demo.breakdowns[0].results[0].dimension_values, vec!["US"]);
398        assert_eq!(demo.breakdowns[0].results[0].value, 100.0);
399    }
400}