File Coverage

blib/lib/Time/Ago.pm
Criterion Covered Total %
statement 81 83 97.5
branch 43 50 86.0
condition 1 3 33.3
subroutine 15 15 100.0
pod 1 2 50.0
total 141 153 92.1


line stmt bran cond sub pod time code
1             package Time::Ago;
2             # ABSTRACT: Approximate duration in words
3              
4             # Port of Rails distance_of_time_in_words and time_ago_in_words
5             # http://apidock.com/rails/v4.2.1/ActionView/Helpers/DateHelper/distance_of_time_in_words
6              
7 1     1   787 use strict;
  1         2  
  1         29  
8 1     1   5 use warnings;
  1         1  
  1         23  
9 1     1   470 use utf8;
  1         12  
  1         4  
10 1     1   24 use Carp;
  1         2  
  1         54  
11 1     1   483 use Encode;
  1         6974  
  1         62  
12 1     1   424 use Locale::Messages qw/ bind_textdomain_filter /;
  1         3371  
  1         61  
13 1     1   418 use Locale::TextDomain 'Time-Ago';
  1         1499  
  1         5  
14 1     1   4935 use Scalar::Util qw/ blessed /;
  1         3  
  1         72  
15              
16             our $VERSION = '1.00';
17              
18             BEGIN {
19 1     1   6 $ENV{OUTPUT_CHARSET} = 'UTF-8';
20 1         5 bind_textdomain_filter 'Time-Ago' => \&Encode::decode_utf8;
21             }
22              
23             use constant {
24 1         1035 MINUTES_IN_QUARTER_YEAR => 131400, # 91.25 days
25             MINUTES_IN_THREE_QUARTERS_YEAR => 394200, # 273.75 days
26             MINUTES_IN_YEAR => 525600,
27 1     1   32 };
  1         2  
