Attached is the first Beta of DateTime::Event::Easter. Play with it. Poke
it. If something doesn't work like you expect, or you want it to work
differently, let me know.

The POD should explain how it all works.

There's a few TODOs including ->set($dt) returning a DateTime::Set. Also,
Astronomical Paschal Full Moon calculations need to be added. I'm working on
finding a reliable source for the algorithm. Anyone with Calendrical Calcs
tell me if there's an algorithm therein?


ANNOUNCE: Once I release this I'll be releasing DateTime::Event::Catholic
which will be similar in interface to DateTime::Event::Easter (and will in
fact rely on ::Easter). Calculations will come initially from
http://www.saradouglass.com/relfest.html, so if you think the name needs
changing, or if the site I'm using for calculations is wrong, or missing
info, please let me know. I'm just a little Baptist minister's kid who goes
to Mass. This will really make dad's toenails curl :). I'll also add a
method to find the nearest event without specifying what it is.
(DateTime::Event::Easter's constructor allows you to work with associated
days like Good Friday. With DateTime::Event::Catholic, I'll do the same,
however I'd really like to be able to get the previous, following or closest
event, whatever it is. Which means I'll put an 'any' option in .. should I
do this for ::Easter as well?)

Cheers!
Rick
--------------------------------------------------------
             There are 10 kinds of people:
   those that understand binary, and those that don't.
--------------------------------------------------------
   The day Microsoft makes something that doesn't suck
     is the day they start selling vacuum cleaners
--------------------------------------------------------


package DateTime::Event::Easter;
use DateTime;
use Carp;
use Params::Validate qw( validate SCALAR BOOLEAN OBJECT );

use strict;
use vars qw(
        $VERSION @ISA @EXPORT @EXPORT_OK 
        $earliest $latest $zonestart $zoneend %official_easter_cache 
%astronomical_easter_cache
);

require Exporter;

@ISA = qw(Exporter);

@EXPORT_OK = qw(easter);
$VERSION = '0.01';


$earliest = '0321';             # Earliest Easter is March 21st
$latest = '0425';               # Latest Easter is April 18

$zonestart = '0920';    # In the 'zone' we need to check both the
$zoneend = '1025';              #   previous and next to find the closest


sub new {
        my $class = shift;
        
    my %args  = validate( @_,
                                        {       fullmoon        => { type => SCALAR, 
default=>'official', optional=>1, regex => qr/^(official|astronomical)$/ },
                                                day                     => { type => 
SCALAR, default=>'sunday', optional=>1 },
                                        }
                                );
        
        my %self;
        my $offset;
        if ($args{day} =~/^palm/i) {
                $offset = -7;
        } elsif ($args{day} =~/saturday/i) {
                $offset = -1;
        } elsif ($args{day} =~/friday/i) {
                $offset = -1;
        } elsif ($args{day} =~/thursday/i) {
                $offset = -1;
        } elsif ($args{day} =~/^\-?\d+$/i) {
                $offset = $args{day};
        } else {
                $offset = 0;
        }
        $self{offset} = DateTime::Duration->new(days=>$offset);
        
        return bless \%self, $class;
}


sub following {
        my $self = shift;
        my $dt = shift;
        croak ("Dates need to be DateTime objects (".ref($dt).")") unless (ref($dt) eq 
'DateTime');
        my $dtmd = _monthday($dt);
        
        if ($self->is($dt)) {
                return easter($dt->year+1)+$self->{offset};
        } elsif ($dtmd < $earliest) {
                return easter($dt->year)+$self->{offset};
        } elsif ($dtmd > $latest) {
                return easter($dt->year + 1)+$self->{offset};
        } else {
                my $thisyear = easter($dt->year)+$self->{offset};
                return ($thisyear > $dt) ? $thisyear : easter($dt->year + 
1)+$self->{offset};
        }
}

sub previous {
        my $self = shift;
        my $dt = shift;
        croak ("Dates need to be DateTime objects (".ref($dt).")") unless (ref($dt) eq 
'DateTime');
        my $dtmd = _monthday($dt);
        
        if ($self->is($dt)) {
                return easter($dt->year-1)+$self->{offset};
        } elsif ($dtmd gt $latest) {
                return easter($dt->year)+$self->{offset};
        } elsif ($dtmd lt $earliest) {
                return easter($dt->year - 1)+$self->{offset};
        } else {
                my $thisyear = easter($dt->year)+$self->{offset};
                return ($thisyear < $dt) ? $thisyear : easter($dt->year - 
1)+$self->{offset};
        }
}

