                    Managing Dates in dBASE

        Dates are difficult.  It's the calendar's fault, not that of
dBASE IV, but that's small comfort when a seemingly simple problem
begins to look like the Big Muddy.  The general rule is to use the
considerable date-arithmetic powers of dBASE IV when possible, but
always to remember that particular dates cannot be accurately found
through simple averaging formulas.

        Perhaps the most common problem with dates is finding the
correct age of someone or something:

*  Find age in years between first date and second date.

FUNCTION age                    && returns a number (of years )
PARAMETERS D1, D2               && takes two dates
PRIVATE Years
Years = year( D2 ) - year( D1 )
DO CASE
  CASE month( D2 ) > month( D1 )
    RETURN Years
  CASE month( D2 ) < month( D1 )
    RETURN Years - 1
  CASE day( D2 ) < month( D1 )
    RETURN Years - 1
  OTHERWISE
    RETURN Years
ENDCASE

        There are innumerable variations on the above; all correct ones
make use of the month() and day() of the dates to determine whether or
not the anniversary date has passed.  It is simply not possible to use a
formula like ( D2 - D1 ) / 365.25 to do this accurately, because some
pairs of dates 365 days apart are a full year apart but others are not.

        It can be used to find out whether or not an anniversary of
a date occurs in a range of dates, useful for churches and organizations
wanting to reach someone on or near his or her birthday:

*  Returns .T. if and only if an anniversary of date Test occurs in
*  the period from Beg to End, inclusive.  Returns .F. in all other
*  cases including invalid ranges or blank dates.
*  By David Love ( BORBBS Davidlove ) & Jay Parsons ( BORBBS Jparsons )

FUNCTION annivrsry                          && returns a logical
PARAMETERS Test, Beg, End                   && takes three dates
PRIVATE Yrs
Yrs = 0
IF Beg <= End .AND. Test <= End             && will be false if blank
  Yrs = age( Test, End ) - iif( Test < Beg, age( Test, Beg - 1 ) , 0 )
ENDIF
RETURN Yrs > 0

        A similar problem is to find the date exactly one or more months
ahead of a given date, as for determining when the next of a series of
periodic payments is due:

*  Find same day as given date N months ahead.

FUNCTION addmonths                && returns a date
PARAMETERS Mdate, N               && takes a date and a number
PRIVATE Newdate, Testdate
Newdate = Mdate - day( Mdate )+ 15 + 30.436875 * N  && middle of month
Testdate = Newdate - day( Newdate ) + day( Mdate )
RETURN iif( month( Testdate ) = month( Newdate ), Testdate, ;
     Testdate - day( Testdate ) + iif( N > 0, 1, 0 ) )

        The above function will return the first day of the following
month if there is no date in the month otherwise returned and N is
positive, or the last day of the month if N is negative.  That is, a
call with {01/31/91} ( January 31, 1991 ) and 1 would yield March 1,
there being no February 31.  If your application requires that the date
returned in such a case be the last day of February instead, change the
">" to "<" in the RETURN line.  The value 30.436875, which is 365.2425 /
12, is the average number of days in a month, but of course a much less
accurate value could be used in most cases since any error of less than
14 days will be immaterial.

        Do not use the above function successively to find first the
date one month ahead, then the date one month beyond that.  Instead,
to find the date two months ahead from the original date, call this
function with the original date and N = 2.  Otherwise, in the example,
you'll get April 1 the second time rather than the correct March 31.

        Similarly, to advance a date by a number of years:

FUNCTION addyears               && returns a date
PARAMETERS Mdate, N             && takes a date and a number
RETURN stod( str( year( Mdate ) + N ) ;
       + right( str( month( Mdate ) + 100 ), 2 ) ;
       + right( str( day( Mdate ) + 100 ), 2 ) )

        The above function makes use of the stod() function, below,
to be independent of the SET DATE format.  If you know that it will
be used only with MDY or DMY formats, you can simplify it:

FUNCTION addyrs                 && not ANSI or YMD format
PARAMETERS Mdate, N
RETURN ctod( left( dtoc( Mdate ), 6 ) ;
    + ltrim( str( year( Mdate ) + N ) ) )

        Using either of the above functions, dBASE IV will take care of