28              
29              
30             sub new {
31 1     1 0 454 my $class = shift;
32              
33 1   33     7 $class = ref($class) || $class;
34 1         2 my $self = bless {}, $class;
35              
36 1         4 while (@_) {
37 0         0 my ($method, $val) = splice @_, 0, 2;
38 0 0       0 $self->$method(ref $val eq 'ARRAY' ? @$val : $val);
39             }
40              
41 1         5 return $self;
42             }
43              
44              
45             {
46             my %locale = (
47             about_x_hours => sub {
48             __nx('about {count} hour', 'about {count} hours', $_, count => $_);
49             },
50              
51             about_x_months => sub {
52             __nx('about {count} month', 'about {count} months', $_, count => $_);
53             },
54              
55             about_x_years => sub {
56             __nx('about {count} year', 'about {count} years', $_, count => $_);
57             },
58              
59             almost_x_years => sub {
60             __nx('almost {count} year', 'almost {count} years', $_, count => $_);
61             },
62              
63             half_a_minute => sub { __('half a minute') },
64              
65             less_than_x_minutes => sub {
66             __nx('less than a minute', 'less than {count} minutes', $_, count => $_);
67             },
68              
69             less_than_x_seconds => sub {
70             __nx(
71             'less than {count} second',
72             'less than {count} seconds',
73             $_,
74             count => $_,
75             );
76             },
77              
78             over_x_years => sub {
79             __nx('over {count} year', 'over {count} years', $_, count => $_);
80             },
81              
82             x_days => sub {
83             __nx('{count} day', '{count} days', $_, count => $_);
84             },
85              
86             x_minutes => sub {
87             __nx('{count} minute', '{count} minutes', $_, count => $_);
88             },
89              
90             x_months => sub {
91             __nx('{count} month', '{count} months', $_, count => $_);
92             },
93             );
94              
95             sub _locale {
96 134     134   186 my $self = shift;
97              
98             return sub {
99 134 50   134   294 my $string_id = shift or croak 'no string id supplied';
100 134         310 my %args = @_;
101              
102 134 50       313 my $sub = $locale{ $string_id }
103             or croak "unknown locale string_id '$string_id'";
104              
105 134         465 local $_ = $args{count};
106 134         249 return $sub->();
107 134         427 };
108             }
109             }
110              
111              
112             sub in_words {
113 134     134 1 496820 my $self = shift;
114 134 50       461 my %args = (@_ % 2 ? (duration => @_) : @_);
115              
116 134 50       369 defined $args{duration} or croak 'no duration supplied';
117 134         199 my $duration = $args{duration};
118              
119 134 100       357 if (blessed $duration) {
120 2 100       16 if ($duration->can('epoch')) { # DateTime/Time::Piece-like object
    50          
121 1         5 $duration = time - $duration->epoch;
122             } elsif ($duration->can('delta_months')) { # DateTime::Duration-like
123             # yes, we're treating every month as 30.41 days
124 1         5 $duration = ($duration->delta_months * (86400 * 365 / 12)) +
125             ($duration->delta_days * 86400) +
126             ($duration->delta_minutes * 60) +
127             $duration->delta_seconds
128             ;
129             }
130             }
131              
132 134     278   471 my $round = sub { int($_[0] + 0.5) };
  278         510  
133              
134 134         211 $duration = abs $duration;
135 134         353 my $mins = $round->($duration / 60);
136 134         241 my $secs = $round->($duration);
137              
138 134         279 my $locale = $self->_locale;
139              
140 134 100       324 if ($mins <= 1) {
141 103 100       215 unless ($args{include_seconds}) {
142 91 100       224 return $mins == 0 ?
143             $locale->('less_than_x_minutes', count => 1) :
144             $locale->('x_minutes', count => $mins)
145             ;
146             }
147              
148 12 100       29 return $locale->('less_than_x_seconds', count => 5) if $secs <= 4;
149 10 100       23 return $locale->('less_than_x_seconds', count => 10) if $secs <= 9;
150 8 100       21 return $locale->('less_than_x_seconds', count => 20) if $secs <= 19;
151 6 100       16 return $locale->('half_a_minute', count => 20) if $secs <= 39;
152 4 100       14 return $locale->('less_than_x_minutes', count => 1) if $secs <= 59;
153 2         6 return $locale->('x_minutes', count => 1);
154             }
155              
156 31 100       70 return $locale->('x_minutes', count => $mins) if $mins <= 44;
157 27 100       55 return $locale->('about_x_hours', count => 1) if $mins <= 89;
158              
159             # 90 mins up to 24 hours
160 25 100       55 if ($mins <= 1439) {
161 2         6 return $locale->('about_x_hours', count => $round->($mins/60));
162             }
163              
164             # 24 hours up to 42 hours
165 23 100       44 return $locale->('x_days', count => 1) if $mins <= 2519;
166              
167             # 42 hours up to 30 days
168 21 100       45 return $locale->('x_days', count => $round->($mins / 1440)) if $mins <= 43199;
169              
170             # 30 days up to 60 days
171 19 100       40 if ($mins <= 86399) {
172 4         9 return $locale->('about_x_months', count => $round->($mins / 43200));
173             }
174              
175             # 60 days up to 365 days
176 15 100       31 if ($mins <= 525600) {
177 2         5 return $locale->('x_months', count => $round->($mins / 43200));
178             }
179              
180             # XXX does not implement leap year stuff that Rails implementation has
181              
182 13         19 my $remainder = $mins % MINUTES_IN_YEAR;
183 13         23 my $years = int($mins / MINUTES_IN_YEAR);
184              
185 13 100       29 if ($remainder < MINUTES_IN_QUARTER_YEAR) {
186 4         10 return $locale->('about_x_years', count => $years);
187             }
188              
189 9 100       21 if ($remainder < MINUTES_IN_THREE_QUARTERS_YEAR) {
190 4         9 return $locale->('over_x_years', count => $years);
191             }
192            
193 5         13 return $locale->('almost_x_years', count => $years + 1);
194             }
195              
196              
197             1;
198              
199             __END__
200              
201             =pod
202              
203             =encoding UTF-8
204              
205             =head1 NAME
206              
207             Time::Ago - Approximate duration in words
208              
209             =head1 VERSION
210              
211             version 1.00
212              
213             =head1 SYNOPSIS
214              
215             use Time::Ago;
216              
217             print Time::Ago->in_words(0), "\n";
218             # prints "less than a minute"
219              
220             print Time::Ago->in_words(3600 * 4.6), "\n";
221             # prints "about 5 hours"
222            
223             print Time::Ago->in_words(86400 * 360 * 2), "\n";
224             # prints "almost 2 years"
225            
226             print Time::Ago->in_words(86400 * 365 * 11.3), "\n";
227             # prints "over 11 years"
228              
229             =head1 DESCRIPTION
230              
231             Given a duration, in seconds, returns a readable approximation.
232             This a Perl port of the time_ago_in_words() helper from Rails.
233              
234             From Rails' docs:
235              
236             0 <-> 29 secs
237             less than a minute
238              
239             30 secs <-> 1 min, 29 secs
240             1 minute
241              
242             1 min, 30 secs <-> 44 mins, 29 secs
243             [2..44] minutes
244              
245             44 mins, 30 secs <-> 89 mins, 29 secs
246             about 1 hour
247              
248             89 mins, 30 secs <-> 23 hrs, 59 mins, 29 secs
249             about [2..24] hours
250              
251             23 hrs, 59 mins, 30 secs <-> 41 hrs, 59 mins, 29 secs
252             1 day
253              
254             41 hrs, 59 mins, 30 secs <-> 29 days, 23 hrs, 59 mins, 29 secs
255             [2..29] days
256              
257             29 days, 23 hrs, 59 mins, 30 secs <-> 44 days, 23 hrs, 59 mins, 29 secs
258             about 1 month
259              
260             44 days, 23 hrs, 59 mins, 30 secs <-> 59 days, 23 hrs, 59 mins, 29 secs
261             about 2 months
262              
263             59 days, 23 hrs, 59 mins, 30 secs <-> 1 yr minus 1 sec
264             [2..12] months
265              
266             1 yr <-> 1 yr, 3 months
267             about 1 year
268              
269             1 yr, 3 months <-> 1 yr, 9 months
270             over 1 year
271              
272             1 yr, 9 months <-> 2 yr minus 1 sec
273             almost 2 years
274              
275             2 yrs <-> max time or date
276             (same rules as 1 yr)
277              
278             =head1 METHODS
279              
280             =over 4
281              
282             =item in_words
283              
284             Time::Ago->in_words(30); # returns "1 minute"
285             Time::Ago->in_words(3600 * 24 * 365 * 10); # returns "about 10 years"
286              
287             Given a duration, in seconds, returns a readable approximation in words.
288              
289             If an include_seconds parameter is supplied, durations under one minute
290             generate more granular phrases:
291              
292             foreach (4, 9, 19, 39, 59) {
293             print Time::Ago->in_words($_, include_seconds => 1), "\n";
294             }
295              
296             # less than 5 seconds
297             # less than 10 seconds
298             # less than 20 seconds
299             # half a minute
300             # less than a minute
301              
302             As a convenience, if the duration is an object with an epoch() interface
303             (as provided by Time::Piece or DateTime), the current time minus the
304             object's epoch() seconds is used.
305              
306             Passing the duration as a DateTime::Duration instance is also supported.
307              
308             =back
309              
310             =head1 LOCALIZATION
311              
312             Locale::TextDomain is used for localization.
313              
314             Currently Arabic, Dutch, English, French, German, Italian, Japanese, Russian,
315             and Spanish translations are available. Contact me if you need another
316             language.
317              
318             See L<Locale::TextDomain> for how to specify a language.
319              
320             #!/usr/bin/env perl
321            
322             use strict;
323             use warnings;
324             use open qw/ :std :utf8 /;
325             use POSIX ':locale_h';
326             use Time::Ago;
327            
328             my $secs = 86400 * 365 * 10.4;
329            
330             foreach (qw/ en fr de it ja ru es /) {
331             setlocale(LC_ALL, '');
332             $ENV{LANGUAGE} = $_;
333             print Time::Ago->in_words($secs), "\n";
334             }
335              
336             Output:
337              
338             over 10 years
339             plus de 10 ans
340             vor mehr als 10 Jahren
341             oltre 10 anni
342             10年以上
343             больше 10 лет
344             más de 10 años
345              
346             =head1 BUGS
347              
348             The rails' implementation includes some logic for leap years that is not
349             implemented here.
350              
351             =head1 CREDITS
352              
353             Ruby on Rails DateHelper
354             L<http://apidock.com/rails/v4.2.1/ActionView/Helpers/DateHelper/distance_of_time_in_words>
355              
356             Ruby i18n library
357             L<https://github.com/svenfuchs/i18n>
358              
359             =head1 SEE ALSO
360              
361             Github repository L<https://github.com/mla/time-ago>
362              
363             L<Time::Duration>, L<DateTime::Format::Human::Duration>, L<Locale::TextDomain>
364              
365             =head1 AUTHOR
366              
367             Maurice Aubrey <maurice.aubrey@gmail.com>
368              
369             =head1 COPYRIGHT AND LICENSE
370              
371             This software is copyright (c) 2017 by Maurice Aubrey.
372              
373             This is free software; you can redistribute it and/or modify it under
374             the same terms as the Perl 5 programming language system itself.
375              
376             =cut