github_heatmap/heatmap/
contribution_week.rs

1use scraper::ElementRef;
2use crate::HeatmapError;
3use super::Contribution;
4
5const Y_ATTR: &str = "y";
6
7/// A `ContributionWeek` instance represents an entire week of contributions
8/// in a Github contribution heatmap. Typically visible as a column of heatmap
9/// nodes on a Github profile page.
10///
11/// `ContributionWeek` instances are typically not constructed explicitly, rather created
12/// implicitly by the higher level `Heatmap` struct via the `from_days` associated method.
13///
14#[derive(Debug, Eq, PartialEq)]
15pub struct ContributionWeek {
16    /// A vector of [`Contribution`] instances belonging to the week. 
17    ///
18    /// Contributions are wrapped in an Option, as years won't necessarily begin
19    /// and/or end on a Sunday, meaning that certain days may not be included in the
20    /// heatmap during any given week.
21    pub contributions: Vec<Option<Contribution>>
22}
23
24impl ContributionWeek {
25    /// Contructs a new `ContributionWeek` instance from a vector of HTML elements.
26    /// Provided vector corresponds to a collection of Github heatmap nodes.
27    ///
28    /// For each valid day of the week with contributions, a [`Contribution`] instance
29    /// will be constructed and pushed to the `contributions` vector.
30    ///
31    /// # Errors
32    /// - [`HeatmapError::QueryAttribute`] fails to query y attribute
33    /// - [`HeatmapError::ParseAttribute`] fails to parse y attribute
34    /// - [`HeatmapError::UnknownNodeFormat`] encounters unexpected heatmap node size while
35    /// determining day of week for contributions
36    ///
37    /// See [`Contribution`] for possible errors related to constructing a ['Contribution'].
38    ///
39    pub fn from_days(days: &Vec<ElementRef>) -> Result<Self, HeatmapError> {
40        let mut contributions: Vec<Option<Contribution>> = vec![None; 7]; 
41
42        for day in days {
43            let y_value = Self::parse_y_attr(day)?;
44            let day_index = Self::get_day_index(y_value)?;
45            let contribution = Contribution::from_el(day)?;
46
47            contributions[day_index] = Some(contribution);
48        }
49
50        Ok(ContributionWeek { contributions })
51    }
52
53    fn parse_y_attr(day_el: &ElementRef) -> Result<usize, HeatmapError> {
54        let result = day_el
55            .value()
56            .attr(Y_ATTR)
57            .ok_or_else(|| HeatmapError::QueryAttribute { 
58                attr: Y_ATTR.to_string(), 
59                on_alias: "heatmap node".to_string(),
60            })?
61            .parse()
62            .map_err(|_| HeatmapError::ParseAttribute { 
63                attr: Y_ATTR.to_string(), 
64                on_alias: "heatmap node".to_string(),
65            })?;
66
67        Ok(result)
68    }
69
70    fn get_day_index(y_value: usize) -> Result<usize, HeatmapError> {
71        // To my knowledge, Github uses either a y attribute of 13px or 15px while rendering 
72        // the heatmap nodes, depending on the size of the heatmap on the profile.
73        match y_value {
74            0 => Ok(0),
75            y if y % 13 == 0 => Ok(y / 13),
76            y if y % 15 == 0 => Ok(y / 15),
77            _ => Err(HeatmapError::UnknownNodeFormat)
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use scraper::{Html, Selector};
86
87    #[test]
88    fn constructs_contribution_week() {
89        let fragment = Html::parse_fragment(r#"
90            <rect y='0' data-level='1' />
91            <rect y='15' data-level='2' />
92            <rect y='30' data-level='3' />
93            <rect y='45' data-level='4' />
94            <rect y='60' data-level='4' />
95            <rect y='75' data-level='4' />
96            <rect y='90' data-level='4' />
97        "#);
98
99        let selector = Selector::parse("rect").unwrap();
100        let rects: Vec<_> = fragment.select(&selector).collect();
101        let contribution_week = ContributionWeek::from_days(&rects).unwrap();
102
103        let expected = ContributionWeek {
104            contributions: vec![
105                Some(Contribution { heat_level: 1 }),
106                Some(Contribution { heat_level: 2 }),
107                Some(Contribution { heat_level: 3 }),
108                Some(Contribution { heat_level: 4 }),
109                Some(Contribution { heat_level: 4 }), 
110                Some(Contribution { heat_level: 4 }),
111                Some(Contribution { heat_level: 4 }),
112            ]
113        };
114
115        assert_eq!(contribution_week, expected) 
116    }
117
118    #[test]
119    fn constructs_partial_contribution_week() {
120        let fragment = Html::parse_fragment(r#"
121            <rect y='60' data-level='1' />
122            <rect y='75' data-level='2' />
123            <rect y='90' data-level='3' />
124        "#);
125
126        let selector = Selector::parse("rect").unwrap();
127        let rects: Vec<_> = fragment.select(&selector).collect();
128        let contribution_week = ContributionWeek::from_days(&rects).unwrap();
129
130        let expected = ContributionWeek {
131            contributions: vec![
132                None,
133                None,
134                None,
135                None,
136                Some(Contribution { heat_level: 1 }), 
137                Some(Contribution { heat_level: 2 }),
138                Some(Contribution { heat_level: 3 }),
139            ]
140        };
141
142        assert_eq!(contribution_week, expected) 
143    }
144
145    #[test]
146    fn parses_y_attribute() {
147        let fragment = Html::parse_fragment("<rect y='15' data-level='3' />");
148        let selector = Selector::parse("rect").unwrap();
149        let rect_el = fragment.select(&selector).next().unwrap();
150        let y_value = ContributionWeek::parse_y_attr(&rect_el).unwrap();
151
152        assert_eq!(y_value, 15)
153    }
154
155    #[test]
156    fn error_if_no_y_attribute() {
157        let fragment = Html::parse_fragment("<rect data-level='3' />");
158        let selector = Selector::parse("rect").unwrap();
159        let rect_el = fragment.select(&selector).next().unwrap();
160        let y_value = ContributionWeek::parse_y_attr(&rect_el);
161
162        assert_eq!(
163            y_value, 
164            Err(HeatmapError::QueryAttribute { attr: Y_ATTR.to_string(), on_alias: "heatmap node".to_string() })
165        )
166    } 
167
168    #[test]
169    fn error_if_invalid_y_attribute() {
170        let fragment = Html::parse_fragment("<rect y='fifteen' data-level='three' />");
171        let selector = Selector::parse("rect").unwrap();
172        let rect_el = fragment.select(&selector).next().unwrap();
173        let contribution = ContributionWeek::parse_y_attr(&rect_el);
174
175        assert_eq!(
176            contribution, 
177            Err(HeatmapError::ParseAttribute { attr: Y_ATTR.to_string(), on_alias: "heatmap node".to_string() })
178        )
179    }
180
181    #[test]
182    fn gets_first_day_index() {
183        let day_index = ContributionWeek::get_day_index(0).unwrap();
184        assert_eq!(day_index, 0)
185    }
186
187    #[test]
188    fn gets_large_day_index() {
189        let day_index = ContributionWeek::get_day_index(30).unwrap();
190        assert_eq!(day_index, 2)
191    }
192
193    #[test]
194    fn gets_small_day_index() {
195        let day_index = ContributionWeek::get_day_index(26).unwrap();
196        assert_eq!(day_index, 2)
197    }
198
199    #[test]
200    fn error_if_unknown_node_format() {
201        let result = ContributionWeek::get_day_index(20);
202        assert_eq!(result, Err(HeatmapError::UnknownNodeFormat))
203    }
204}