post-push-party 0.1.10

Push code, earn points, throw a party!
use crate::storage::PushHistory;

use super::{BonusTrack, Clock, PushContext, Reward, Tier};

/// bonus for pushing consistently over multiple days
pub struct Streak;

/// count consecutive days with at least one push, ending today
fn consecutive_push_days(history: &PushHistory, clock: &Clock) -> u32 {
    let mut day_id = clock.today_id();
    let mut count = history.count_since(clock.today_start()).unwrap_or_default();

    // count today automatically
    let mut consec_days = 1;

    loop {
        day_id -= 1;
        let day_start = clock.day_start(day_id);
        let new_count = history.count_since(day_start).unwrap_or_default();

        if new_count > count {
            consec_days += 1;
            count = new_count;
        } else {
            break;
        }
    }

    consec_days
}

/// minimum consecutive days to trigger the bonus
const MIN_STREAK_DAYS: u32 = 3;

static TIERS: &[Tier] = &[
    Tier {
        cost: 50,
        reward: Reward::Multiplier(2),
    },
    Tier {
        cost: 500,
        reward: Reward::Multiplier(3),
    },
    Tier {
        cost: 3000,
        reward: Reward::Multiplier(4),
    },
    Tier {
        cost: 20000,
        reward: Reward::Multiplier(5),
    },
    Tier {
        cost: 120000,
        reward: Reward::Multiplier(6),
    },
];

impl BonusTrack for Streak {
    fn id(&self) -> &'static str {
        "streak"
    }

    fn name(&self) -> &'static str {
        "Hot Streak"
    }

    fn description(&self) -> &'static str {
        "Multiplier for pushing 3+ days in a row."
    }

    fn tiers(&self) -> &'static [Tier] {
        TIERS
    }

    fn applies(&self, ctx: &PushContext) -> u32 {
        if ctx.push.commits().is_empty() {
            return 0;
        }

        if consecutive_push_days(ctx.history, ctx.clock) >= MIN_STREAK_DAYS {
            1
        } else {
            0
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        git::{Commit, Push},
        storage::{DbConnection, PushEntry},
    };

    const SECONDS_PER_DAY: u64 = 86400;

    fn clock_at_day(day: u64) -> Clock {
        Clock::at(day * SECONDS_PER_DAY + 3600) // 1am on that day
    }

    fn entry_on_day(day: u64) -> PushEntry {
        PushEntry::at(day * SECONDS_PER_DAY + 3600)
    }

    #[test]
    fn applies_with_3_day_streak() {
        let conn = DbConnection::create_in_memory().unwrap();

        let bonus = Streak;
        let push = Push::new(vec![Commit::default()]);
        let history = PushHistory::new(&conn).with_entries([
            entry_on_day(100),
            entry_on_day(101),
            entry_on_day(102),
        ]);

        let clock = clock_at_day(102);
        let ctx = PushContext {
            push: &push,
            history: &history,
            clock: &clock,
        };
        assert_eq!(bonus.applies(&ctx), 1);
    }

    #[test]
    fn applies_with_longer_streak() {
        let conn = DbConnection::create_in_memory().unwrap();

        let bonus = Streak;
        let push = Push::new(vec![Commit::default()]);
        let history = PushHistory::new(&conn).with_entries((95..=102).map(entry_on_day));

        let clock = clock_at_day(102);
        let ctx = PushContext {
            push: &push,
            history: &history,
            clock: &clock,
        };
        assert_eq!(bonus.applies(&ctx), 1);
    }

    #[test]
    fn does_not_apply_with_2_day_streak() {
        let conn = DbConnection::create_in_memory().unwrap();

        let bonus = Streak;
        let push = Push::new(vec![Commit::default()]);
        let history = PushHistory::new(&conn).with_entries([entry_on_day(101), entry_on_day(102)]);

        let clock = clock_at_day(102);
        let ctx = PushContext {
            push: &push,
            history: &history,
            clock: &clock,
        };
        assert_eq!(bonus.applies(&ctx), 0);
    }

    #[test]
    fn does_not_apply_with_gap() {
        let conn = DbConnection::create_in_memory().unwrap();

        let bonus = Streak;
        let push = Push::new(vec![Commit::default()]);
        let history = PushHistory::new(&conn).with_entries([
            entry_on_day(99),
            entry_on_day(100),
            // gap on day 101
            entry_on_day(102),
        ]);

        let clock = clock_at_day(102);
        let ctx = PushContext {
            push: &push,
            history: &history,
            clock: &clock,
        };
        assert_eq!(bonus.applies(&ctx), 0);
    }

    #[test]
    fn applies_on_first_push_of_day() {
        let conn = DbConnection::create_in_memory().unwrap();

        let bonus = Streak;
        let push = Push::new(vec![Commit::default()]);
        let history = PushHistory::new(&conn).with_entries([
            entry_on_day(99),
            entry_on_day(100),
            entry_on_day(101),
        ]);

        // clock is on day 102, so this is the first push
        let clock = clock_at_day(102);
        let ctx = PushContext {
            push: &push,
            history: &history,
            clock: &clock,
        };
        assert_eq!(bonus.applies(&ctx), 1);
    }

    #[test]
    fn applies_with_negative_timezone_offset() {
        let conn = DbConnection::create_in_memory().unwrap();

        let bonus = Streak;
        let push = Push::new(vec![Commit::default()]);

        // UTC-6 (e.g. CDMX): 11pm local = 5am UTC the next day
        let tz_offset: i32 = -21600;
        let local_11pm = |day: u64| -> u64 {
            let day_start_utc = (day as i64 * SECONDS_PER_DAY as i64 - tz_offset as i64) as u64;
            day_start_utc + 23 * 3600
        };

        let history = PushHistory::new(&conn).with_entries([
            PushEntry::at(local_11pm(100)),
            PushEntry::at(local_11pm(101)),
            PushEntry::at(local_11pm(102)),
        ]);

        let clock = Clock::with_offset(local_11pm(102), tz_offset);
        let ctx = PushContext {
            push: &push,
            history: &history,
            clock: &clock,
        };
        assert_eq!(bonus.applies(&ctx), 1);
    }

    #[test]
    fn does_not_apply_to_empty_pushes() {
        let conn = DbConnection::create_in_memory().unwrap();

        let bonus = Streak;
        let push = Push::new(vec![]);
        let history = PushHistory::new(&conn).with_entries([
            entry_on_day(100),
            entry_on_day(101),
            entry_on_day(102),
        ]);

        let clock = clock_at_day(102);
        let ctx = PushContext {
            push: &push,
            history: &history,
            clock: &clock,
        };
        assert_eq!(bonus.applies(&ctx), 0);
    }
}