1use std::collections::BTreeMap;
4use std::error::Error;
5use std::fmt;
6
7use chrono::{Datelike, Days, IsoWeek, NaiveDate, Weekday};
8
9use crate::history::{
10 AccountSnapshot, ConflictingRepository, ContributionHistoryError, ContributionSummary,
11 ContributionWeek, LanguageColor, calculate_language_shares, summarize_contributions,
12};
13
14#[derive(Debug, PartialEq)]
16pub struct KoiDevelopment {
17 pub identity_seed: u64,
19 pub language_shares: BTreeMap<String, f64>,
21 pub language_colors: BTreeMap<String, Option<LanguageColor>>,
23 pub total_contributions: u64,
25 pub observed_on: NaiveDate,
27 pub streaks: StreakDevelopment,
29 pub recent_activity: RecentActivity,
31}
32
33#[derive(Debug, Eq, PartialEq)]
35pub struct RecentActivity {
36 pub quiet_day_count: u32,
39 pub recent_contributions: u64,
41 pub complete_week_contributions: Vec<u64>,
44 pub observation_week_contributions: Vec<u64>,
47}
48
49#[derive(Debug, Eq, PartialEq)]
51pub struct StreakDevelopment {
52 pub longest: Vec<ContributionWeek>,
54 pub recent: Option<RecentStreak>,
56}
57
58#[derive(Debug, Eq, PartialEq)]
60pub enum RecentStreak {
61 Current(Vec<ContributionWeek>),
63 Ended {
65 weeks: Vec<ContributionWeek>,
67 ended_on: NaiveDate,
70 },
71}
72
73pub fn derive_koi_development(
87 account_snapshot: &AccountSnapshot,
88) -> Result<KoiDevelopment, KoiDevelopmentError> {
89 let contribution_summary = summarize_contributions(account_snapshot)
90 .map_err(KoiDevelopmentError::ContributionHistory)?;
91 let language_shares =
92 calculate_language_shares(account_snapshot).map_err(KoiDevelopmentError::Repositories)?;
93 let recorded_colors = account_snapshot
94 .language_colors
95 .as_ref()
96 .ok_or(KoiDevelopmentError::MissingLanguageColors)?;
97 let language_colors = language_shares
98 .keys()
99 .map(|language_name| {
100 recorded_colors
101 .get(language_name)
102 .map(|language_color| (language_name.clone(), *language_color))
103 .ok_or_else(|| KoiDevelopmentError::MissingLanguageColor(language_name.clone()))
104 })
105 .collect::<Result<_, _>>()?;
106 Ok(KoiDevelopment {
107 identity_seed: account_snapshot.account_id,
108 language_shares,
109 language_colors,
110 total_contributions: contribution_summary.total_contributions,
111 observed_on: contribution_summary.observed_on,
112 streaks: calculate_streak_development(&contribution_summary),
113 recent_activity: measure_recent_activity(&contribution_summary),
114 })
115}
116
117pub const FULL_COLOR_CONTRIBUTIONS: u64 = 500;
119pub const YOUNG_SHAPE_CONTRIBUTIONS: u64 = 1_500;
121pub const ADULT_CONTRIBUTIONS: u64 = 12_750;
124pub const MAX_GROWTH_CONTRIBUTIONS: u64 = 100_000;
126
127pub fn calculate_growth_fraction(total_contributions: u64) -> f64 {
131 const CONTRIBUTION_SCALE: f64 = 50.0;
132 let bounded_total = total_contributions.min(MAX_GROWTH_CONTRIBUTIONS);
133 (1.0 + bounded_total as f64 / CONTRIBUTION_SCALE).log2()
134 / (1.0 + MAX_GROWTH_CONTRIBUTIONS as f64 / CONTRIBUTION_SCALE).log2()
135}
136
137#[derive(Debug, Eq, PartialEq)]
139pub enum KoiDevelopmentError {
140 ContributionHistory(ContributionHistoryError),
142 Repositories(ConflictingRepository),
144 MissingLanguageColors,
146 MissingLanguageColor(String),
148}
149
150impl fmt::Display for KoiDevelopmentError {
151 fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
152 match self {
153 Self::ContributionHistory(history_error) => write!(output_formatter, "{history_error}"),
154 Self::Repositories(repository_error) => write!(output_formatter, "{repository_error}"),
155 Self::MissingLanguageColors => write!(
156 output_formatter,
157 "the snapshot has no language colors; collect it again with `git-koi collect`"
158 ),
159 Self::MissingLanguageColor(language_name) => write!(
160 output_formatter,
161 "the snapshot has no color for {language_name}; collect it again with `git-koi collect`"
162 ),
163 }
164 }
165}
166
167impl Error for KoiDevelopmentError {
168 fn source(&self) -> Option<&(dyn Error + 'static)> {
169 match self {
170 Self::ContributionHistory(history_error) => Some(history_error),
171 Self::Repositories(repository_error) => Some(repository_error),
172 Self::MissingLanguageColors | Self::MissingLanguageColor(_) => None,
173 }
174 }
175}
176
177fn measure_recent_activity(contribution_summary: &ContributionSummary) -> RecentActivity {
178 const RECENT_DAY_COUNT: u64 = 7;
179 const DAYS_PER_WEEK: u64 = 7;
180 let observed_on = contribution_summary.observed_on;
181 let daily_counts = &contribution_summary.daily_counts;
182 let quiet_day_count = daily_counts
183 .values()
184 .rev()
185 .take_while(|&&contribution_count| contribution_count == 0)
186 .count() as u32;
187 let recent_start = observed_on
188 .checked_sub_days(Days::new(RECENT_DAY_COUNT - 1))
189 .expect("observation dates are far from the calendar's start");
190 let recent_contributions = daily_counts
191 .range(recent_start..=observed_on)
192 .map(|(_, &contribution_count)| u64::from(contribution_count))
193 .sum();
194 let mut weekly_totals: BTreeMap<IsoWeek, (u64, u64)> = BTreeMap::new();
195 for (contribution_date, &contribution_count) in daily_counts {
196 let (week_contributions, week_day_count) = weekly_totals
197 .entry(contribution_date.iso_week())
198 .or_default();
199 *week_contributions += u64::from(contribution_count);
200 *week_day_count += 1;
201 }
202 let observation_week = observed_on.iso_week();
203 let observation_monday = observed_on
204 .checked_sub_days(Days::new(u64::from(
205 observed_on.weekday().num_days_from_monday(),
206 )))
207 .expect("observation dates are far from the calendar's start");
208 RecentActivity {
209 quiet_day_count,
210 recent_contributions,
211 complete_week_contributions: weekly_totals
212 .into_iter()
213 .filter(|&(iso_week, (_, week_day_count))| {
214 week_day_count == DAYS_PER_WEEK && iso_week != observation_week
215 })
216 .map(|(_, (week_contributions, _))| week_contributions)
217 .collect(),
218 observation_week_contributions: daily_counts
219 .range(observation_monday..=observed_on)
220 .map(|(_, &contribution_count)| u64::from(contribution_count))
221 .collect(),
222 }
223}
224
225fn calculate_streak_development(contribution_summary: &ContributionSummary) -> StreakDevelopment {
226 let mut longest_week_range = 0..0;
227 let mut recent_week_range = 0..0;
228 let mut current_start_week_index = None;
229 let mut ended_on = None;
230 let observation_week = contribution_summary.observed_on.iso_week();
231 for (week_index, contribution_week) in contribution_summary.weeks.iter().enumerate() {
232 if contribution_week.active_day_count > 0 {
233 let start_week_index = *current_start_week_index.get_or_insert(week_index);
234 recent_week_range = start_week_index..week_index + 1;
235 ended_on = None;
236 if recent_week_range.len() > longest_week_range.len() {
237 longest_week_range = recent_week_range.clone();
238 }
239 } else if contribution_week.iso_week != observation_week
240 && current_start_week_index.take().is_some()
241 {
242 let inactive_sunday = NaiveDate::from_isoywd_opt(
243 contribution_week.iso_week.year(),
244 contribution_week.iso_week.week(),
245 Weekday::Sun,
246 )
247 .expect("a completed observation week has a Sunday");
248 ended_on = Some(
249 inactive_sunday
250 .succ_opt()
251 .expect("a completed week precedes the observation date"),
252 );
253 }
254 }
255 let recent_streak = if recent_week_range.is_empty() {
256 None
257 } else {
258 let recent_weeks = contribution_summary.weeks[recent_week_range].to_vec();
259 Some(match ended_on {
260 Some(ended_on) => RecentStreak::Ended {
261 weeks: recent_weeks,
262 ended_on,
263 },
264 None => RecentStreak::Current(recent_weeks),
265 })
266 };
267 StreakDevelopment {
268 longest: contribution_summary.weeks[longest_week_range].to_vec(),
269 recent: recent_streak,
270 }
271}