converting February 29 to March 1 if moving from a leap to a non-leap
year.  However, neither may be used backwards ( negative value of N )
since the date a year before February 29, 1992 will be returned as March
1, 1991, not February 28, 1991.  If you must move back, either check
explicitly for February 29 as the original date or add code as in the
addmonths() function to test for the date returned being of a different
month than the original and, if it is, to subtract its day().

                          Julian Numbers

        Until the dBASE date "type" arrived with dBASE III, date
arithmetic was an agonizing matter requiring complex and esoteric
routines to convert dates into numbers and back again.

        Those days are fortunately behind us.  Date-type variables and
fields may now be subtracted from one another, or added to or subtracted
from numbers, directly.  dBASE stores dates in two formats, both well
chosen, and converts from one to another with almost no effort by the
user.

        In .dbf and index files, dates are stored in ASCII characters as
YYYYMMDD, the format returned by the dBASE IV dtos() function.  This
format assures that the ASCII values of the characters in dates will be
ordered the same way as the dates, simplifying using dates as part of an
index key.

        In memory or in .mem files, dates are stored as float-type
numbers.  Consecutive dates have consecutive numbers, enabling dBASE to
do its date arithmetic easily.  For reasons I don't know other than that
it is so far past the expected end of the universe that it could never
be a real date, a blank date is represented by the value of a googol,
10^100.

        To test for a date being blank, test for the month() being 0 or
simply {} being equal to the date in dBASE IV. Don't attempt to compare
two dates by the relational operators, "<" and the like, without first
checking for them being blank.  If one and only one of the dates is
blank the result of the comparison will be .F. whatever the relational
operator or order of comparison.

        The numbers used by dBASE for non-blank dates are those of the
Julian period devised by Joseph Justus Scaliger in 1582, the year the
Roman Catholic countries of western Europe adopted the calendar we use
today. Scaliger chose January 1, 4713 B.C., as date 1, believing that
this date predated the formation of the universe and that no earlier
dates would ever be needed.  Scaliger named his period after his father,
Julius Caesar Scaliger, causing no small amount of confusion with the
essentially unrelated Julian calendar then being replaced.

                Spreadsheet Date Conversion

        You can, although you probably won't need to except for
spreadsheets as indicated below, convert dates to and from Julian.
January 1, 1901, was day 2,415,386 of the dBASE Julian period, so the
following functions will do the conversion.

FUNCTION dat2jul                        && returns a number ( of days )
PARAMETERS Date                         && takes a date
RETURN 2415386 + Date - ctod("01/01/01")

FUNCTION jul2dat                        && returns a date
PARAMETERS Julian                       && takes a number ( of days )
RETURN ctod("01/01/01") + Julian - 2415386

        These functions work for dates before or after January 1, 1901,
which was chosen as the base date simply to make the functions
independent, during this century, of the date format specified by SET
DATE.  Don't use the Julian dates returned for astronomy without
understanding that the setting of your computer's clock to your local
time may cause a day's discrepancy requiring adjustment.

        Spreadsheets typically use some date such as December 30, 1899
as the base date.  It doesn't matter. Record the number shown by your
spreadsheet program for the date January 1, 1901, entered as
@DATE(1,1,1) or as appropriate but without formatting the cell for a
date.  The number will often be 366.  Substitute it for 2415386 in the
functions above.  You can then use the functions to convert dBASE
date-type fields to and from the numbers used for dates by the
spreadsheet program, which may then be exported to or imported from the
spreadsheet program.

               Calendar Differences and Leap Years

        Julian dates remain useful to astronomers. Unfortunately, you
can't do arithmetic with ancient dates in dBASE.  dBASE cannot represent
a date earlier than 100 A.D., although the jul2dat() function above
returns sensible values back to March 1, 0000, Julian date 1,721,120.
More subtly, dBASE counts as though the current calendar had always been
in effect.  If you try to use dBASE to count the days since Columbus's
sighting of land on October 12, 1492, you'll get the wrong result by
nine days.  dBASE ignores February 29, 1500, a date which did exist, but
counts the 10 days 10/5/1582 through 10/14/1582, which did not.  In that
year October 15 followed October 4 to correct the error accumulated
under the Julian calendar and put the vernal equinox back on March 21st.
                                                               
        If you need to know the difference in days between a date under
