Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.16% covered (warning)
74.16%
221 / 298
50.00% covered (danger)
50.00%
7 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
NumberRange
74.16% covered (warning)
74.16%
221 / 298
50.00% covered (danger)
50.00%
7 / 14
318.74
0.00% covered (danger)
0.00%
0 / 1
 query
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
8.02
 render
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
4.00
 formatTimeRange
80.00% covered (warning)
80.00%
20 / 25
0.00% covered (danger)
0.00%
0 / 1
15.57
 formatTimeRangeInUnit
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
342
 formatSecondsInUnit
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 makePluralWith
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 makePlural
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 makeSingle
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
8
 formatSeconds
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
18
 getRangeLabel
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 parseTimeRange
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
90
 convertToSeconds
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 defineOptions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 buildOptionsForm
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace Drupal\visitors\Plugin\views\field;
4
5use Drupal\Core\Form\FormStateInterface;
6use Drupal\views\Attribute\ViewsField;
7use Drupal\views\Plugin\views\field\FieldPluginBase;
8use Drupal\views\ResultRow;
9
10/**
11 * Field handler to display numeric values grouped into configured ranges.
12 */
13#[ViewsField("visitors_number_range")]
14class NumberRange extends FieldPluginBase {
15
16  /**
17   * {@inheritdoc}
18   */
19  public function query() {
20    $this->ensureMyTable();
21    $suffix = '__range';
22    $field = "$this->tableAlias.$this->realField";
23
24    $ranges_text = $this->options['ranges'];
25    $ranges = preg_split('/\r\n|\r|\n/', $ranges_text);
26    $ranges = array_map('trim', $ranges);
27    $ranges = array_filter($ranges, function ($range) {
28      return trim($range) !== '';
29    });
30
31    if (empty($ranges)) {
32      return parent::query();
33    }
34
35    $case_sql = [];
36    foreach ($ranges as $i => $range) {
37
38      $value = $i;
39      $parsed_range = $this->parseTimeRange($range);
40
41      // "X-Y" with or without time units.
42      if (preg_match('/^(\d+(?:\.\d+)?)\-(\d+(?:\.\d+)?)$/', $range, $m)) {
43        $start_seconds = $this->convertToSeconds($m[1], $parsed_range['start_unit']);
44        $end_seconds = $this->convertToSeconds($m[2], $parsed_range['end_unit']);
45        $case_sql[] = "WHEN $field BETWEEN $start_seconds AND $end_seconds THEN $value";
46      }
47      // "-X" format (0 to X).
48      elseif (preg_match('/^\-(\d+(?:\.\d+)?)([mdwy]?)$/', $range, $m)) {
49        $end_seconds = $this->convertToSeconds($m[1], $m[2] ?: NULL);
50        $case_sql[] = "WHEN $field BETWEEN 0 AND $end_seconds THEN $value";
51      }
52      // "X+" with or without time units.
53      elseif (preg_match('/^(\d+(?:\.\d+)?)\+$/', $range, $m)) {
54        $seconds = $this->convertToSeconds($m[1], $parsed_range['start_unit']);
55        $case_sql[] = "WHEN $field >= $seconds THEN $value";
56      }
57      // Exact number with or without time units.
58      elseif (preg_match('/^(\d+(?:\.\d+)?)$/', $range, $m)) {
59        $seconds = $this->convertToSeconds($m[1], $parsed_range['start_unit']);
60        $case_sql[] = "WHEN $field = $seconds THEN $value";
61      }
62
63    }
64    $max_value = count($ranges) + 1;
65    $case_expression = "CASE " . implode(' ', $case_sql) . " ELSE $max_value END";
66
67    // Tell Views to use this CASE expression as the field value.
68    $this->field_alias = $this->query->addField(NULL, $case_expression, $this->tableAlias . '_' . $this->realField . $suffix);
69  }
70
71  /**
72   * {@inheritdoc}
73   */
74  public function render(ResultRow $values) {
75    $label = $this->getValue($values);
76
77    // Get display name.
78    $display_name = $this->options;
79
80    // Always replace the index value with the range label.
81    $label = $this->getRangeLabel($label);
82
83    // If time format is enabled, convert the range to time format.
84    if ($this->options['time_format']) {
85      $time_unit = $this->options['time_unit'] ?? 'auto';
86      if ($time_unit === 'auto') {
87        $label = $this->formatTimeRange($label);
88      }
89      else {
90        $label = $this->formatTimeRangeInUnit($label, $time_unit);
91      }
92    }
93    elseif ($this->options['pluralize']) {
94      // Example: pluralize with the word "item".
95      return $this->formatPlural(
96        $label,
97        $this->options['singular_label'],
98        $this->options['plural_label'],
99      );
100    }
101
102    return $label;
103  }
104
105  /**
106   * Formats a range value as time when the range represents seconds.
107   *
108   * @param string $range
109   *   The range value (e.g., "60", "3600", "86400").
110   *
111   * @return string
112   *   The formatted time range (e.g., "1m", "1h", "1d").
113   */
114  protected function formatTimeRange(string $range): string {
115    // Handle ranges like "60-120" or "3600+".
116    if (strpos($range, '-') !== FALSE) {
117      $parts = explode('-', $range);
118      $unit = NULL;
119      $start = $this->formatSeconds((int) $parts[0], $unit);
120      $end = $this->formatSeconds((int) $parts[1], $unit);
121      // If start and end are less than 1 unit apart, return the integer with
122      // the unit. Example: "1 day".
123      if (is_numeric($start) && is_numeric($end)
124          && abs($end - $start) < 1) {
125        $integer = is_int($end) ? $end : (is_int($start) ? $start : $end);
126        // Only return "0" without units if both start and end are exactly 0.
127        if ($integer == 0 && $start == 0 && $end == 0) {
128          return '0';
129        }
130        return $this->makePluralWith($integer, $unit);
131      }
132      if ($start == 0 && $end == 1) {
133        return $this->makeSingle("$end", $unit);
134      }
135      elseif ($start == 0) {
136        return $this->makeSingle("$start-$end", $unit);
137      }
138
139      return $this->makePlural(floor($start) . "-$end", $unit);
140    }
141
142    if (strpos($range, '+') !== FALSE) {
143      $seconds = (int) rtrim($range, '+');
144      $unit = NULL;
145      $time = $this->formatSeconds($seconds, $unit);
146      return $this->makePluralWith(floor($time), $unit ?? 'second', '+');
147    }
148
149    // Single value.
150    $seconds = (int) $range;
151    $unit = NULL;
152    $time = $this->formatSeconds($seconds, $unit);
153    return $this->makePluralWith($time, $unit ?? 'second');
154  }
155
156  /**
157   * Formats a range value as time using a specific unit.
158   *
159   * @param string $range
160   *   The range value (e.g., "60", "3600", "86400").
161   * @param string $unit
162   *   The time unit to use (second, minute, hour, day, week, year).
163   *
164   * @return string
165   *   The formatted time range in the specified unit.
166   */
167  protected function formatTimeRangeInUnit(string $range, string $unit): string {
168    // Handle "-X" format (0 to X).
169    if (preg_match('/^\-(\d+)$/', $range, $m)) {
170      $end = $this->formatSecondsInUnit((int) $m[1], $unit);
171      if ($end == 0) {
172        return '0';
173      }
174      return $this->makePluralWith($end, $unit);
175    }
176
177    // Handle ranges like "60-120" or "3600+".
178    if (strpos($range, '-') !== FALSE) {
179      $parts = explode('-', $range);
180      $start = $this->formatSecondsInUnit((int) $parts[0], $unit);
181      $end = $this->formatSecondsInUnit((int) $parts[1], $unit);
182
183      // If both start and end are 0, return just "0".
184      if ($start == 0 && $end == 0) {
185        return '0';
186      }
187
188      // If start and end are less than 1 unit apart, return the integer with
189      // the unit. Example: "1 day".
190      if (is_numeric($start) && is_numeric($end)
191          && abs($end - $start) < 1) {
192        $integer = floor($start);
193        // Only return "0" without units if both start and end are exactly 0.
194        if ($integer == 0 && $start == 0 && $end == 0) {
195          return '0';
196        }
197        return $this->makePluralWith($integer, $unit);
198      }
199      if ($start == 0 && $end == 1) {
200        return $this->makeSingle("$end", $unit);
201      }
202      elseif ($start == 0) {
203        return $this->makePlural($end, $unit);
204      }
205
206      return $this->makePlural(floor($start) . "-" . floor($end), $unit);
207    }
208
209    if (strpos($range, '+') !== FALSE) {
210      $seconds = (int) rtrim($range, '+');
211      $time = $this->formatSecondsInUnit($seconds, $unit);
212      if ($time == 0) {
213        return '0+';
214      }
215      return $this->makePluralWith(floor($time), $unit, '+');
216    }
217
218    // Single value.
219    $seconds = (int) $range;
220    $time = $this->formatSecondsInUnit($seconds, $unit);
221    if ($time == 0) {
222      return '0';
223    }
224    return $this->makePluralWith($time, $unit);
225  }
226
227  /**
228   * Formats seconds into a specific time unit.
229   *
230   * @param int $seconds
231   *   The number of seconds.
232   * @param string $unit
233   *   The unit of time to format the seconds into.
234   *
235   * @return float
236   *   The formatted time value as a float.
237   */
238  protected function formatSecondsInUnit(int $seconds, string $unit): float {
239    switch ($unit) {
240      case 'second':
241        return (float) $seconds;
242
243      case 'minute':
244        return $seconds / 60;
245
246      case 'hour':
247        return $seconds / 3600;
248
249      case 'day':
250        return $seconds / 86400;
251
252      case 'week':
253        return $seconds / 604800;
254
255      case 'year':
256        return $seconds / 31536000;
257
258      default:
259        return (float) $seconds;
260    }
261  }
262
263  /**
264   * Creates plural or singular form based on the time value.
265   *
266   * @param mixed $time
267   *   The time value to format.
268   * @param string $unit
269   *   The time unit (second, minute, hour, etc.).
270   * @param string $suffix
271   *   Optional suffix to append to the time value.
272   *
273   * @return string
274   *   The formatted time string with proper pluralization.
275   */
276  protected function makePluralWith($time, string $unit, $suffix = ''): string {
277    if ($time == 1) {
278      return $this->makeSingle($time, $unit, $suffix);
279    }
280    return $this->makePlural($time, $unit, $suffix);
281  }
282
283  /**
284   * Creates plural form of time units.
285   *
286   * @param mixed $time
287   *   The time value to format.
288   * @param string $unit
289   *   The time unit (second, minute, hour, etc.).
290   * @param string $suffix
291   *   Optional suffix to append to the time value.
292   *
293   * @return string
294   *   The formatted time string in plural form.
295   */
296  protected function makePlural($time, string $unit, $suffix = ''): string {
297    $plural = $this->t('Unknown unit');
298    switch ($unit) {
299      case 'second':
300        $plural = $this->t('@time seconds', ['@time' => $time . $suffix]);
301        break;
302
303      case 'minute':
304        $plural = $this->t('@time minutes', ['@time' => $time . $suffix]);
305        break;
306
307      case 'hour':
308        $plural = $this->t('@time hours', ['@time' => $time . $suffix]);
309        break;
310
311      case 'day':
312        $plural = $this->t('@time days', ['@time' => $time . $suffix]);
313        break;
314
315      case 'week':
316        $plural = $this->t('@time weeks', ['@time' => $time . $suffix]);
317        break;
318
319      case 'month':
320        $plural = $this->t('@time months', ['@time' => $time . $suffix]);
321        break;
322
323      case 'year':
324        $plural = $this->t('@time years', ['@time' => $time . $suffix]);
325        break;
326    }
327
328    return $plural;
329  }
330
331  /**
332   * Creates singular form of time units.
333   *
334   * @param mixed $time
335   *   The time value to format.
336   * @param string $unit
337   *   The time unit (second, minute, hour, etc.).
338   * @param string $suffix
339   *   Optional suffix to append to the time value.
340   *
341   * @return string
342   *   The formatted time string in singular form.
343   */
344  protected function makeSingle($time, string $unit, $suffix = ''): string {
345    $single = $this->t('Unknown unit');
346    switch ($unit) {
347      case 'second':
348        $single = $this->t('@time second', ['@time' => $time . $suffix]);
349        break;
350
351      case 'minute':
352        $single = $this->t('@time minute', ['@time' => $time . $suffix]);
353        break;
354
355      case 'hour':
356        $single = $this->t('@time hour', ['@time' => $time . $suffix]);
357        break;
358
359      case 'day':
360        $single = $this->t('@time day', ['@time' => $time . $suffix]);
361        break;
362
363      case 'week':
364        $single = $this->t('@time week', ['@time' => $time . $suffix]);
365        break;
366
367      case 'month':
368        $single = $this->t('@time month', ['@time' => $time . $suffix]);
369        break;
370
371      case 'year':
372        $single = $this->t('@time year', ['@time' => $time . $suffix]);
373        break;
374    }
375    return $single;
376  }
377
378  /**
379   * Formats seconds into human-readable time units.
380   *
381   * @param int $seconds
382   *   The number of seconds.
383   * @param string|null $unit
384   *   The unit of time used to format the seconds.
385   *
386   * @return float
387   *   The formatted time value as a float.
388   */
389  protected function formatSeconds(int $seconds, &$unit = NULL): float {
390    if ($seconds === 0) {
391      return (float) $seconds;
392    }
393
394    // If unit is already specified and not empty, format seconds as that unit.
395    if ($unit !== NULL && $unit !== '') {
396      switch ($unit) {
397        case 'second':
398          return (float) $seconds;
399
400        case 'minute':
401          return $seconds / 60;
402
403        case 'hour':
404          return $seconds / 3600;
405
406        case 'day':
407          return $seconds / 86400;
408
409        case 'week':
410          return $seconds / 604800;
411
412        case 'month':
413          return $seconds / 2592000;
414
415        case 'year':
416          return $seconds / 31536000;
417
418        default:
419          // If unit is not recognized, fall through to automatic detection.
420          break;
421      }
422    }
423
424    // Automatic unit detection (existing logic)
425    if ($seconds < 60) {
426      $unit = 'second';
427      return (float) $seconds;
428    }
429
430    if ($seconds < 3600) {
431      $unit = 'minute';
432      return $seconds / 60;
433    }
434
435    if ($seconds < 86400) {
436      $unit = 'hour';
437      return $seconds / 3600;
438    }
439
440    if ($seconds < 604800) {
441      $unit = 'day';
442      return $seconds / 86400;
443    }
444
445    if ($seconds < 2592000) {
446      $unit = 'week';
447      return $seconds / 604800;
448    }
449
450    if ($seconds < 31536000) {
451      $unit = 'month';
452      return $seconds / 2592000;
453    }
454
455    $unit = 'year';
456    return $seconds / 31536000;
457  }
458
459  /**
460   * Gets the range label for a given index value.
461   *
462   * @param string|int $index
463   *   The index value (e.g., 1, 2, 3, 4, etc.).
464   *
465   * @return string
466   *   The range label for the index.
467   */
468  protected function getRangeLabel($index): string {
469    $ranges_text = $this->options['ranges'];
470    $ranges = preg_split('/\r\n|\r|\n/', $ranges_text);
471
472    $i = 0;
473    foreach ($ranges as $range) {
474      $range = trim($range);
475      if ($range === '') {
476        continue;
477      }
478
479      if ($i === (int) $index) {
480        return $range;
481      }
482
483      $i += 1;
484    }
485
486    // If no match found, return the original index value.
487    return (string) $index;
488  }
489
490  /**
491   * Parses a time range string to extract time units.
492   *
493   * @param string $range
494   *   The range string (e.g., "1m-2m", "5d+", "1w", "-60").
495   *
496   * @return array
497   *   An array with 'start_unit' and 'end_unit' keys containing the time units.
498   */
499  protected function parseTimeRange(string $range): array {
500    $result = ['start_unit' => NULL, 'end_unit' => NULL];
501
502    // Handle range format "X-Y" with units.
503    if (preg_match('/^(\d+(?:\.\d+)?)([mdwy]?)\-(\d+(?:\.\d+)?)([mdwy]?)$/', $range, $m)) {
504      $result['start_unit'] = $m[2] ?: NULL;
505      $result['end_unit'] = $m[4] ?: NULL;
506    }
507    // Handle "-X" format (0 to X) with units.
508    elseif (preg_match('/^\-(\d+(?:\.\d+)?)([mdwy]?)$/', $range, $m)) {
509      $result['start_unit'] = NULL;
510      $result['end_unit'] = $m[2] ?: NULL;
511    }
512    // Handle single value or "X+" format with units.
513    elseif (preg_match('/^(\d+(?:\.\d+)?)([mdwy]?)(\+?)$/', $range, $m)) {
514      $result['start_unit'] = $m[2] ?: NULL;
515      $result['end_unit'] = $m[2] ?: NULL;
516    }
517
518    return $result;
519  }
520
521  /**
522   * Converts a number with time unit to seconds.
523   *
524   * @param string $value
525   *   The numeric value.
526   * @param string|null $unit
527   *   The time unit (m, d, w, y) or NULL for no unit.
528   *
529   * @return int
530   *   The value converted to seconds.
531   */
532  protected function convertToSeconds(string $value, ?string $unit): int {
533    $num_value = (float) $value;
534
535    if ($unit === NULL) {
536      return (int) $num_value;
537    }
538
539    switch ($unit) {
540      // Minutes.
541      case 'm':
542        return (int) ($num_value * 60);
543
544      // Days.
545      case 'd':
546        return (int) ($num_value * 86400);
547
548      // Weeks.
549      case 'w':
550        return (int) ($num_value * 604800);
551
552      // Years.
553      case 'y':
554        return (int) ($num_value * 31536000);
555
556      default:
557        return (int) $num_value;
558    }
559  }
560
561  /**
562   * {@inheritdoc}
563   */
564  protected function defineOptions() {
565    $options = parent::defineOptions();
566    $options['pluralize'] = ['default' => FALSE];
567    $options['singular_label'] = ['default' => '1 item'];
568    $options['plural_label'] = ['default' => '@count items'];
569    $options['ranges'] = ['default' => "0\n1\n2\n3\n4\n5\n6-7\n8-10\n11-14\n15-20\n21+"];
570    $options['time_format'] = ['default' => FALSE];
571    $options['time_unit'] = ['default' => 'auto'];
572    return $options;
573  }
574
575  /**
576   * {@inheritdoc}
577   */
578  public function buildOptionsForm(&$form, FormStateInterface $form_state) {
579    parent::buildOptionsForm($form, $form_state);
580
581    $form['pluralize'] = [
582      '#type' => 'checkbox',
583      '#title' => $this->t('Enable pluralization'),
584      '#default_value' => $this->options['pluralize'],
585    ];
586
587    $form['singular_label'] = [
588      '#type' => 'textfield',
589      '#title' => $this->t('Singular label'),
590      '#default_value' => $this->options['singular_label'],
591      '#states' => [
592        'visible' => [
593          ':input[name="options[pluralize]"]' => ['checked' => TRUE],
594        ],
595      ],
596    ];
597
598    $form['plural_label'] = [
599      '#type' => 'textfield',
600      '#title' => $this->t('Plural label'),
601      '#default_value' => $this->options['plural_label'],
602      '#states' => [
603        'visible' => [
604          ':input[name="options[pluralize]"]' => ['checked' => TRUE],
605        ],
606      ],
607    ];
608
609    $form['ranges'] = [
610      '#type' => 'textarea',
611      '#title' => $this->t('Number ranges'),
612      '#description' => $this->t("Enter one range per line. Examples: <code>0</code>, <code>6-7</code>, <code>21+</code>."),
613      '#default_value' => $this->options['ranges'],
614    ];
615
616    $form['time_format'] = [
617      '#type' => 'checkbox',
618      '#title' => $this->t('Format ranges as time (e.g., 60s, 1h, 1d)'),
619      '#default_value' => $this->options['time_format'],
620    ];
621
622    $form['time_unit'] = [
623      '#type' => 'select',
624      '#title' => $this->t('Time unit'),
625      '#description' => $this->t('Choose the time unit for displaying all ranges. Auto will use the most appropriate unit for each range.'),
626      '#default_value' => $this->options['time_unit'] ?? 'auto',
627      '#options' => [
628        'auto' => $this->t('Auto'),
629        'second' => $this->t('Seconds'),
630        'minute' => $this->t('Minutes'),
631        'hour' => $this->t('Hours'),
632        'day' => $this->t('Days'),
633        'week' => $this->t('Weeks'),
634        'year' => $this->t('Years'),
635      ],
636      '#states' => [
637        'visible' => [
638          ':input[name="options[time_format]"]' => ['checked' => TRUE],
639        ],
640      ],
641    ];
642  }
643
644}