sub closest {
        my $self = shift;
        my $dt = shift;
        croak ("Dates need to be DateTime objects (".ref($dt).")") unless (ref($dt) eq 
'DateTime');
        my $dtmd = _monthday($dt);
        
        if ($dtmd gt $zoneend) {
                return easter($dt->year + 1)+$self->{offset};
        } elsif ($dtmd lt $zonestart) {
                return easter($dt->year)+$self->{offset};
        } else {
                my $previous = $self->previous($dt);
                my $previous_delta_duration = ($dt - $previous);

                my $following = $self->following($dt);
                my $following_delta_duration = ($dt - $following);

                return ($following < $previous) ? $following : $previous;
        }
}

sub is {
        my $self = shift;
        my $dt = shift;
        croak ("Dates need to be DateTime objects (".ref($dt).")") unless (ref($dt) eq 
'DateTime');
        my $dtmd = _monthday($dt);
        
        #my $thisyear = easter($dt->year);
        #return ($thisyear->truncate(to=>'day') eq $dt->truncate(to=>'day')) ? 1 : 0;

        return (_monthday(easter($dt->year)+$self->{offset}) eq _monthday($dt)) ? 1 : 
0;
        
}

sub set {
        my $self = shift;
    my %args  = validate( @_,
                                        {       from            => { type => OBJECT },
                                                to                      => { type => 
OBJECT },
                                                inclusive       => { type => SCALAR, 
default=>0 },
                                        }
                                );
                                
        my @set = ();
        
        if ($args{inclusive}) {
                if ($self->is($args{from})) {
                        push(@set,$args{from});
                }
                if ($self->is($args{to})) {
                        push(@set,$args{to});
                }
        }
        
        my $checkdate = $args{from};
        $|++;
        while ($checkdate < $args{to}) {
                print "Checking $checkdate\n";
                $checkdate = $self->following($checkdate);
                push(@set,$checkdate) if ($checkdate < $args{to});
        }
        
        print join("\n# ", sort @set);
        
        return sort @set;
        # Later we'll use this:
        # return DateTime::Set->new( dates => [ sort @set ] );
}

sub _monthday {
        my $dt = shift;
        return sprintf("%02d%02d", $dt->month, $dt->day);
}

sub _easter {
        my $self = shift;
        my $year = shift;
        
        return ($self->{fullmoon} eq 'astronomical') 
                ? astronomical_easter($year) 
                : official_easter($year);
}

sub official_easter {
        my $year = shift;
        croak "Year value '$year' should be numeric." if $year=~/\D/;
#       print "\nYear: $year\n";
        
        if ($official_easter_cache{$year}) {
#               print "Have cache for $year ($official_easter_cache{$year})\n";
                return $official_easter_cache{$year};
        }
        
        my $golden_number = $year % 19;
#       print "G: $golden_number\n";
        my $quasicentury = int($year / 100);
#       print "C: $quasicentury\n";
        my $epact = ($quasicentury - int($quasicentury/4) - int(($quasicentury * 8 + 
13)/25) + ($golden_number*19) + 15) % 30;
#       print "H: $epact\n";
        my $interval = $epact - int($epact/28)*(1 - int(29/($epact+1)) * int((21 - 
$golden_number)/11) );
#       print "I: $interval\n";
        my $weekday = ($year + int($year/4) + $interval + 2 - $quasicentury + 
int($quasicentury/4)) % 7;
#       print "J: $weekday\n";
        
        my $offset = $interval - $weekday;
#       print "L: $offset\n";
        my $month = 3 + int(($offset+40)/44);
#       print "Month: $month\n";
        my $day = $offset + 28 - 31* int($month/4);
#       print "Day: $day\n";
        
        $official_easter_cache{$year} = DateTime->new(year=>$year, month=>$month, 
day=>$day);
        return $official_easter_cache{$year};
}
*easter = \&official_easter; #alias so people can call 'easter($year)' externally

sub astronomical_easter {
        my $year = shift;
        croak "Year value '$year' should be numeric." if $year=~/\D/;

        croak "We don't support the astronomical full-moon yet. Sorry";
}

1;
__END__

=head1 NAME

DateTime::Event::Easter - Returns Easter events for DateTime objects

