Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.76% covered (warning)
87.76%
43 / 49
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComputeEditingStreaks
87.76% covered (warning)
87.76%
43 / 49
50.00% covered (danger)
50.00%
2 / 4
13.31
0.00% covered (danger)
0.00%
0 / 1
 getEditingStreaks
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
1 / 1
9
 getLongestEditingStreak
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 makeDatePeriod
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 isDateAdjacent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\UserImpact;
4
5use DateInterval;
6use DatePeriod;
7use DateTime;
8use DateTimeInterface;
9use Exception;
10
11/*
12 * Utility class for processing "Editing streaks", which are consecutive days when a user made edits.
13 *
14 * The minimum unit for an editing streak is a single day. So if a user has made a single edit, the editing streak
15 * is for the date of that edit.
16 */
17class ComputeEditingStreaks {
18
19    /**
20     * Given an array of dates => edit counts (see UserImpact::getEditCountsByDay), generate an array of
21     * EditingStreak objects. An "editing streak" consists of a date range (minimum unit is one day) with a start and
22     * end date, and a total edit count number for that streak.
23     *
24     * @param array $editCountByDay @see UserImpact::getEditCountByDay()
25     * @return EditingStreak[]
26     * @throws Exception
27     */
28    public static function getEditingStreaks( array $editCountByDay ): array {
29        $editingStreaks = [];
30        // Create an initial, empty editing streak to start with.
31        // This is also needed in the event that $editCountByDay is empty.
32        $editingStreak = new EditingStreak();
33
34        // Iterate over each row in the edit count data. At the start of each loop,
35        // we have an EditingStreak object. Check if its date is adjacent to the row's
36        // date time.
37        //  - If it is adjacent, increment the total edit count for the streak, and increment
38        //    the end period for the streak.
39        //  - if not adjacent, add the editing streak to the list of streaks, and create a new
40        //    object
41        foreach ( $editCountByDay as $dateStringIndex => $editCountForDate ) {
42            try {
43                $currentRowDateTime = new DateTime( $dateStringIndex );
44            } catch ( \Exception $exception ) {
45                // silently discard row if date is invalid.
46                continue;
47            }
48            if ( !is_int( $editCountForDate ) || $editCountForDate === 0 ) {
49                // silently discard row if edit count format is invalid or 0.
50                continue;
51            }
52
53            if ( $editingStreak->getDatePeriod() &&
54                $editingStreak->getDatePeriod()->getEndDate() &&
55                !self::isDateAdjacent(
56                    $currentRowDateTime,
57                    // isDateAdjacent takes two DateTimeInterface objects.
58                    // getEndDate() can somehow return null (even though constructing a DatePeriod
59                    // requires an end date?), but anyway, we know there is an end date because
60                    // we just checked for one directly above this conditional.
61                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
62                    $editingStreak->getDatePeriod()->getEndDate()
63                ) ) {
64                $editingStreaks[] = $editingStreak;
65                $editingStreak = new EditingStreak(
66                    new DatePeriod(
67                        $currentRowDateTime,
68                        new DateInterval( 'P1D' ),
69                        // We need to set an end time, so set it to the current row's date by default.
70                        $currentRowDateTime
71                    ),
72                    $editCountForDate
73                );
74                continue;
75            }
76            $editingStreak->setTotalEditCountForPeriod(
77                $editingStreak->getTotalEditCountForPeriod() + $editCountForDate
78            );
79            $editingStreak->setDatePeriod(
80                new DatePeriod(
81                    $editingStreak->getDatePeriod() ?
82                        $editingStreak->getDatePeriod()->getStartDate() :
83                        $currentRowDateTime,
84                    new DateInterval( 'P1D' ),
85                    $currentRowDateTime
86                )
87            );
88        }
89        // Add the last processed edit streak to the list.
90        // Also catches the special case of a single item in the editCountByDay array.
91        $editingStreaks[] = $editingStreak;
92        return $editingStreaks;
93    }
94
95    /**
96     * Get the editing streak with the most consecutive days in a given set of edit count by day data.
97     *
98     * @param array $editCountByDay
99     * @return EditingStreak
100     * @throws Exception
101     */
102    public static function getLongestEditingStreak(
103        array $editCountByDay
104    ): EditingStreak {
105        $editingStreaks = self::getEditingStreaks( $editCountByDay );
106        usort( $editingStreaks, static function ( EditingStreak $a, EditingStreak $b ) {
107            return $a->getStreakNumberOfDays() < $b->getStreakNumberOfDays() ? 1 : -1;
108        } );
109        return reset( $editingStreaks );
110    }
111
112    /**
113     * Utility method for constructing a DatePeriod.
114     *
115     * @param string $start ISO 8601 date, e.g. '2022-08-25'.
116     * @param string $end ISO 8601 date
117     * @return DatePeriod
118     * @throws Exception
119     */
120    public static function makeDatePeriod( string $start, string $end ): DatePeriod {
121        return new DatePeriod(
122            new DateTime( $start ),
123            new DateInterval( 'P1D' ),
124            new DateTime( $end )
125        );
126    }
127
128    /**
129     * Utility method to see if a given date is adjacent to another one.
130     *
131     * Examples:
132     *  - "2022-10-01" is adjacent to "2022-09-30"
133     *  - "2022-10-01" is not adjacent to "2022-10-03"
134     * @param DateTimeInterface $dateOne
135     * @param DateTimeInterface $dateTwo
136     * @return bool True if adjacent, false otherwise
137     */
138    private static function isDateAdjacent(
139        DateTimeInterface $dateOne, DateTimeInterface $dateTwo
140    ): bool {
141        return $dateOne->diff( $dateTwo )->days === 1;
142    }
143}