PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.7
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.7
4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 4.2.0 4.2.1 All 138 releases
learnpress / inc / Statistics / PeriodResolver.php

PeriodResolver.php in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.7, at inc/Statistics/PeriodResolver.php

478 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class PeriodResolver
4 *
5 * @package LearnPress/Classes/Statistics
6 * @since 4.4.2
7 */
8
9 namespace LearnPress\Statistics;
10
11 use DateTimeImmutable;
12 use Exception;
13
14 defined( 'ABSPATH' ) || exit();
15
16 /**
17 * Single source of truth for "what window are we looking at, and at what chart
18 * resolution". Maps a `preset` (+ optional custom `date`) to a PeriodRange,
19 * and a PeriodRange (+ compare mode) to its delta baseline.
20 *
21 * - Presets resolve to concrete [ start … end ] windows carried as the legacy
22 * `custom` pair, so every existing DB method works unchanged; the only new
23 * signal is the explicit `granularity`.
24 * - Weeks honor the WP `start_of_week` option; quarters are calendar quarters.
25 * - All date math anchors on current_time() (site timezone); `$now` is
26 * injectable for tests.
27 *
28 * Fail-soft contract: resolve() never throws — unknown/invalid input falls back
29 * to `today`; previous() returns null so a KPI renders without a delta.
30 *
31 * @since 4.4.2
32 */
33 class PeriodResolver {
34 const GRAN_HOUR = 'hour';
35 const GRAN_DAY = 'day';
36 const GRAN_MONTH = 'month';
37
38 const COMPARE_PREVIOUS_PERIOD = 'previous_period';
39 const COMPARE_PREVIOUS_YEAR = 'previous_year';
40
41 /**
42 * Presets offered in the date-range dropdown, in display order
43 * ( 'custom' is the dropdown's second tab, not a list entry ).
44 */
45 const UI_PRESETS = array( 'today', 'yesterday', 'week', 'last_week', 'month', 'last_month', 'quarter', 'last_quarter', 'year', 'last_year' );
46
47 /**
48 * Resolve a requested preset into a PeriodRange.
49 *
50 * @param string $preset Preset id; unknown → 'today'.
51 * @param string $date 'Y-m-d+Y-m-d' pair, only read when $preset is 'custom'.
52 * @param string $now 'Y-m-d H:i:s' anchor, '' → current_time( 'mysql' ) (injectable for tests).
53 * @return PeriodRange
54 */
55 public static function resolve( string $preset, string $date = '', string $now = '' ): PeriodRange {
56 $now_dt = self::now_dt( $now );
57 $preset = '' !== $preset ? $preset : 'today';
58
59 try {
60 $range = self::build( $preset, $date, $now_dt );
61 } catch ( Exception $e ) {
62 $range = null;
63 }
64
65 if ( ! $range instanceof PeriodRange ) {
66 // Poka-yoke: unknown preset or unparsable custom date → today.
67 $range = self::build( 'today', '', $now_dt );
68 }
69
70 /**
71 * Filter the resolved statistics window.
72 *
73 * @param PeriodRange $range
74 * @param string $preset Requested preset id.
75 * @param string $date Raw custom date pair.
76 * @since 4.4.2
77 */
78 return apply_filters( 'learn-press/statistics/period-range', $range, $preset, $date );
79 }
80
81 /**
82 * Baseline window for KPI deltas.
83 *
84 * previous_period shifts back by one natural unit of the preset so "to date"
85 * stays calendar-aligned ( Jul 1–14 → Jun 1–14 ); custom/rolling windows get
86 * the equal-length window immediately before. previous_year is the same
87 * month/day window one calendar year earlier ( Feb 29 clamps to Feb 28 ).
88 *
89 * Baselines are day-granular: the legacy pair queries whole days, exactly
90 * like the existing PeriodHelper mapping.
91 *
92 * @param PeriodRange $range Current window from resolve().
93 * @param string $compare COMPARE_PREVIOUS_PERIOD | COMPARE_PREVIOUS_YEAR.
94 * @return PeriodRange|null Null when no baseline can be built.
95 */
96 public static function previous( PeriodRange $range, string $compare = self::COMPARE_PREVIOUS_PERIOD ): ?PeriodRange {
97 try {
98 $start = new DateTimeImmutable( $range->start );
99 $end = new DateTimeImmutable( $range->end );
100
101 if ( self::COMPARE_PREVIOUS_YEAR === self::sanitize_compare( $compare ) ) {
102 return self::baseline( $range, self::shift_years( $start, -1 ), self::shift_years( $end, -1 ) );
103 }
104
105 switch ( $range->preset ) {
106 case 'today':
107 case 'yesterday':
108 return self::baseline( $range, $start->modify( '-1 day' ), $end->modify( '-1 day' ) );
109
110 case 'week':
111 return self::baseline( $range, $start->modify( '-7 days' ), $end->modify( '-7 days' ) );
112
113 case 'last_week':
114 return self::baseline( $range, $start->modify( '-7 days' ), $start->modify( '-1 day' ) );
115
116 case 'month':
117 $prev_first = $start->modify( 'first day of previous month' );
118 $day = min( (int) $end->format( 'j' ), (int) $prev_first->format( 't' ) );
119
120 return self::baseline( $range, $prev_first, $prev_first->modify( sprintf( '+%d days', $day - 1 ) ) );
121
122 case 'last_month':
123 $prev_first = $start->modify( 'first day of previous month' );
124
125 return self::baseline( $range, $prev_first, $prev_first->modify( 'last day of this month' ) );
126
127 case 'quarter':
128 $prev_first = $start->modify( '-3 months' );
129 $prev_last = $start->modify( '-1 day' );
130 $elapsed = (int) $start->diff( $end )->format( '%a' );
131 $prev_end = $prev_first->modify( sprintf( '+%d days', $elapsed ) );
132
133 return self::baseline( $range, $prev_first, min( $prev_end, $prev_last ) );
134
135 case 'last_quarter':
136 return self::baseline( $range, $start->modify( '-3 months' ), $start->modify( '-1 day' ) );
137
138 case 'year':
139 return self::baseline( $range, $start->modify( '-1 year' ), self::shift_years( $end, -1 ) );
140
141 case 'last_year':
142 $prev_first = $start->modify( '-1 year' );
143
144 return self::baseline( $range, $prev_first, $prev_first->modify( '+11 months' )->modify( 'last day of this month' ) );
145
146 case 'custom':
147 default:
148 $length = (int) $start->diff( $end )->format( '%a' ) + 1;
149 $prev_end = $start->modify( '-1 day' );
150 $prev_start = $prev_end->modify( sprintf( '-%d days', $length - 1 ) );
151
152 return self::baseline( $range, $prev_start, $prev_end );
153 }
154 } catch ( Exception $e ) {
155 return null;
156 }
157 }
158
159 /**
160 * Whitelist the compare mode; anything else → previous_period.
161 *
162 * @param string $compare Raw request value.
163 * @return string
164 */
165 public static function sanitize_compare( string $compare ): string {
166 return self::COMPARE_PREVIOUS_YEAR === $compare ? self::COMPARE_PREVIOUS_YEAR : self::COMPARE_PREVIOUS_PERIOD;
167 }
168
169 /**
170 * Granularity rule for arbitrary spans ( custom ranges ): day up to 92 days,
171 * month beyond. Presets carry their own pinned granularity instead.
172 *
173 * @param DateTimeImmutable $start
174 * @param DateTimeImmutable $end
175 * @return string
176 */
177 public static function granularity_for_span( DateTimeImmutable $start, DateTimeImmutable $end ): string {
178 $days = (int) $start->diff( $end )->format( '%a' ) + 1;
179
180 return $days <= 92 ? self::GRAN_DAY : self::GRAN_MONTH;
181 }
182
183 /**
184 * Build the range for one preset, or null for an unknown one.
185 *
186 * @param string $preset
187 * @param string $date
188 * @param DateTimeImmutable $now
189 * @return PeriodRange|null
190 * @throws Exception When a custom date pair does not parse.
191 */
192 private static function build( string $preset, string $date, DateTimeImmutable $now ): ?PeriodRange {
193 $today = $now->setTime( 0, 0, 0 );
194
195 switch ( $preset ) {
196 case 'today':
197 return self::make( $preset, $today, $now, self::GRAN_HOUR, 'date', $now->format( 'Y-m-d' ) );
198
199 case 'yesterday':
200 $day = $today->modify( '-1 day' );
201
202 return self::make( $preset, $day, self::day_end( $day ), self::GRAN_HOUR, 'date', $day->format( 'Y-m-d' ) );
203
204 case 'week':
205 return self::make_custom( $preset, self::week_start( $today ), $now, self::GRAN_DAY );
206
207 case 'last_week':
208 $week_start = self::week_start( $today );
209
210 return self::make_custom( $preset, $week_start->modify( '-7 days' ), self::day_end( $week_start->modify( '-1 day' ) ), self::GRAN_DAY );
211
212 case 'month':
213 return self::make_custom( $preset, $today->modify( 'first day of this month' ), $now, self::GRAN_DAY );
214
215 case 'last_month':
216 $first = $today->modify( 'first day of previous month' );
217
218 return self::make_custom( $preset, $first, self::day_end( $first->modify( 'last day of this month' ) ), self::GRAN_DAY );
219
220 case 'quarter':
221 return self::make_custom( $preset, self::quarter_start( $today ), $now, self::GRAN_DAY );
222
223 case 'last_quarter':
224 $quarter_start = self::quarter_start( $today );
225
226 return self::make_custom( $preset, $quarter_start->modify( '-3 months' ), self::day_end( $quarter_start->modify( '-1 day' ) ), self::GRAN_DAY );
227
228 case 'year':
229 return self::make_custom( $preset, $today->modify( 'first day of january' ), $now, self::GRAN_MONTH );
230
231 case 'last_year':
232 $jan1 = $today->modify( 'first day of january' )->modify( '-1 year' );
233
234 return self::make_custom( $preset, $jan1, self::day_end( $jan1->modify( '+11 months' )->modify( 'last day of this month' ) ), self::GRAN_MONTH );
235
236 case 'custom':
237 $dates = explode( '+', $date );
238 if ( 2 !== count( $dates ) ) {
239 return null;
240 }
241 sort( $dates );
242 $start = ( new DateTimeImmutable( $dates[0] ) )->setTime( 0, 0, 0 );
243 $end = self::day_end( new DateTimeImmutable( $dates[1] ) );
244
245 return self::make_custom( $preset, $start, $end, self::granularity_for_span( $start, $end ) );
246 }
247
248 return null;
249 }
250
251 /**
252 * @param string $preset
253 * @param DateTimeImmutable $start
254 * @param DateTimeImmutable $end
255 * @param string $granularity
256 * @param string $filter_type Legacy DB type.
257 * @param string|int $time Legacy DB value.
258 * @return PeriodRange
259 */
260 private static function make( string $preset, DateTimeImmutable $start, DateTimeImmutable $end, string $granularity, string $filter_type, $time ): PeriodRange {
261 return new PeriodRange(
262 $preset,
263 $start->format( 'Y-m-d H:i:s' ),
264 $end->format( 'Y-m-d H:i:s' ),
265 $granularity,
266 self::label_for( $preset, $start, $end, $granularity ),
267 $filter_type,
268 $time
269 );
270 }
271
272 /**
273 * Range carried as the legacy `custom` pair ( whole days, BETWEEN in SQL ).
274 *
275 * @param string $preset
276 * @param DateTimeImmutable $start
277 * @param DateTimeImmutable $end
278 * @param string $granularity
279 * @return PeriodRange
280 */
281 private static function make_custom( string $preset, DateTimeImmutable $start, DateTimeImmutable $end, string $granularity ): PeriodRange {
282 return self::make( $preset, $start, $end, $granularity, 'custom', $start->format( 'Y-m-d' ) . '+' . $end->format( 'Y-m-d' ) );
283 }
284
285 /**
286 * Baseline PeriodRange for previous(): keeps the preset id and granularity,
287 * carries the shifted window as date|custom legacy pair, no label.
288 *
289 * @param PeriodRange $range Current window.
290 * @param DateTimeImmutable $start
291 * @param DateTimeImmutable $end
292 * @return PeriodRange
293 */
294 private static function baseline( PeriodRange $range, DateTimeImmutable $start, DateTimeImmutable $end ): PeriodRange {
295 $start_day = $start->format( 'Y-m-d' );
296 $end_day = $end->format( 'Y-m-d' );
297
298 if ( $start_day === $end_day ) {
299 $pair = array( 'date', $start_day );
300 } else {
301 $pair = array( 'custom', $start_day . '+' . $end_day );
302 }
303
304 return new PeriodRange(
305 $range->preset,
306 $start->setTime( 0, 0, 0 )->format( 'Y-m-d H:i:s' ),
307 self::day_end( $end )->format( 'Y-m-d H:i:s' ),
308 $range->granularity,
309 '',
310 $pair[0],
311 $pair[1]
312 );
313 }
314
315 /**
316 * Translated display name of a preset ( "month" → "Month to date" ).
317 *
318 * @param string $preset Preset id.
319 * @return string Falls back to the raw id for unknown presets.
320 */
321 public static function preset_name( string $preset ): string {
322 $names = array(
323 'today' => __( 'Today', 'learnpress' ),
324 'yesterday' => __( 'Yesterday', 'learnpress' ),
325 'week' => __( 'Week to date', 'learnpress' ),
326 'last_week' => __( 'Last week', 'learnpress' ),
327 'month' => __( 'Month to date', 'learnpress' ),
328 'last_month' => __( 'Last month', 'learnpress' ),
329 'quarter' => __( 'Quarter to date', 'learnpress' ),
330 'last_quarter' => __( 'Last quarter', 'learnpress' ),
331 'year' => __( 'Year to date', 'learnpress' ),
332 'last_year' => __( 'Last year', 'learnpress' ),
333 'custom' => __( 'Custom', 'learnpress' ),
334 );
335
336 return $names[ $preset ] ?? $preset;
337 }
338
339 /**
340 * Human range part of a resolved window ( "Jul 1 – 14" ) — what the
341 * dropdown toggle shows next to the preset name.
342 *
343 * @param PeriodRange $range
344 * @return string
345 */
346 public static function range_label_for( PeriodRange $range ): string {
347 try {
348 return self::range_label(
349 new DateTimeImmutable( $range->start ),
350 new DateTimeImmutable( $range->end ),
351 $range->granularity
352 );
353 } catch ( Exception $e ) {
354 return '';
355 }
356 }
357
358 /**
359 * Preset name + resolved range, e.g. "Month to date (Jul 1 – 14)".
360 *
361 * @param string $preset
362 * @param DateTimeImmutable $start
363 * @param DateTimeImmutable $end
364 * @param string $granularity
365 * @return string
366 */
367 private static function label_for( string $preset, DateTimeImmutable $start, DateTimeImmutable $end, string $granularity ): string {
368 return sprintf( '%s (%s)', self::preset_name( $preset ), self::range_label( $start, $end, $granularity ) );
369 }
370
371 /**
372 * Human range part of the label, densest unambiguous form.
373 *
374 * @param DateTimeImmutable $start
375 * @param DateTimeImmutable $end
376 * @param string $granularity
377 * @return string
378 */
379 private static function range_label( DateTimeImmutable $start, DateTimeImmutable $end, string $granularity ): string {
380 $same_year = $start->format( 'Y' ) === $end->format( 'Y' );
381 $same_month = $same_year && $start->format( 'm' ) === $end->format( 'm' );
382
383 if ( self::GRAN_MONTH === $granularity ) {
384 // Exact calendar year → just the year ( "2025" ).
385 if ( $same_year && '01-01' === $start->format( 'm-d' ) && '12-31' === $end->format( 'm-d' ) ) {
386 return date_i18n( 'Y', $start->getTimestamp() );
387 }
388 if ( $same_month ) {
389 return date_i18n( 'M Y', $start->getTimestamp() );
390 }
391 if ( $same_year ) {
392 return date_i18n( 'M', $start->getTimestamp() ) . '' . date_i18n( 'M', $end->getTimestamp() );
393 }
394
395 return date_i18n( 'M Y', $start->getTimestamp() ) . '' . date_i18n( 'M Y', $end->getTimestamp() );
396 }
397
398 if ( $start->format( 'Y-m-d' ) === $end->format( 'Y-m-d' ) ) {
399 return date_i18n( 'M j', $start->getTimestamp() );
400 }
401 if ( $same_month ) {
402 return date_i18n( 'M j', $start->getTimestamp() ) . '' . date_i18n( 'j', $end->getTimestamp() );
403 }
404 if ( $same_year ) {
405 return date_i18n( 'M j', $start->getTimestamp() ) . '' . date_i18n( 'M j', $end->getTimestamp() );
406 }
407
408 return date_i18n( 'M j, Y', $start->getTimestamp() ) . '' . date_i18n( 'M j, Y', $end->getTimestamp() );
409 }
410
411 /**
412 * First day of the week containing $day, honoring WP start_of_week
413 * ( 0 = Sunday … 6 = Saturday ).
414 *
415 * @param DateTimeImmutable $day
416 * @return DateTimeImmutable
417 */
418 private static function week_start( DateTimeImmutable $day ): DateTimeImmutable {
419 $start_of_week = (int) get_option( 'start_of_week', 1 );
420 $offset = ( (int) $day->format( 'w' ) - $start_of_week + 7 ) % 7;
421
422 return $day->modify( sprintf( '-%d days', $offset ) );
423 }
424
425 /**
426 * First day of the calendar quarter containing $day.
427 *
428 * @param DateTimeImmutable $day
429 * @return DateTimeImmutable
430 */
431 private static function quarter_start( DateTimeImmutable $day ): DateTimeImmutable {
432 $quarter_month = (int) floor( ( (int) $day->format( 'n' ) - 1 ) / 3 ) * 3 + 1;
433
434 return $day->setDate( (int) $day->format( 'Y' ), $quarter_month, 1 );
435 }
436
437 /**
438 * Shift a date by whole years, clamping Feb 29 → Feb 28 instead of letting
439 * PHP overflow into March.
440 *
441 * @param DateTimeImmutable $date
442 * @param int $years Signed.
443 * @return DateTimeImmutable
444 */
445 private static function shift_years( DateTimeImmutable $date, int $years ): DateTimeImmutable {
446 $year = (int) $date->format( 'Y' ) + $years;
447 $month = (int) $date->format( 'n' );
448 $day = (int) $date->format( 'j' );
449
450 if ( ! checkdate( $month, $day, $year ) ) {
451 $day = (int) $date->setDate( $year, $month, 1 )->format( 't' );
452 }
453
454 return $date->setDate( $year, $month, $day );
455 }
456
457 /**
458 * @param DateTimeImmutable $day
459 * @return DateTimeImmutable
460 */
461 private static function day_end( DateTimeImmutable $day ): DateTimeImmutable {
462 return $day->setTime( 23, 59, 59 );
463 }
464
465 /**
466 * @param string $now Injected 'Y-m-d H:i:s' or '' for WP current time.
467 * @return DateTimeImmutable
468 * @throws Exception Never in practice — falls back to server now.
469 */
470 private static function now_dt( string $now ): DateTimeImmutable {
471 if ( '' === $now ) {
472 $now = (string) current_time( 'mysql' );
473 }
474
475 return new DateTimeImmutable( $now );
476 }
477 }
478