the current ( Gregorian ) calendar and the same date under the Julian
calendar:

FUNCTION calerror                       && returns a number ( of days )
PARAMETERS Date                         && takes a date
RETURN int( year( Date ) / 100 ) - int( year( Date ) / 400 ) - 2 

        The difference is the number of days which must be added to
a date given under the Julian calendar before doing date arithmetic in
dBASE IV.

        The difference in the calendars is that the current calendar
eliminated February 29 in century years not divisible by 400.
Fortunately, not much happened on the Julian-calendar February 29ths
that were dropped; they cannot be represented or dealt with by dBASE.
Under the current calendar:

*  Returns .T. if leap year, otherwise .F.

FUNCTION isleap                         && returns logical value
PARAMETERS Year                         && takes year > 1500
RETURN mod( iif( mod( Year, 100 ) = 0, Year / 100, Year ), 4 ) = 0

                Day and Week of the Year

        It can be useful to know the days from the beginning of the
year.  Some organizations keep accounts this way.

*  Returns day of the year of a date ( not ANSI or YMD format )

FUNCTION doy                            && returns a number ( of days )
PARAMETERS Date                         && takes a date
RETURN Date + 1 - ctod( "01/01/" + str( year( Date ) ) )

        Other organizations keep their accounts by weeks.  While there
are differences in assumptions that preclude the use of any single
conversion method, the following function and its notes illustrate the
technique of assigning dates to the correct week.  Its fourth line calls
the doy() function given above, so that must be in a procedure file
active when this function is called unless you place the code in line.

*  Returns the number of the week of a given date.  Assumes that each
*  week starts on Sunday, that the first Sunday in January starts week
*  1, that days, if any, before it are week 0, and that any days after
*  the 52nd Saturday of the year are week 53.  See below to implement
*  changes to these assumptions.

FUNCTION weekno                         && returns a number (of weeks)
PARAMETERS Date                         && takes a date
PRIVATE Basedate
Basedate = Date - doy( Date )
Basedate = Basedate - mod( dow( Basedate - 1 ), 7 )
RETURN int( ( Date - Basedate ) / 7 )

        To use the above function but start the week on a different day,
change the 1 in the second-to-last line, the dow() of Sunday, to the
dow() of the day that should start each week, 2 for Monday through 7 for
Saturday.

        If a partial week at the beginning of the year should count as
week 1 and the following week as week 2, change the same line of the
function to read as follows:

        Basedate = Basedate - mod( dow( Basedate - 1 ) + 1, 7 ) - 6

        In the above line, the 1 inside the dow() expression should be
replaced by the dow() of the day starting the week as needed; the other
numbers should be left alone.  It won't do merely to retain the original
function line and add a week to the result, because a year that has no
partial week at the start would then begin with week 2.

        If days in a partial week before the first full week, or after
the end of week 52, are not numbered separately but are considered part
of week 1 or week 52, as the case may be, use max() or min(), or both,
in the last line.  For example:

        RETURN min( max( int( ( Date - Basedate ) / 7 ), 1 ), 52 )

        This will return 1 for the days that would otherwise be returned
as week 0 and 52 for those otherwise returned as week 53.

                        Floating Holidays

        Businesses also need to know when holidays occur.  The fixed
ones are easy, the floating ones less so.  In most applications, it
will save execution time to use these functions only annually or
monthly to generate a lookup table of holidays, not to call the
functions for each possible holiday for each day of the year.

*  Returns date of U. S. Thanksgiving for a year.
*  Requires MDY date format.

FUNCTION turkeyday                      && returns a date
PARAMETERS Year                         && takes a number ( the year )
PRIVATE Basedate
Basedate = ctod( "11/29/" + str( Year ) ) - 5
RETURN Basedate - dow( Basedate ) + 5

        This may be adapted to find any holiday or other day fixed as a
