1use warnings;
2use strict;
3
4package Jifty::DateTime;
5
6=head1 NAME
7
8Jifty::DateTime - a DateTime subclass that knows about Jifty users
9
10=head1 SYNOPSIS
11
12  use Jifty::DateTime;
13
14  # Get the current date and time
15  my $dt = Jifty::DateTime->now;
16
17  # Print out the pretty date (i.e., today, tomorrow, yesterday, or 2007-09-11)
18  Jifty->web->out( $dt->friendly_date );
19
20  # Better date parsing
21  my $dt_from_human = Jifty::DateTime->new_from_string("next Saturday");
22
23=head1 DESCRIPTION
24
25Jifty natively stores timestamps in the database in GMT.  Dates are
26stored without timezone. This class loads and parses dates and sets
27them into the proper timezone.
28
29To use this DateTime class to it's fullest ability, you'll need to add
30a C<time_zone> method to your application's user object class. This is
31the class returned by L<Jifty::CurrentUser/user_object>. It must
32return a value valid for using as an argument to L<DateTime>'s
33C<set_time_zone()> method.
34
35=cut
36
37BEGIN {
38    # we spent about 30% of the time in validate during 'require
39    # DateTime::Locale' which isn't necessary at all
40    require Params::Validate;
41    no warnings 'redefine';
42    local *Params::Validate::validate = sub { pop @_, return @_ };
43    require DateTime::Locale;
44}
45
46use base qw(Jifty::Object DateTime);
47
48use Jifty::DBI::Schema;
49Jifty::DBI::Schema->register_types(
50    timestamp => sub {
51        encode_on_select is 1,
52        type is 'timestamp',
53        filters are qw( Jifty::Filter::DateTime Jifty::DBI::Filter::DateTime ),
54    },
55);
56
57=head2 new ARGS
58
59See L<DateTime/new>. If we get what appears to be a date, then we keep this in
60the floating datetime. Otherwise, set this object's timezone to the current
61user's time zone, if the current user's user object has a method called
62C<time_zone>.
63
64=cut
65
66sub new {
67    my $class = shift;
68    my %args  = (
69        current_user     => undef,
70        time_zone        => undef,
71        input_time_zone  => undef,
72        output_time_zone => undef,
73        @_,
74    );
75
76    my ($input_time_zone, $output_time_zone);
77    $input_time_zone = delete($args{input_time_zone})   || $args{time_zone};
78    $output_time_zone = delete($args{output_time_zone}) || $args{time_zone};
79
80    my $current_user = delete $args{current_user};
81
82    my $self = $class->SUPER::new(%args, time_zone => $input_time_zone);
83
84    my $is_date = $self->hms eq '00:00:00'
85               && $self->time_zone->name eq 'floating';
86
87    # The output time_zone is the *current user's* time zone. It's okay that
88    # $current_user can be undef; we'll still find and set the right current
89    # user then set the time zone.
90    $self->current_user($current_user);
91
92    if ($output_time_zone) {
93        $self->set_time_zone($output_time_zone);
94    }
95    # If we were given a date, then we need to make sure its output time zone
96    # is Floating and it's set to 00:00:00.
97    # This sucks when you want a timestamp (not just a datestamp) at midnight
98    # in the floating time zone but we don't have any better way to make this
99    # work.
100    elsif ($is_date) {
101        $self->set_time_zone('floating');
102
103        # Without this check we loop infinitely, because set_hour constructs
104        # a new Jifty::DateTime object.
105        if ($self->hms ne '00:00:00') {
106            $self->set_hour(0);
107            $self->set_minute(0);
108            $self->set_second(0);
109        }
110    }
111
112    return $self;
113}
114
115=head2 now ARGS
116
117See L<DateTime/now>. If a time_zone argument is passed in, then this wrapper
118is effectively a no-op.
119
120OTHERWISE this will always set this object's timezone to the current user's
121timezone. Without this, DateTime's C<now> will set the timezone to UTC always
122(by passing C<< time_zone => 'UTC' >> to C<Jifty::DateTime::new>. We want
123Jifty::DateTime to always reflect the current
124user's timezone (unless otherwise requested, of course).
125
126=cut
127
128sub now {
129    my $class = shift;
130    my %args  = (
131        current_user => undef,
132        #time_zone => undef, # DateTime doesn't like undef time_zone
133        @_,
134    );
135
136    my $current_user = delete $args{current_user};
137    my $self = $class->SUPER::now(%args);
138
139    $self->current_user($current_user);
140
141    # We set time_zone here since saying
142    # "Jifty::DateTime->now(time_zone => 'UTC')" is obviously referring the
143    # output time zone; the input time zone doesn't matter at all.
144    $self->set_time_zone($args{time_zone}) if $args{time_zone};
145
146    return $self;
147}
148
149=head2 from_epoch ARGS
150
151See L<DateTime/from_epoch> and L<Jifty::DateTime/now>. This handles the common
152mistake of C<from_epoch($epoch)> as well.
153
154=cut
155
156sub from_epoch {
157    my $class = shift;
158
159    # from_epoch(100) should dwim
160    unshift @_, 'epoch' if @_ == 1;
161
162    my %args  = (
163        current_user => undef,
164        #time_zone => undef, # DateTime doesn't like undef time_zone
165        @_,
166    );
167
168    my $current_user = delete $args{current_user};
169    my $self = $class->SUPER::from_epoch(%args);
170
171    $self->current_user($current_user);
172
173    # We set time_zone here since saying
174    # "Jifty::DateTime->now(time_zone => 'UTC')" is obviously referring the
175    # output time zone; the input time zone doesn't matter at all.
176    $self->set_time_zone($args{time_zone}) if $args{time_zone};
177
178    return $self;
179}
180
181=head2 current_user [CURRENTUSER]
182
183When setting the current user, update the timezone appropriately.
184
185If an C<undef> current user is passed, this method will find the correct
186current user and set the time zone.
187
188=cut
189
190sub current_user {
191    my $self = shift;
192    return $self->SUPER::current_user unless @_;
193
194    # $date->current_user(undef) will not remove the current user, but it will
195    # calculate who the current user is for setting the time zone
196    if (@_ == 1 && !defined($_[0])) {
197        shift;
198        $self->_get_current_user;
199    }
200
201    my $ret = $self->SUPER::current_user(@_);
202
203    $self->set_current_user_timezone();
204    return $ret;
205}
206
207=head2 current_user_has_timezone
208
209Return timezone if the current user has one. This is determined by
210checking to see if the current user has a user object. If it has a
211user object, then it checks to see if that user object has a
212C<time_zone> method and uses that to determine the value.
213
214=cut
215
216sub current_user_has_timezone {
217    my $self = shift;
218
219    # make this work as Jifty::DateTime->current_user_has_timezone
220    my $dt = ref($self) ? $self : $self->now;
221
222    $dt->_get_current_user();
223
224    # Can't continue if we have no notion of a user_object
225    $dt->current_user->can('user_object') or return;
226
227    # Can't continue unless the user object is defined
228    my $user_obj = $dt->current_user->user_object or return;
229
230    # Check for a time_zone method and then use it if it exists
231    my $f = $user_obj->can('time_zone') || $user_obj->can('timezone')
232        or return;
233
234    return $f->($user_obj);
235}
236
237=head2 set_current_user_timezone [DEFAULT_TZ]
238
239=head2 set_current_user_time_zone [DEFAULT_TZ]
240
241Set this Jifty::DateTime's timezone to the current user's timezone. If that's
242not available, then use the passed in DEFAULT_TZ (or GMT if not passed in).
243Returns the Jifty::DateTime object itself.
244
245If your subclass changes this method, please override
246C<set_current_user_timezone> not C<set_current_user_time_zone>, since the
247latter is merely an alias for the former.
248
249=cut
250
251sub set_current_user_timezone {
252    my $self    = shift;
253    my $default = shift || Jifty->config->framework('Timezone') || 'UTC';
254    my $tz = $self->current_user_has_timezone || $default;
255
256    $self->set_time_zone($tz);
257    return $self;
258}
259
260sub set_current_user_time_zone { shift->set_current_user_timezone(@_) }
261
262=head2 new_from_string STRING[, ARGS]
263
264Take some user defined string like "tomorrow" and turn it into a
265C<Jifty::Datetime> object. If a C<time_zone> argument is passed in, that is
266used for the B<input> time zone.
267
268If the string appears to be a _date_, the B<output> time zone will be floating.
269Otherwise, the B<output> time zone will be the current user's time zone.
270
271As of this writing, this uses L<Date::Manip> along with some internal
272hacks to alter the way L<Date::Manip> normally interprets week day
273names. This may change in the future.
274
275=cut
276
277sub new_from_string {
278    my $class  = shift;
279    my $string = shift;
280    return unless $string;
281    my %args = (
282        time_zone => undef,
283        @_,
284    );
285
286    my $epoch;
287
288    # Hack to use Date::Manip to flexibly scan dates from strings
289    {
290        # Date::Manip interprets days of the week (eg, ''Monday'') as
291        # days within the *current* week. Detect these and prepend
292        # ``next''
293        # XXX TODO: Find a real solution (better date-parsing library?)
294        if($string =~ /^\s* (?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)$/xi) {
295            $string = "next $string";
296        }
297
298        my $offset = $class->get_tz_offset(
299            $args{time_zone} ? (time_zone => $args{time_zone}) : (),
300        );
301
302        require Date::Manip;
303
304        # ForceDate forces the current date to be now in the user's timezone,
305        #    if we don't set it then DM uses the machine's timezone
306        Date::Manip::Date_Init("ForceDate=now,$offset");
307        $epoch = Date::Manip::UnixDate( $string, "%o" );
308    }
309
310    # Stop here if Date::Manip couldn't figure it out
311    return undef unless $epoch;
312
313    # Build a DateTime object from the Date::Manip value and setup the TZ
314    my $self = $class->from_epoch( epoch => $epoch, time_zone => 'UTC' );
315    if (my $tz = $self->current_user_has_timezone) {
316        if ($self->hms(':') ne '00:00:00') {
317            $self->set_time_zone($tz);
318        }
319        else {
320            $self->set_time_zone("floating");
321        }
322    }
323
324    return $self;
325}
326
327=head2 friendly_date
328
329Returns the date given by this C<Jifty::DateTime> object. It will display "today"
330for today, "tomorrow" for tomorrow, or "yesterday" for yesterday. Any other date
331will be displayed in C<ymd> format.
332
333We currently shift by "24 hours" to detect yesterday and tomorrow, rather than
334"1 day" because of daylight saving issues. "1 day" can result in invalid local
335time errors.
336
337=cut
338
339sub friendly_date {
340    my $self = shift;
341    my $ymd = $self->ymd;
342
343    # Use the current user's time zone on the date
344    my $tz = $self->current_user_has_timezone || $self->time_zone;
345    my $rel = DateTime->now( time_zone => $tz );
346
347    # Is it today?
348    if ($ymd eq $rel->ymd) {
349        return "today";
350    }
351
352    # Is it yesterday?
353    my $yesterday = $rel->clone->subtract(hours => 24);
354    if ($ymd eq $yesterday->ymd) {
355        return "yesterday";
356    }
357
358    # Is it tomorrow?
359    my $tomorrow = $rel->clone->add(hours => 24);
360    if ($ymd eq $tomorrow->ymd) {
361        return "tomorrow";
362    }
363
364    # None of the above, just spit out the date
365    return $ymd;
366}
367
368=head2 is_date
369
370Returns whether or not this C<Jifty::DateTime> object represents a date
371(without a specific time). Dates in Jifty are in the floating time zone and
372are set to midnight.
373
374=cut
375
376sub is_date {
377    my $self = shift;
378
379    # all dates are in the floating time zone
380    return 0 unless $self->time_zone->name eq 'floating';
381
382    # all dates are set to midnight
383    return 0 unless $self->hms eq '00:00:00';
384
385    return 1;
386}
387
388=head2 get_tz_offset
389
390Returns the offset for a time zone. If there is no current
391user, or the current user's time zone is unset, then UTC will be used.
392
393The optional datetime argument lets you calculate an offset for some time other
394than "right now".
395
396=cut
397
398sub get_tz_offset {
399    my $self = shift;
400    my %args = (
401        datetime  => DateTime->now,
402        time_zone => $self->current_user_has_timezone || 'UTC',
403        @_,
404    );
405
406    my $dt = $args{datetime}->clone;
407
408    $dt->set_time_zone($args{time_zone});
409    return $dt->strftime("%z");
410}
411
412=head2 jifty_serialize_format
413
414This returns a DateTime (or string) consistent with Jifty's date format.
415
416=cut
417
418sub jifty_serialize_format {
419    my $dt = shift;
420
421    # if it looks like just a date, then return just the date portion
422    return $dt->ymd if $dt->is_date;
423
424    # otherwise let stringification take care of it
425    return $dt;
426}
427
428=head1 WHY?
429
430There are other ways to do some of these things and some of the
431decisions here may seem arbitrary, particularly if you read the
432code. They are.
433
434These things are valuable to applications built by Best Practical
435Solutions, so it's here. If you disagree with the policy or need to do
436it differently, then you probably need to implement something yourself
437using a DateTime::Format::* class or your own code.
438
439Parts may be cleaned up and the API cleared up a bit more in the future.
440
441=head1 SEE ALSO
442
443L<DateTime>, L<DateTime::TimeZone>, L<Jifty::CurrentUser>
444
445=head1 LICENSE
446
447Jifty is Copyright 2005-2010 Best Practical Solutions, LLC.
448Jifty is distributed under the same terms as Perl itself.
449
450=cut
451
4521;
453