1use chrono::{Datelike, NaiveDate, Weekday};
2use serde::{Deserialize, Serialize, Serializer};
3use std::collections::HashMap;
4
5type UserName<'a> = &'a str;
6
7type DateRange = (NaiveDate, NaiveDate);
8
9#[derive(Debug, Hash, Eq, PartialEq)]
10pub struct YearWeek {
11 pub year: usize,
12 pub week: usize,
13}
14
15impl Serialize for YearWeek {
16 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
17 where
18 S: Serializer,
19 {
20 serializer.serialize_str(&format!("{}-W{:02}", self.year, self.week))
21 }
22}
23
24#[derive(Serialize, Debug)]
26pub struct Activity {
27 pub date_range: DateRange,
29 pub contributions: HashMap<YearWeek, HashMap<Weekday, u32>>,
34}
35
36impl Activity {
37 pub fn number_of_weeks(&self) -> usize {
38 ((self.date_range.1 - self.date_range.0).num_days() as f32 / 7.0).ceil() as usize
39 }
40
41 pub fn as_matrix(&self) -> Vec<Vec<u32>> {
45 let mut matrix: Vec<Vec<u32>> = vec![vec![0; 7]; self.number_of_weeks()];
46 for date in self.date_range.0.iter_weeks().take(self.number_of_weeks()) {
47 let year_week = get_year_week(date);
48 if let Some(week_entry) = self.contributions.get(&year_week) {
49 for (weekday, count) in week_entry {
50 let week_index = year_week.week - 1;
51 let day_index = weekday.num_days_from_monday() as usize;
52 matrix[week_index][day_index] = count.clone();
53 }
54 }
55 }
56 return matrix;
57 }
58}
59
60#[derive(Serialize, Debug)]
62struct GraphQLRequest {
63 query: String,
64 variables: GraphQLVariables,
65}
66
67#[derive(Serialize, Debug)]
68struct GraphQLVariables {
69 username: String,
70 from: String,
71 to: String,
72}
73
74#[derive(Deserialize, Debug)]
75struct GraphQLResponse {
76 data: GraphQLData,
77}
78
79#[derive(Deserialize, Debug)]
80struct GraphQLData {
81 user: User,
82}
83
84#[derive(Deserialize, Debug)]
85struct User {
86 #[serde(rename = "contributionsCollection")]
87 contributions_collection: ContributionsCollection,
88}
89
90#[derive(Deserialize, Debug)]
91struct ContributionsCollection {
92 #[serde(rename = "contributionCalendar")]
93 contribution_calendar: ContributionCalendar,
94}
95
96#[derive(Deserialize, Debug)]
97struct ContributionCalendar {
98 #[allow(dead_code)]
99 #[serde(rename = "totalContributions")]
100 total_contributions: u32,
101 weeks: Vec<Week>,
102}
103
104#[derive(Deserialize, Debug)]
105struct Week {
106 #[serde(rename = "contributionDays")]
107 contribution_days: Vec<ContributionDay>,
108}
109
110#[derive(Deserialize, Debug)]
111struct ContributionDay {
112 date: String,
113 #[serde(rename = "contributionCount")]
114 contribution_count: u32,
115}
116
117fn get_year_week(date: NaiveDate) -> YearWeek {
118 let iso_week = date.iso_week();
119 YearWeek {
120 year: iso_week.year() as usize,
121 week: iso_week.week() as usize,
122 }
123}
124
125pub async fn get_activity(
131 user: UserName<'_>,
132 date_range: DateRange,
133 maybe_token: Option<String>,
134) -> Result<Activity, Box<dyn std::error::Error>> {
135 let number_of_weeks = ((date_range.1 - date_range.0).num_days() as f32 / 7.0).ceil() as usize;
136
137 let mut contributions: HashMap<YearWeek, HashMap<Weekday, u32>> = HashMap::new();
138
139 for date in date_range.0.iter_weeks().take(number_of_weeks) {
141 let year_week = get_year_week(date);
142 contributions.insert(year_week, HashMap::new());
143 }
144
145 let client = reqwest::Client::new();
146
147 let from = format!("{}T00:00:00Z", date_range.0);
149 let to = format!("{}T23:59:59Z", date_range.1);
150
151 let graphql_query = r#"
153 query($username: String!, $from: DateTime!, $to: DateTime!) {
154 user(login: $username) {
155 contributionsCollection(from: $from, to: $to) {
156 contributionCalendar {
157 totalContributions
158 weeks {
159 contributionDays {
160 date
161 contributionCount
162 }
163 }
164 }
165 }
166 }
167 }
168 "#;
169
170 let request_body = GraphQLRequest {
171 query: graphql_query.to_string(),
172 variables: GraphQLVariables {
173 username: user.to_string(),
174 from,
175 to,
176 },
177 };
178
179 let mut request = client
181 .post("https://api.github.com/graphql")
182 .header("User-Agent", "gh-trophy")
183 .json(&request_body);
184
185 if let Some(token) = maybe_token {
187 request = request.bearer_auth(token);
188 }
189
190 let response = request.send().await?;
192
193 let status = response.status();
195 let response_text = response.text().await?;
196
197 if !status.is_success() {
198 return Err(format!("GitHub API error {}: {}", status, response_text).into());
199 }
200
201 let graphql_response: GraphQLResponse = serde_json::from_str(&response_text)?;
203
204 for week in graphql_response
206 .data
207 .user
208 .contributions_collection
209 .contribution_calendar
210 .weeks
211 {
212 for day in week.contribution_days {
213 if day.contribution_count > 0 {
214 if let Ok(date) = NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") {
216 if date >= date_range.0 && date <= date_range.1 {
217 let year_week = get_year_week(date);
218 let weekday = date.weekday();
219
220 let entry = contributions.entry(year_week).or_insert_with(HashMap::new);
221 *entry.entry(weekday).or_insert(0) += day.contribution_count;
222 }
223 }
224 }
225 }
226 }
227
228 Ok(Activity {
229 date_range,
230 contributions,
231 })
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237 use chrono::NaiveDate;
238
239 #[test]
240 fn test_year_week_serialization() {
241 let year_week = YearWeek {
242 year: 2024,
243 week: 5,
244 };
245 let serialized = serde_json::to_string(&year_week).unwrap();
246 assert_eq!(serialized, "\"2024-W05\"");
247 }
248
249 #[test]
250 fn test_year_week_serialization_single_digit() {
251 let year_week = YearWeek {
252 year: 2023,
253 week: 1,
254 };
255 let serialized = serde_json::to_string(&year_week).unwrap();
256 assert_eq!(serialized, "\"2023-W01\"");
257 }
258
259 #[test]
260 fn test_year_week_serialization_double_digit() {
261 let year_week = YearWeek {
262 year: 2024,
263 week: 52,
264 };
265 let serialized = serde_json::to_string(&year_week).unwrap();
266 assert_eq!(serialized, "\"2024-W52\"");
267 }
268
269 #[test]
270 fn test_get_year_week() {
271 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
272 let year_week = get_year_week(date);
273 assert_eq!(year_week.year, 2024);
274 assert_eq!(year_week.week, 3);
275 }
276
277 #[test]
278 fn test_get_year_week_start_of_year() {
279 let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
280 let year_week = get_year_week(date);
281 assert_eq!(year_week.year, 2024);
282 assert_eq!(year_week.week, 1);
283 }
284
285 #[test]
286 fn test_get_year_week_end_of_year() {
287 let date = NaiveDate::from_ymd_opt(2024, 12, 30).unwrap();
288 let year_week = get_year_week(date);
289 assert_eq!(year_week.year, 2025);
290 assert_eq!(year_week.week, 1);
291 }
292
293 #[test]
294 fn test_activity_number_of_weeks_exact() {
295 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
296 let end = NaiveDate::from_ymd_opt(2024, 1, 28).unwrap();
297 let activity = Activity {
298 date_range: (start, end),
299 contributions: HashMap::new(),
300 };
301 assert_eq!(activity.number_of_weeks(), 4);
302 }
303
304 #[test]
305 fn test_activity_number_of_weeks_partial() {
306 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
307 let end = NaiveDate::from_ymd_opt(2024, 1, 30).unwrap();
308 let activity = Activity {
309 date_range: (start, end),
310 contributions: HashMap::new(),
311 };
312 assert_eq!(activity.number_of_weeks(), 5);
314 }
315
316 #[test]
317 fn test_activity_number_of_weeks_one_week() {
318 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
319 let end = NaiveDate::from_ymd_opt(2024, 1, 7).unwrap();
320 let activity = Activity {
321 date_range: (start, end),
322 contributions: HashMap::new(),
323 };
324 assert_eq!(activity.number_of_weeks(), 1);
325 }
326
327 #[test]
328 fn test_activity_as_matrix_empty() {
329 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
330 let end = NaiveDate::from_ymd_opt(2024, 1, 14).unwrap();
331 let activity = Activity {
332 date_range: (start, end),
333 contributions: HashMap::new(),
334 };
335 let matrix = activity.as_matrix();
336 assert_eq!(matrix.len(), 2); assert_eq!(matrix[0].len(), 7); assert_eq!(matrix[1].len(), 7); for row in matrix {
341 for value in row {
342 assert_eq!(value, 0);
343 }
344 }
345 }
346
347 #[test]
348 fn test_activity_as_matrix_with_contributions() {
349 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
350 let end = NaiveDate::from_ymd_opt(2024, 1, 14).unwrap();
351
352 let mut contributions = HashMap::new();
353
354 let year_week = YearWeek {
356 year: 2024,
357 week: 1,
358 };
359 let mut week_contributions = HashMap::new();
360 week_contributions.insert(Weekday::Mon, 5);
361 week_contributions.insert(Weekday::Wed, 3);
362 contributions.insert(year_week, week_contributions);
363
364 let activity = Activity {
365 date_range: (start, end),
366 contributions,
367 };
368
369 let matrix = activity.as_matrix();
370 assert_eq!(matrix.len(), 2); assert_eq!(matrix[0][0], 5);
374 assert_eq!(matrix[0][2], 3);
376 assert_eq!(matrix[0][1], 0); assert_eq!(matrix[0][3], 0); }
380
381 #[test]
382 fn test_activity_as_matrix_multiple_weeks() {
383 let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
384 let end = NaiveDate::from_ymd_opt(2024, 1, 21).unwrap();
385
386 let mut contributions = HashMap::new();
387
388 let mut week1 = HashMap::new();
390 week1.insert(Weekday::Mon, 10);
391 contributions.insert(
392 YearWeek {
393 year: 2024,
394 week: 1,
395 },
396 week1,
397 );
398
399 let mut week2 = HashMap::new();
401 week2.insert(Weekday::Fri, 7);
402 contributions.insert(
403 YearWeek {
404 year: 2024,
405 week: 2,
406 },
407 week2,
408 );
409
410 let activity = Activity {
411 date_range: (start, end),
412 contributions,
413 };
414
415 let matrix = activity.as_matrix();
416 assert_eq!(matrix.len(), 3); assert_eq!(matrix[0][0], 10);
420 assert_eq!(matrix[1][4], 7);
422 }
423}