particular day of the week within a range of dates.  If we choose any
base date and subtract its dow() we reach the Saturday before the base
date.  By then adding the dow() of any day of the week, we reach the
next occurrence of that day of the week.  That's what the last line of
the function does, adding 5, the dow() of Thursday.

        The hard part is choosing the correct base date.  The U. S.
Thanksgiving holiday falls on the fourth Thursday of November.  This
must be within the fourth seven-day period of the month, November 22
through 28.  As you can work out if you wish, the date following the
last date on which the holiday might fall, here November 29, less the
dow() of the holiday, is always the correct base date.  The function
could have used "11/24" of the year directly as the base date, but the
reason for choosing that date would not have been evident.

        For the base date needed to find some other holidays and
special days, change the function by substituting the value from the
"Afterdate" column in the table below in the ctod() expression and
subtracting the number in the "Dow" column.  In the RETURN line,
add the number in the "Dow" column.  Or, use the generic function
below; it has been altered to expect the FIRST of the possible dates
( Afterdate - 7 ) and the Dow as parameters:

     Name                Occurs             Afterdate      Dow

President's Day    3rd Monday of Feb           02/22/       2
Memorial Day       last Monday of May          06/01/       2
Labor Day          1st Monday of Sep           09/08/       2
Columbus Day       2nd Monday of Oct           10/15/       2
Election Day       1st Tues Nov not Nov 1      11/09/       3
1st Sun of Advent  Sunday closest Nov 30       12/04/       1

*  Gives date of holiday given first possible date and dow().

FUNCTION holiday                        && returns a date
PARAMETERS Firstdate, Dow               && takes date and number
PRIVATE Basedate
Basedate = Firstdate + 7 - Dow
RETURN Basedate - dow( Basedate ) + Dow

        Easter day is even more complex.  The algorithm is adapted from
the one in Example 14 to Section 1.3.2 of Donald Knuth's "The Art of
Computer Programming", Second Edition (Addison-Wesley, Reading, MA,
1973), attributed to Aloysius Lilius and Christopher Clavius in the late
16th century.  It gives the date of Easter as calculated by most Western
churches.  As Dr. Knuth points out, calculations of Easter seem to have
been the primary use of arithmetic in Europe at the time and the spur to
much mathematical development, so the method has some historical
significance.

        Due to perturbations in the moon's orbit, the date of Easter
returned may not coincide precisely with the astronomical objective, the
first Sunday after the first full moon on or after the vernal equinox.
Also, of course, the date returned may not coincide with Easter as
celebrated by the Eastern churches nor even with Passover, which are
found by different calculations.  (Author's note: Anyone with an
algorithm to create the Jewish calendar, please share it.  Both Hillel
II and Maimonides apparently wrote about the subject.)

        The variable "Golden" is the "golden number" of the year and
"Epact" the date of the first full moon of the year.  Both show up in
other astronomical calculations.  "Moonorbit" is a special correction to
synchronize with the moon's orbit, "Noleap" the number of non-leap
even-hundreds years since 1582 and "Pascalmoon" the date, ignoring
perturbations of the moon's orbit, of the first full moon on or after
the equinox.

*  Returns date of Easter for a year.
*  Works only with MDY format and years > 1582.

FUNCTION easterday                      && returns a date
PARAMETERS Year                         && takes a number, the year
PRIVATE Golden, Century, Noleap, Moonorbit, Epact, Pascalmoon
Golden = 1 + mod( Year, 19 )
Century = floor( Year / 100 ) + 1
Noleap = floor( 3 * Century / 4 ) - 12 
Moonorbit = floor( ( 8 * Century + 5 ) / 25 ) - 5
Epact = mod( 11*Golden + Moonorbit - Noleap + 20, 30 )
Epact = Epact + iif( Epact = 24 .OR. ;
        ( Epact = 25 .AND. Golden > 11 ), 1, 0 )
Pascalmoon = ctod( "03/21/"+str( Year ) ) + mod( 53 - Epact, 30 )
RETURN Pascalmoon + 8 - dow( Pascalmoon )

                        Date Formats

        Sometimes none of the SET DATE format options is what you want,
as for the date inside a letter.  Note that this function calls a
function, ord(), which is essentially a numeric-conversion function
unrelated to dates included here for convenience.  If using this code to
set up a library, don't include it twice.