=head1 SYNOPSIS

  use DateTime::Event::Easter;
  
  $dt = DateTime->new( year   => 2002,
                       month  => 3,
                       day    => 31,
                     );
  
  
  $easter_sunday = DateTime::Event::Easter->new();

  $previous_easter_sunday = $easter_sunday->previous($dt);
  # Sun, 15 Apr 2001 00:00:00 UTC
  
  $following_easter_sunday = $easter_sunday->following($dt);
  # Sun, 20 Apr 2003 00:00:00 UTC
  
  $closest_easter_sunday = $easter_sunday->closest($dt);
  # Sun, 31 Mar 2002 00:00:00 UTC
  
  $is_easter_sunday = $easter_sunday->is($dt);
  # 1
  
  $palm_sunday = DateTime::Event::Easter->new(day=>'Palm Sunday');


  $dt2 = DateTime->new( year   => 2012,
                        month  => 3,
                        day    => 31,
                      );
  
  @set = $palm_sunday->set(from=>$dt, to=>$dt2, inclusive=>1);
  # Sun, 13 Apr 2003 00:00:00 UTC
  # Sun, 04 Apr 2004 00:00:00 UTC
  # Sun, 20 Mar 2005 00:00:00 UTC
  # Sun, 09 Apr 2006 00:00:00 UTC
  # Sun, 01 Apr 2007 00:00:00 UTC
  # Sun, 16 Mar 2008 00:00:00 UTC
  # Sun, 05 Apr 2009 00:00:00 UTC
  # Sun, 28 Mar 2010 00:00:00 UTC
  # Sun, 17 Apr 2011 00:00:00 UTC
  
=head1 DESCRIPTION

The DateTime::Event::Easter module returns Easter events for DateTime objects.
From a given datetime, it can tell you the previous, the following and the closest
Easter event. The 'is' method will tell you if the given DateTime is an Easter Event.

Easter Events can be Palm Sunday, Maundy Thursday, Good Friday, Black Saturday and
Easter Sunday. If that's not enough, the module will also accept an offset so you can
get the date for Pentecost (49 days after Easter Sunday) by passing 49.


=head1 BACKGROUND

Easter Sunday is the Sunday following the first full moon on or following the Official
Vernal Equinox. The Official Vernal Equinox is March 21st. Easter Sunday is never on 
the full moon. Thus the earliest Easter can be is March 22nd.

=head1 CONSTRUCTOR

This class accepts the following options to its 'new' constructor:

=over 4

=item * fullmoon => ([official]|astronomical)

The Catholic and Protestant churches use the 'official' date for the Paschal Full Moon
which can differ by a few days from the Astronomical Paschal Full Moon. The Orthodox
churches use the Astronomical Paschal Full Moon for Easter calculations which is why
Easter is sometimes celebrated on a different day.

By default this module uses the Official Paschal Full Moon.

In the future it is planned to allow an option of 'auto' here. This will take a look at
the DateTime object's timezone to determine if a country follows the Official or the
Astronomical method.

=item * day => ([Easter Sunday]|Palm Sunday|Maundy Thursday|Good Friday|Black 
Saturday|I<n>)

When constructed with a day parameter, the method can return associated Easter days 
other
than Easter Sunday. The constructor also allows an integer to be passed here as an 
offset.
For example, Maundy Thursday is the same as an offset of -3 (Three days before Easter
Sunday)

This option allows the following abreviations:
day => ([Sunday]|Palm|Thursday|Friday|Saturday)

Other offset which rely on Easter might be added at later times. On the other hand, 
some
of them belong in another module DateTime::Event::ChurchYear. For the record here are 
some
offsets you might use:

=over 4

=item * 49 - Pentecost

=back

=back

=head1 METHODS

This class offers the following methods.

=over 4

=item * following($dt)

Returns the DateTime object for the Easter Event after $dt. This will not return $dt.

=item * previous($dt)

Returns the DateTime object for the Easter Event before $dt. This will not return $dt.

=item * closest($dt)

Returns the DateTime object for the Easter Event closest to $dt. This will return 
midnight
of $dt if $dt is the Easter Event.

=item * is($dt)

Return positive (1) if $dt is the Easter Event, otherwise returns false (0)

=item * set(from => $dt, to => $dt2, inclusive=>I<([0]|1)>)

Returns a list of Easter Events between I<to> and I<from>. If the optional I<inclusive>
parameter is true (non-zero), the to and from dates will be checked.

In the (near) future, this method will return a DateTime::Set object.

=back

=head1 EXPORTS

This class does not export any methods by default, however the following exports are
supported.

=over 4

=item * easter($year)

Given a year, this method will return a DateTime object for Easter Sunday in that year.

This method uses the Official Paschal Moon.

=back

=head1 AUTHOR

Rick Measham <[EMAIL PROTECTED]>

=head1 SEE ALSO

L<DateTime>, perl(1).

Reply via email to