*  Convert a date to form like "September 1st, 1991".
*  Similar to MDY() but eliminates blank for days < 10, adds "st", etc.
*  and prints year with four digits regardless of SET CENTURY switch.

*  Thanks for the idea to Ellen Sander.  Requires the function ord().
*  You may substitute "str" for "ord", in which case the "st", "nd",
*  etc. will not be added to the day.

FUNCTION myday                          && returns a string
PARAMETERS Mdate                        && takes a date
RETURN cmonth( Mdate ) + " " + ltrim( ord( day( Mdate ) ) ) ;
   + ", " + str( year( Mdate ), 4 )

*  Convert a positive integer to ordinal representation.
*  Does not trim off leading spaces or format with commas.

FUNCTION ord                            && returns a string
PARAMETERS N                            && takes a number
PRIVATE D
D = mod( N, 100 ) - 1     && the -1 just happens to simplify things
RETURN str( N ) + iif( mod( D, 10 ) > 2 .OR. abs( D - 11 ) < 2, ;
   "th", substr( "stndrd", mod( D, 10 ) * 2 + 1, 2 ) )

        With the next, you can find out the date format in use.  It
works as set("DATE") should if such a function existed in dBASE IV.  You
might use it to be sure the format is compatible with that required by
some of the earlier functions, saving the current setting and restoring
it after calling one of those functions.

        You might also change the earlier functions to add a call to
this one and appropriate code to deal with the format found, making the
earlier functions format-independent.  I didn't do this because I
concluded most systems will be used with only a single date format and
the clutter of CASE statements would obscure more interesting issues.

*  Returns string giving name of current DATE format.

FUNCTION dateset                        && returns string
PRIVATE Cent, Testdate, Delimiter
Cent = set( "CENTURY" )
SET CENTURY OFF
Testdate = ctod( "01/02/03" )
Delimiter = substr( dtoc( Testdate ), 3, 1 )
SET CENTURY &Cent
DO CASE
  CASE month( Testdate ) = 1
    RETURN iif( Delimiter = "-", "USA", "MDY" )
  CASE day( Testdate ) = 1
    RETURN iif( Delimiter = "/", "DMY", ;
      iif( Delimiter = ".", "GERMAN", "ITALIAN" ) )
  OTHERWISE
    RETURN iif( Delimiter = ".", "ANSI", "YMD" )
ENDCASE

        In the above function, you may wish to make one or more of the
substitutions AMERICAN for MDY, BRITISH or FRENCH for DMY and JAPAN for
YMD.  Or you might, I suppose, substitute something else entirely, if
you could think of a good reason.

        Here's one I wish were implemented internally in dBASE IV, since
using it and dtos() instead of ctod() and dtoc() would eliminate all
dependency on date formats.

*  Convert string YYYYMMDD or YYMMDD to date regardless of SET DATE

FUNCTION stod()                         && returns a date
PARAMETERS String                       && takes character string
PRIVATE Testdate, M, D, Y
Testdate = ctod( "01/02/03" )
IF len( String ) < 8
  String = left( str( year( date() ), 4 ), 2 ) + String
ENDIF
Y = left( String, 4 )
M = substr( String, 5, 2 )
D = right( String, 2 )
DO CASE
  CASE month( Testdate ) = 1
    RETURN ctod( M + "/" + D + "/" + Y )
  CASE day( Testdate ) = 1
    RETURN ctod( D + "/" + M + "/" + Y )
  OTHERWISE
    RETURN ctod( Y + "/" + M + "/" + D )
ENDCASE

        Why didn't I use curly braces instead of ctod() to specify dates
in these functions?  Because there's a bug in dBASE IV, Version 1.1,
that causes the curly braces to ignore the European formats of SET DATE.
The last two functions rely on ctod() to interpret the string "01/02/03"
in accordance with the date format in current effect.  {01/02/03} will
return the date of January 2, 1903 even if SET DATE has been set to DMY,
which is incorrect.  If you use one of the DMY formats, watch out for
this bug.

        Placed in the public domain November 10, 1991.  Comments and
corrections will be greatly appreciated.

                                Jay Parsons

