DateEx: Extended Date library

Share your advanced PureBasic knowledge/code with the community.
Little John
Addict
Addict
Posts: 4772
Joined: Thu Jun 07, 2007 3:25 pm
Location: Berlin, Germany

DateEx: Extended Date library

Post by Little John »

//edit 2025-07-25: version 2.06

The functions in this module are cross-platform and compatible with PureBasic's built-in Date functions.
The module provides
  • Replacements for functions of PureBasic's Date library. These functions have the same names as the original ones, with "Q" appended to the name.
    Besides extended date range, some of the functions in this module are more flexible or powerful than the original ones:
    • DateQ() can be called without any parameter of course, but it can also be called e.g. like this:
      Debug DateQ(year, month, day)
      while with the original PB function we have to write in this case
      Debug Date(year, month, day, 0, 0, 0)
    • DayOfWeekQ() provides an optional parameter that allows to get a result according to the ISO standard.
    • In all functions that take a weekday as parameter, Sunday can be given as 0 (compliant with PB's date functions) or as 7 (according to ISO).
    • FormatDateQ() supports additional tokens in the mask string, and also will show the current date and time when it's called without any parameter.
  • Additional functions and macros such as WeekNumber(), LocalizedDayName() etc. (see lower part of the 'DeclareModule' section)
The module can handle
  • dates of the proleptic Julian calendar: from 0001-01-01 00:00:00 to 32767-12-31 23:59:59;
    default range is from 0001-01-01 00:00:00 to 1582-10-04 23:59:59
    (see <https://en.wikipedia.org/wiki/Proleptic_Julian_calendar>)
  • dates of the Gregorian calendar*: from 1582-10-15 00:00:00 to 32767-12-31 23:59:59
  • Julian dates (not to be confused with the Julian calendar)
Enjoy!

*) Only a few catholic countries such as Spain and Portugal actually adopted the Gregorian calendar on October 4, 1582 (followed by October 15, 1582). Handling the dates when the Gregorian calendar was introduced in which country is beyond the scope of this contribution. However, in the date range from 1582-10-15 00:00:00 to 32767-12-31 23:59:59, you can choose yourself whether you use the Julian or the Gregorian calendar for your calculations.

Code: Select all

; -- A module with extended date functions
; <https://www.purebasic.fr/english/viewtopic.php?t=81956>

; Version 2.06, 2025-07-25
; cross-platform, successfully tested with
; [v] PB 5.73 LTS (x64) on Windows 11      – ASM backend
; [v] PB 6.04 LTS (x64) on Linux Mint 20.3 – both backends
; [v] PB 6.04 LTS (x86) on Windows 11      – both backends
; [v] PB 6.04 LTS (x64) on Windows 11      – both backends
; [v] PB 6.21     (x64) on Windows 11      – both backends
; [v] PB 6.21     (x64) on Linux Mint 22.1 – both backends

; The functions in this module are compatible with PureBasic's built-in Date
; functions. They are using Quad values for representing the date.
; The module provides
; - replacements for functions of PureBasic's Date library. These functions
;   have the same names as the original ones, with "Q" appended to the name.
; - additional functions such as WeekNumber(), LocalizedDayName() etc.
;
; The module can handle
; - dates of the proleptic Julian calendar: from 0001-01-01 00:00:00 to 32767-12-31 23:59:59;
;   default range is from 0001-01-01 00:00:00 to 1582-10-04 23:59:59
;   (see <https://en.wikipedia.org/wiki/Proleptic_Julian_calendar>)
; - dates of the Gregorian calendar: from 1582-10-15 00:00:00 to 32767-12-31 23:59:59
; - Julian dates (not to be confused with the Julian calendar)

; Note: For the Assert() macro to work, the file "PureUnit.res" in PureBasic's
;       "Sdk\PureUnit" subdirectory must be moved to the "Residents" subdirectory.

; ------------------------------------------------------------------------------
; MIT License
;
; Copyright (c) 2025 Jürgen Lüthje <https://luethje.eu/>
;
; Permission is hereby granted, free of charge, to any person obtaining a copy
; of this software and associated documentation files (the "Software"), to deal
; in the Software without restriction, including without limitation the rights
; to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
; copies of the Software, and to permit persons to whom the Software is
; furnished to do so, subject to the following conditions:
;
; The above copyright notice and this permission notice shall be included in all
; copies or substantial portions of the Software.
;
; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
; IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
; FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
; AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
; LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
; OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
; SOFTWARE.
; ------------------------------------------------------------------------------

CompilerIf #PB_Compiler_Version = 573 And #PB_Compiler_Processor = #PB_Processor_x86
   CompilerError "Bug in PB 5.73 LTS. With this version, use the x64 compiler"
CompilerEndIf

CompilerIf #PB_Compiler_Version >= 600 And #PB_Compiler_Version <= 602
   CompilerIf #PB_Compiler_Processor = #PB_Processor_x86 And #PB_Compiler_Backend = #PB_Backend_C
      CompilerError "Bug e.g. in PB 6.02 LTS. With the x86 compiler, use the ASM backend"
   CompilerEndIf
CompilerEndIf


DeclareModule DateEx
   ;-- Constants
   #SECONDS_PER_MINUTE =     60
   #SECONDS_PER_HOUR   =   3600
   #SECONDS_PER_DAY    =  86400
   #SECONDS_PER_WEEK   = 604800

   ; Weekdays
   Enumeration
      #SundayPB
      #Monday
      #Tuesday
      #Wednesday
      #Thursday
      #Friday
      #Saturday
      #SundayISO
   EndEnumeration

   ; Calendar systems
   Enumeration
      #Default
      #Julian
      #Gregorian
   EndEnumeration

   ; Constants for Period2Str()
   #PS_en$ = " year,s, month,s, week,s, day,s, hour,s, minute,s, second,s"
   #PS_de$ = " Jahr,e, Monat,e, Woche,n, Tag,e, Stunde,n, Minute,n, Sekunde,n"

   ; Codes returned by DateError()
   Enumeration 1
      #Err_SplitDate
      #Err_LocalTime
      #Err_OutdatedPBversion
      #Err_DateOutOfBounds
      #Err_CalendarSystem
      #Err_DateType
      #Err_ParseDate
      #Err_YearOutOfBounds
      #Err_LeapDay
      #Err_DaysInMonth
      #Err_DayName
      #Err_MonthName
      #Err_NullPointer
      #Err_NegativePeriod
      #Err_DateDiff
      #Err_TimeUnits
      #Err_NegativeTime
      #Err_DateFromDayOfYear
      #Err_WeekDay
      #Err_WeekDayInMonth
      #Err_Easter
      #Err_JulianDateOutOfBounds
   EndEnumeration

   #NODATE = -70000000000    ; impossible date value (for internal use only)

   ;-- Structures
   Structure Period
      years.q
      months.q
      weeks.q
      days.q
      hours.q
      minutes.q
      seconds.q
   EndStructure

   ;-- Replacements for built-in PureBasic functions
   Declare.q DateQ (year.i=-1, month.i=1, day.i=1, hour.i=0, min.i=0, sec.i=0, calendar.i=#Default)
   Declare.q AddDateQ (date.q, type.i, value.q, calendar.i=#Default)
   Declare.i DayOfWeekQ (date.q, ISO.i=#False)
   Declare.i DayOfYearQ (date.q, calendar.i=#Default)
   Declare.i YearQ (date.q, calendar.i=#Default)
   Declare.i MonthQ (date.q, calendar.i=#Default)
   Declare.i DayQ (date.q, calendar.i=#Default)
   Declare.i HourQ (date.q)
   Declare.i MinuteQ (date.q)
   Declare.i SecondQ (date.q)
   Declare.s FormatDateQ (mask$="%yyyy-%mm-%dd %hh:%ii:%ss", date.q=#NODATE, calendar.i=#Default)
   Declare.q ParseDateQ (mask$, date$, calendar.i=#Default, strict.i=#False)

   ;-- Additional macros and functions
   Macro Today ()
      ; out: current local date at time 00:00:00 in PureBasic format
      (IntQ(DateEx::DateQ() / DateEx::#SECONDS_PER_DAY) * DateEx::#SECONDS_PER_DAY)
   EndMacro

   Macro LastDayInMonth (_year_, _month_, _calendar_=#Default)
      ; out: corresponding date in PureBasic format
      DateEx::AddDateQ(DateEx::DateQ(_year_,_month_,1, 0,0,0, _calendar_), #PB_Date_Day, DateEx::DaysInMonth(_year_, _month_, _calendar_)-1, _calendar_)
   EndMacro

   Declare.i LeapDay (year.i, calendar.i=#Default)
   Declare.i DaysInMonth (year.i, month.i, calendar.i=#Default)
   Declare.i WeekNumber (date.q)
   Declare.s LocalizedDayName (weekday.i, short.i=#False)
   Declare.s LocalizedMonthName (MonthOfYear.i, short.i=#False)
   Declare.q UTC ()
   Declare.i TimeZone ()
   Declare.s TimeZoneStr ()
   Declare.q DateDiff (startTime.q, endTime.q, *p.Period, calendar.i=#Default)
   Declare.s Period2Str (*p.Period, units$=#PS_en$)
   Declare.q DateFromDayOfYear (year.i, dayNo.i, calendar.i=#Default)
   Declare.q nth_WeekDay (weekday.i, dayNo.i, date.q)
   Declare.q nth_WeekDay_InMonth (weekday.i, dayNo.i, year.i, month.i, calendar.i=#Default)
   Declare.q Easter (year.i, calendar.i=#Default)
   Declare.q BlackFriday (year.i)
   Declare.q Advent (year.i)
   Declare.d PureDate2Julian (date.q)
   Declare.q Julian2PureDate (juDate.d)
   Declare.i DateError ()
EndDeclareModule


Module DateEx
   EnableExplicit

   ; -- Constants
   #SOC = SizeOf(Character)

   #DAYS_UNTIL_1970_01_01 = 719163   ; 1970-01-01, 00:00:00

   #DAYS1   = 365                    ; days in   1 year
   #DAYS4   = #DAYS1 * 4 + 1         ; days in   4 years (  1461)
   #DAYS100 = #DAYS4 *25 - 1         ; days in 100 years ( 36524)  // Gregorian calendar
   #DAYS400 = #DAYS100*4 + 1         ; days in 400 years (146097)  // Gregorian calendar

   ; -- Structures
   Structure DateTime
      year.w
      month.b
      day.b
      hour.b
      min.b
      sec.b
   EndStructure

   CompilerIf #PB_Compiler_OS <> #PB_OS_Windows
      Structure tm
         tm_sec.l
         tm_min.l
         tm_hour.l
         tm_mday.l
         tm_mon.l
         tm_year.l
         tm_wday.l
         tm_yday.l
         tm_isdst.l
      EndStructure

      ImportC ""
         localtime(*t.Quad)
      EndImport
   CompilerEndIf

   ImportC ""
      time(*tm=#Null)
   EndImport


   ; -- Global and Shared variables
   Global g_Error.i = 0
   Define s_PrevDate.q = #NODATE
   Define s_PrevCalendar.i = -1
   Define s_DateQ.DateTime


   ;-- Utility functions
   Macro _AscM (_string_, _posn_)
      ; -- fast version of Asc(Mid(_string_, _posn_, 1))
      ; _string_ must not equal "".
      PeekC(@_string_ + ((_posn_)-1)*#SOC)
   EndMacro


   Macro _ModInt (_x_, _n_)
      ; -- True modulo function
      ; in : x,n: whole number
      ; out: the integer in the range [0,n[ that is congruent to x modulo n;
      ;      The sign of the result is always the same as the sign of _n_.
      ;
      ; ModInt(x,n) = x - Floor(x/n) * n

      (((_n_) + ((_x_)%(_n_))) % (_n_))
   EndMacro


   Procedure _YearDate (year.i, dayOfYear.i, *dt.DateTime, calendar.i)
      ; in : year
      ;      dayOfYear: >= 1 and <= last day of the respective year
      ;                 (e.g. 32 for first February)
      ;      calendar : #Default, #Julian, or #Gregorian
      ; out: *dt: pointer to DateTime structure with the calculated values
      Protected days.i

      If year < 1 Or year > 32767
         g_Error = #Err_YearOutOfBounds
         ProcedureReturn
      EndIf

      *dt\year = year
      *dt\month = 1
      days = 31
      While dayOfYear > days
         dayOfYear - days
         *dt\month + 1
         days = DaysInMonth(*dt\year, *dt\month, calendar)
      Wend
      *dt\day = dayOfYear
   EndProcedure


   Procedure.i _DayOfYearYMD (year.i, month.i, day.i, calendar.i)
      ; in : valid date
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: number of given day in given year
      ;                    (e.g. 32 for the first February),
      ;                    or -1 on error
      Protected ret.i

      ret = (month-1)*31 + day
      If month >= 3
         ret + LeapDay(year, calendar) - Int(0.4*month + 2.3)
         If g_Error = #Err_LeapDay
            ProcedureReturn -1
         EndIf
      EndIf

      ProcedureReturn ret
   EndProcedure


   Procedure _SplitDate (date.q, *dt.DateTime, calendar.i)
      ; -- complementary function to DateQ()
      ; in : date    : date in PureBasic format
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: *dt: pointer to DateTime structure with the calculated values
      Protected numDays.q, count.q, year.i, minutes.i

      ; -- calculate year
      numDays = Round(date / #SECONDS_PER_DAY, #PB_Round_Down) - 1 + #DAYS_UNTIL_1970_01_01

      If calendar = #Julian Or (calendar = #Default And numDays <= 577734)  ; 1582-10-04
                                                                            ; proleptic Julian calendar
         numDays + 2
      ElseIf (calendar = #Gregorian Or calendar = #Default) And numDays > 577734
         ; Gregorian calendar
         count = IntQ(numDays / #DAYS400)
         numDays % #DAYS400
         year = count*400
         count = IntQ(numDays / #DAYS100)
         numDays % #DAYS100
         If count = 4                         ; 4*100 years are 1 leapday
            count - 1                         ;   less than 400 years
            numDays = #DAYS100
         EndIf                                ; 0 <= numDays <= DAYS100 (36524)
         year + count*100
      Else
         g_Error = #Err_SplitDate
         ProcedureReturn
      EndIf

      year + IntQ(numDays / #DAYS4) * 4 + 1
      numDays % #DAYS4                        ; 0 <= numDays <= DAYS1*4  (1460)

      count = IntQ(numDays / #DAYS1)
      numDays % #DAYS1
      If count = 4                            ; 4*1 year are 1 leapday
         count - 1                            ;   less than 4 years
         numDays = #DAYS1
      EndIf                                   ; 0 <= numDays <= 365
      year + count

      ; -- calculate month and day
      _YearDate(year, numDays+1, *dt, calendar)
      If g_Error = #Err_YearOutOfBounds
         ProcedureReturn
      EndIf

      ; -- calculate time
      date     = _ModInt(date, #SECONDS_PER_DAY)
      *dt\sec  = date % 60
      minutes  = Int(date / 60)
      *dt\min  = minutes % 60
      *dt\hour = Int(minutes / 60)
   EndProcedure

   ; ========================================================================================

   ;-- Public functions

   Procedure.q DateQ (year.i=-1, month.i=1, day.i=1, hour.i=0, min.i=0, sec.i=0, calendar.i=#Default)
      ; -- complementary function to _SplitDate()
      ; in : date between 0001-01-01 00:00:00 and 32767-12-31 23:59:59
      ;      (There is no year 0 in the civil calendar).
      ;      If no parameter is passed, then the current local date and time is returned.
      ;      calendar: #Default, #Julian, or Gregorian
      ; out: return value: given date and time in PureBasic format (number of seconds since 1970-01-01 00:00:00);
      ;                    when using calendar = #Default:
      ;                      0001-01-01 00:00:00 to  1582-10-04 23:59:59 -> proleptic Julian calendar,
      ;                      1582-10-15 00:00:00 to 32767-12-31 23:59:59 -> Gregorian calendar,
      ;                    or -1 on error (see DateError())
      Protected numDays.i

      If year = -1
         ; return current local time

         CompilerSelect #PB_Compiler_OS
            CompilerCase #PB_OS_Windows
               Protected t.SystemTime

               GetLocalTime_(@t)
               With t
                  year  = \wYear
                  month = \wMonth
                  day   = \wDay
                  hour  = \wHour
                  min   = \wMinute
                  sec   = \wSecond
               EndWith

            CompilerCase #PB_OS_Linux
               ; https://stackoverflow.com/questions/1442116/how-to-get-the-date-and-time-values-in-a-c-program
               Protected utc.q, *t.tm

               utc = time()
               *t = localtime(@utc)
               If *t = #Null
                  g_Error = #Err_LocalTime
                  ProcedureReturn -1
               EndIf

               With *t
                  year  = \tm_year + 1900
                  month = \tm_mon + 1
                  day   = \tm_mday
                  hour  = \tm_hour
                  min   = \tm_min
                  sec   = \tm_sec
               EndWith

            CompilerDefault
               Protected d.q

               d = Date()
               If d = -1
                  g_Error = #Err_OutdatedPBversion
               EndIf
               ProcedureReturn d
         CompilerEndSelect

      ElseIf year < 1 Or year > 32767 Or month < 1 Or month > 12 Or day < 1  Or day > DaysInMonth(year, month, calendar) Or
             hour < 0 Or hour > 23 Or min < 0 Or min > 59 Or sec < 0 Or sec > 59
         g_Error = #Err_DateOutOfBounds
         ProcedureReturn -1
      EndIf

      numDays = _DayOfYearYMD(year, month, day, calendar)
      year - 1
      numDays + year*365 + Int(year/4)

      If calendar = #Default
         If (year < 1581) Or
            (year = 1581 And month < 10) Or
            (year = 1581 And month = 10 And day <= 4)
            calendar = #Julian
         ElseIf year = 1581 And month = 10 And day < 15
            g_Error = #Err_CalendarSystem
            ProcedureReturn -1
         Else
            calendar = #Gregorian
         EndIf
      EndIf

      Select calendar
         Case #Julian
            numDays - 2
         Case #Gregorian
            numDays - Int(year/100) + Int(year/400)
      EndSelect

      numDays - #DAYS_UNTIL_1970_01_01

      ProcedureReturn numDays*#SECONDS_PER_DAY + (hour*60 + min)*60 + sec
   EndProcedure


   Procedure.q AddDateQ (date.q, type.i, value.q, calendar.i=#Default)
      ; in : date    : date in PureBasic format
      ;      type    : #PB_Date_Year, #PB_Date_Month, #PB_Date_Week, #PB_Date_Day,
      ;                #PB_Date_Hour, #PB_Date_Minute, or #PB_Date_Second
      ;      value   : how much of <type> is to be added/subtracted to <date>;
      ;                Only whole numbers are allowed (if you want e.g. to pass
      ;                half an hour, pass 30 minutes instead).
      ;      calendar: #Default, #Julian, or Gregorian
      ; out: return value: calculated new date,
      ;                    or -1 on error (see DateError())
      Protected dt.DateTime, maxDay.i

      If calendar = #Default And date <= -12219292801  ; 1582-10-04 23:59:59
         calendar = #Julian
      EndIf

      Select type
         Case #PB_Date_Year
            _SplitDate(date, @dt, calendar)
            If g_Error = #Err_SplitDate Or g_Error = #Err_YearOutOfBounds
               ProcedureReturn -1
            EndIf
            dt\year + value
            maxDay = DaysInMonth(dt\year, dt\month, calendar)
            If g_Error = #Err_YearOutOfBounds Or g_Error = #Err_DaysInMonth Or g_Error = #Err_LeapDay
               ProcedureReturn -1
            EndIf
            With dt
               If \day > maxDay
                  \day = maxDay
               EndIf
               ProcedureReturn DateQ(\year, \month, \day, \hour, \min, \sec, calendar)
            EndWith

         Case #PB_Date_Month
            _SplitDate(date, @dt, calendar)
            If g_Error = #Err_SplitDate Or g_Error = #Err_YearOutOfBounds
               ProcedureReturn -1
            EndIf
            dt\month + value
            dt\year + Int(Round(dt\month / 12, #PB_Round_Down))
            dt\month = _ModInt(dt\month, 12)
            If dt\month = 0
               dt\year - 1
               dt\month = 12
            EndIf
            Assert(dt\month >= 1 And dt\month <= 12, "dt\month = " + dt\month)
            maxDay = DaysInMonth(dt\year, dt\month, calendar)
            If g_Error = #Err_YearOutOfBounds Or g_Error = #Err_DaysInMonth Or g_Error = #Err_LeapDay
               ProcedureReturn -1
            EndIf
            With dt
               If \day > maxDay
                  \day = maxDay
               EndIf
               ProcedureReturn DateQ(\year, \month, \day, \hour, \min, \sec, calendar)
            EndWith

         Case #PB_Date_Week
            ProcedureReturn date + value*#SECONDS_PER_DAY*7

         Case #PB_Date_Day
            ProcedureReturn date + value*#SECONDS_PER_DAY

         Case #PB_Date_Hour
            ProcedureReturn date + value*3600

         Case #PB_Date_Minute
            ProcedureReturn date + value*60

         Case #PB_Date_Second
            ProcedureReturn date + value

         Default
            g_Error = #Err_DateType
            ProcedureReturn -1
      EndSelect
   EndProcedure


   Procedure.i DayOfWeekQ (date.q, ISO.i=#False)
      ; in : date: date in PureBasic format
      ;      ISO : #True/#False
      ; out: return value: - Monday=1, ..., Saturday=6
      ;                    - Sunday=0 if ISO is False (compatible with PureBasic)
      ;                    - Sunday=7 if ISO is True  (according to ISO standard)
      Protected ret.i

      ret = IntQ(date/#SECONDS_PER_DAY + #DAYS_UNTIL_1970_01_01 + 7) % 7
      If ret = 0 And ISO <> #False
         ret = 7
      EndIf

      ProcedureReturn ret
   EndProcedure


   Procedure.i DayOfYearQ (date.q, calendar.i=#Default)
      ; -- complementary function to DateFromDayOfYear()
      ; in : date    : date in PureBasic format
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: number of given day in given year
      ;                    (e.g. 32 for the first February),
      ;                    or -1 on error (see DateError())
      Protected d.DateTime

      _SplitDate(date, @d, calendar)
      If g_Error = #Err_SplitDate Or g_Error = #Err_YearOutOfBounds
         ProcedureReturn -1
      Else
         ProcedureReturn _DayOfYearYMD(d\year, d\month, d\day, calendar)
      EndIf
   EndProcedure


   Procedure.i YearQ (date.q, calendar.i=#Default)
      ; in : date    : date in PureBasic format
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: year value of 'date',
      ;                    or -1 on error
      Shared s_PrevDate, s_PrevCalendar, s_DateQ

      If s_PrevDate <> date Or s_PrevCalendar <> calendar
         s_PrevDate = date
         s_PrevCalendar = calendar
         _SplitDate(date, @s_DateQ, calendar)
         If g_Error = #Err_SplitDate Or g_Error = #Err_YearOutOfBounds
            ProcedureReturn -1
         EndIf
      EndIf
      ProcedureReturn s_DateQ\year
   EndProcedure


   Procedure.i MonthQ (date.q, calendar.i=#Default)
      ; in : date    : date in PureBasic format
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: month value of 'date',
      ;                    or -1 on error
      Shared s_PrevDate, s_PrevCalendar, s_DateQ

      If s_PrevDate <> date Or s_PrevCalendar <> calendar
         s_PrevDate = date
         s_PrevCalendar = calendar
         _SplitDate(date, @s_DateQ, calendar)
         If g_Error = #Err_SplitDate Or g_Error = #Err_YearOutOfBounds
            ProcedureReturn -1
         EndIf
      EndIf
      ProcedureReturn s_DateQ\month
   EndProcedure


   Procedure.i DayQ (date.q, calendar.i=#Default)
      ; in : date    : date in PureBasic format
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: day value of 'date',
      ;                    or -1 on error
      Shared s_PrevDate, s_PrevCalendar, s_DateQ

      If s_PrevDate <> date Or s_PrevCalendar <> calendar
         s_PrevDate = date
         s_PrevCalendar = calendar
         _SplitDate(date, @s_DateQ, calendar)
         If g_Error = #Err_SplitDate Or g_Error = #Err_YearOutOfBounds
            ProcedureReturn -1
         EndIf
      EndIf
      ProcedureReturn s_DateQ\day
   EndProcedure


   Procedure.i HourQ (date.q)
      ; in : date in PureBasic format
      ; out: return value: hour value of 'date'
      Shared s_PrevDate, s_DateQ

      If s_PrevDate <> date
         s_PrevDate = date
         _SplitDate(date, @s_DateQ, #Default)
      EndIf
      ProcedureReturn s_DateQ\hour
   EndProcedure


   Procedure.i MinuteQ (date.q)
      ; in : date in PureBasic format
      ; out: return value: minute value of 'date'
      Shared s_PrevDate, s_DateQ

      If s_PrevDate <> date
         s_PrevDate = date
         _SplitDate(date, @s_DateQ, #Default)
      EndIf
      ProcedureReturn s_DateQ\min
   EndProcedure


   Procedure.i SecondQ (date.q)
      ; in : date in PureBasic format
      ; out: return value: second value of 'date'
      Shared s_PrevDate, s_DateQ

      If s_PrevDate <> date
         s_PrevDate = date
         _SplitDate(date, @s_DateQ, #Default)
      EndIf
      ProcedureReturn s_DateQ\sec
   EndProcedure


   Procedure.s FormatDateQ (mask$="%yyyy-%mm-%dd %hh:%ii:%ss", date.q=#NODATE, calendar.i=#Default)
      ; in : mask$   : can contain the same tokens as PB's FormatDate(),
      ;                plus the following ones (all case-insensitive):
      ;                - %ww    -->  full  localized name of given weekday
      ;                - %w     -->  short localized name of given weekday
      ;                - %mmmm  -->  full  localized name of given month
      ;                - %mmm   -->  short localized name of given month
      ;                - %m     -->  month number without leading "0"
      ;                - %d     -->  day   number without leading "0"
      ;                - %h     -->  hours   without leading "0"
      ;                - %i     -->  minutes without leading "0"
      ;                - %s     -->  seconds without leading "0"
      ;      date    : date in PureBasic format; #NODATE for current local date and time
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: mask string with all tokens replaced by the respective values,
      ;                    or "" on error
      Protected dt.DateTime, year$

      If date = #NODATE
         date = DateQ()
         If g_Error = #Err_LocalTime Or g_Error = #Err_OutdatedPBversion
            ProcedureReturn ""
         EndIf
      EndIf

      _SplitDate(date, @dt, calendar)
      If g_Error = #Err_SplitDate Or g_Error = #Err_YearOutOfBounds
         ProcedureReturn ""
      EndIf

      mask$ = ReplaceString(mask$, "%ww",   LocalizedDayName(DayOfWeekQ(date)),        #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%w",    LocalizedDayName(DayOfWeekQ(date), #True), #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%mmmm", LocalizedMonthName(dt\month),              #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%mmm",  LocalizedMonthName(dt\month, #True),       #PB_String_NoCase)

      year$ = Str(dt\year)
      If Len(year$) < 4
         year$ = RSet(year$,  4, "0")
      EndIf
      mask$ = ReplaceString(mask$, "%yyyy", year$, #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%yy", Right(year$, 2), #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%mm", RSet(Str(dt\month), 2, "0"), #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%dd", RSet(Str(dt\day),   2, "0"), #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%hh", RSet(Str(dt\hour),  2, "0"), #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%ii", RSet(Str(dt\min),   2, "0"), #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%ss", RSet(Str(dt\sec),   2, "0"), #PB_String_NoCase)

      mask$ = ReplaceString(mask$, "%m", Str(dt\month), #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%d", Str(dt\day),   #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%h", Str(dt\hour),  #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%i", Str(dt\min),   #PB_String_NoCase)
      mask$ = ReplaceString(mask$, "%s", Str(dt\sec),   #PB_String_NoCase)

      ProcedureReturn mask$
   EndProcedure


   Macro _AdjustPointer()
      a = _AscM(date$, datePosn + 1)
      If strict = #False And (a < '0' Or '9' < a)
         *m + #SOC
      EndIf
   EndMacro

   Procedure.q ParseDateQ (mask$, date$, calendar.i=#Default, strict.i=#False)
      ; in : mask$   : defines the format of date$ (e.g. "%yyyy-%mm-%dd %hh:%ii:%ss"),
      ;                case-insensitive
      ;      date$   : string with date to be parsed (e.g. "2023-06-27 15:58:07")
      ;      calendar: #Default, #Julian, or #Gregorian
      ;      strict  : #True/#False;
      ;                If strict = #False (default), no leading zeros are required.
      ;                That's how PB's built-in function ParseDate() works.
      ; out: return value: value of date$ in PureBasic format,
      ;                    or -1 on error (see DateError())
      Protected *m.Character, datePosn.i=1
      Protected.i a, year=1, month=1, day=1, hour=0, min=0, sec=0

      mask$ = LCase(mask$)
      *m = @mask$

      While *m\c <> 0
         If *m\c = '%'
            Select PeekS(*m, 3)
               Case "%yy"
                  If PeekS(*m + 3*#SOC, 3) = "yyy"
                     year = Val(Mid(date$, datePosn, 5))
                     *m + 4*#SOC
                     datePosn + 4
                  ElseIf PeekS(*m + 3*#SOC, 2) = "yy"
                     year = Val(Mid(date$, datePosn, 4))
                     *m + 2*#SOC
                     datePosn + 2
                  Else
                     year = Val(Mid(date$, datePosn, 2))
                     If year > 0
                        year + Int(DateEx::YearQ(DateEx::DateQ()) / 100) * 100
                        _AdjustPointer()
                     EndIf
                  EndIf
               Case "%mm"
                  month = Val(Mid(date$, datePosn, 2))
                  _AdjustPointer()
               Case "%dd"
                  day = Val(Mid(date$, datePosn, 2))
                  _AdjustPointer()
               Case "%hh"
                  hour = Val(Mid(date$, datePosn, 2))
                  _AdjustPointer()
               Case "%ii"
                  min = Val(Mid(date$, datePosn, 2))
                  _AdjustPointer()
               Case "%ss"
                  sec = Val(Mid(date$, datePosn, 2))
                  _AdjustPointer()
            EndSelect
         Else
            a = _AscM(date$, datePosn)
            If (a < '0' Or '9' < a) And a <> *m\c
               g_Error = #Err_ParseDate
               ProcedureReturn -1
            EndIf
            datePosn + 1
         EndIf
         *m + #SOC
      Wend

      ProcedureReturn DateQ(year, month, day, hour, min, sec, calendar)
   EndProcedure


   Procedure.i LeapDay (year.i, calendar.i=#Default)
      ; in : valid year
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: 1 if year has a leap day, 0 if not,
      ;                    or -1 on error

      If year < 1 Or year > 32767
         g_Error = #Err_YearOutOfBounds
         ProcedureReturn -1
      EndIf

      If calendar = #Julian Or (calendar = #Default And year < 1582) Or year = 1582
         ProcedureReturn Bool(year % 4 = 0)
      ElseIf (calendar = #Gregorian Or calendar = #Default) And year > 1582
         ProcedureReturn Bool((year % 4 = 0 And year % 100 <> 0) Or year % 400 = 0)
      Else
         g_Error = #Err_LeapDay
         ProcedureReturn -1
      EndIf
   EndProcedure


   Procedure.i DaysInMonth (year.i, month.i, calendar.i=#Default)
      ; in : valid year and month
      ;      calendar: #Default, #Julian, or #Gregorian;
      ;                The given calendar must be valid for the whole given month.
      ; out: return value: number of days in given month of given year,
      ;                    or -1 on error
      Protected ret

      If year < 1 Or year > 32767
         g_Error = #Err_YearOutOfBounds
         ProcedureReturn -1
      EndIf

      Select month
         Case 1, 3, 5, 7, 8, 10, 12
            ret = 31
         Case 4, 6, 9, 11
            ret = 30
         Case 2
            ret = 28 + LeapDay(year, calendar)
            If g_Error = #Err_LeapDay
               ProcedureReturn -1
            EndIf
         Default
            g_Error = #Err_DaysInMonth
            ret = -1
      EndSelect

      ProcedureReturn ret
   EndProcedure


   Procedure.i WeekNumber (date.q)
      ; in : date of the Gregorian calendar in PureBasic format
      ; out: return value: number of ISO calendar week for the given date
      ;
      ; According to ISO 8601, the week that contains January 4 is week 1.
      ; ISO weeks start on Monday.
      ; <https://en.wikipedia.org/wiki/ISO_8601>
      ; based on code by wilbert, <https://www.purebasic.fr/english/viewtopic.php?p=420708#p420708>
      Protected.q week1_prev, week1_this, week1_next
      Protected.i year=YearQ(date)

      week1_prev = DateQ(year-1, 1, 4)
      week1_prev - ((DayOfWeekQ(week1_prev) + 6) % 7) * #SECONDS_PER_DAY   ; begin of 1st week

      week1_this = DateQ(year, 1, 4)
      week1_this - ((DayOfWeekQ(week1_this) + 6) % 7) * #SECONDS_PER_DAY   ; begin of 1st week

      week1_next = DateQ(year+1, 1, 4)
      week1_next - ((DayOfWeekQ(week1_next) + 6) % 7) * #SECONDS_PER_DAY   ; begin of 1st week

      If date < week1_this
         ; still in last week of previous year
         ProcedureReturn Int((date - week1_prev) / #SECONDS_PER_WEEK) + 1  ; can be 52 or 53
      ElseIf date < week1_next
         ProcedureReturn Int((date - week1_this) / #SECONDS_PER_WEEK) + 1
      Else
         ; already in week 1 of next year
         ProcedureReturn 1
      EndIf
   EndProcedure


   CompilerIf #PB_Compiler_OS = #PB_OS_Windows
      ; <https://www.purebasic.fr/english/viewtopic.php?p=377844#p377844>
      ; <https://learn.microsoft.com/en-us/windows/win32/api/winnls/nf-winnls-getlocaleinfoa>
      ; tested on German Windows 10 and 11

      Procedure.s LocalizedDayName (weekday.i, short.i=#False)
         ; in : weekday: Monday=1, ..., Saturday=6
         ;               (Sunday can be coded as 0 or 7)
         ;      short  : #True/#False
         ; out: return value: short or full localized name of given weekday,
         ;                    or "" on error
         Protected fmt.i, bufferSize.i=80, buffer$=Space(bufferSize)

         If weekday < 0 Or weekday > 7
            g_Error = #Err_DayName
            ProcedureReturn ""
         EndIf

         If weekday = 0
            weekday = 7
         EndIf

         If short
            fmt = #LOCALE_SABBREVDAYNAME1
         Else
            fmt = #LOCALE_SDAYNAME1
         EndIf

         If GetLocaleInfo_(#LOCALE_USER_DEFAULT, fmt + weekday - 1, @buffer$, bufferSize) > 0
            ProcedureReturn buffer$
         Else
            g_Error = #Err_DayName
            ProcedureReturn ""
         EndIf
      EndProcedure

      Procedure.s LocalizedMonthName (MonthOfYear.i, short.i=#False)
         ; in : MonthOfYear: January=1, ..., December=12
         ;                   (compliant with PureBasic's Month() function)
         ;      short      : #True/#False
         ; out: return value: short or full localized name of given month
         ;                    or "" on error
         Protected fmt.i, bufferSize.i=80, buffer$=Space(bufferSize)

         If MonthOfYear < 1 Or MonthOfYear > 12
            g_Error = #Err_MonthName
            ProcedureReturn ""
         EndIf

         If short
            fmt = #LOCALE_SABBREVMONTHNAME1
         Else
            fmt = #LOCALE_SMONTHNAME1
         EndIf

         If GetLocaleInfo_(#LOCALE_USER_DEFAULT, fmt + MonthOfYear - 1, @buffer$, bufferSize) > 0
            ProcedureReturn buffer$
         Else
            g_Error = #Err_MonthName
            ProcedureReturn ""
         EndIf
      EndProcedure

   CompilerElse
      ; <https://www.purebasic.fr/english/viewtopic.php?p=489699#p489699>
      ; <https://man7.org/linux/man-pages/man3/strftime.3.html>
      ; tested on German Linux Mint 20.3 and on Mac

      Procedure.s LocalizedDayName (weekday.i, short.i=#False)
         ; in : weekday: Monday=1, ..., Saturday=6
         ;               (Sunday can be coded as 0 or 7)
         ;      short  : #True/#False
         ; out: return value: short or full localized name of given weekday,
         ;                    or "" on error
         Protected tm.tm, fmt.i, numBytes.i, bufferSize.i=80, buffer$=Space(bufferSize)

         If weekday < 0 Or weekday > 7
            g_Error = #Err_DayName
            ProcedureReturn ""
         EndIf

         If weekday = 7
            weekday = 0
         EndIf

         If short
            fmt = $6125      ; "%a"
         Else
            fmt = $4125      ; "%A"
         EndIf

         tm\tm_wday = weekday
         numBytes = strftime_(@buffer$, bufferSize * #SOC, @fmt, @tm)

         If numBytes > 0
            ProcedureReturn PeekS(@buffer$, numBytes, #PB_UTF8|#PB_ByteLength)
         Else
            g_Error = #Err_DayName
            ProcedureReturn ""
         EndIf
      EndProcedure

      Procedure.s LocalizedMonthName (MonthOfYear.i, short.i=#False)
         ; in : MonthOfYear: January=1, ..., December=12
         ;                   (compliant with PureBasic's Month() function)
         ;      short      : #True/#False
         ; out: return value: short or full localized name of given month,
         ;                    or "" on error
         Protected tm.tm, fmt.i, numBytes.i, bufferSize.i=80, buffer$=Space(bufferSize)

         If MonthOfYear < 1 Or MonthOfYear > 12
            g_Error = #Err_MonthName
            ProcedureReturn ""
         EndIf

         If short
            fmt = $6225      ; "%b"
         Else
            fmt = $4225      ; "%B"
         EndIf

         tm\tm_mon = MonthOfYear - 1
         numBytes = strftime_(@buffer$, bufferSize * #SOC, @fmt, @tm)

         If numBytes > 0
            ProcedureReturn PeekS(@buffer$, numBytes, #PB_UTF8|#PB_ByteLength)
         Else
            g_Error = #Err_MonthName
            ProcedureReturn ""
         EndIf
      EndProcedure
   CompilerEndIf


   Procedure.q UTC ()
      ; out: return value: current UTC in PureBasic format

      ProcedureReturn time()
   EndProcedure

   Procedure.i TimeZone ()
      ; out: return value: current difference local time - UTC (in seconds),
      ;                    e.g. 3600 for CET and 7200 for CEST

      ProcedureReturn DateQ() - time()
   EndProcedure

   Procedure.s TimeZoneStr ()
      ; out: return value: current time zone as formatted string,
      ;                    e.g. +0100 for CET and +0200 for CEST
      Protected sign$, m.i, h.i, tz.i=TimeZone()

      If tz < 0 : sign$ = "-" : Else : sign$ = "+" : EndIf
      m = Abs(tz/60)
      h = Int(m/60)
      ProcedureReturn sign$ + RSet(Str(h), 2, "0") + RSet(Str(m % 60), 2, "0")
   EndProcedure


   Procedure.q DateDiff (startTime.q, endTime.q, *p.Period, calendar.i=#Default)
      ; in : startTime: point in time in PureBasic format
      ;      endTime  : point in time in PureBasic format
      ;      *p       : set field values to -1 for units that shall be ignored
      ;      calendar : #Default, #Julian, or #Gregorian
      ; out: *p          : period from startTime to endTime expressed in wanted units;
      ;                    The values are compatible with PB's built-in AddDate() function.
      ;      return value: period in seconds (>= 0) on success,
      ;                    or -1 on error
      Protected.i y, m=0
      Protected.q rest, tmp=startTime

      If *p = #Null
         g_Error = #Err_NullPointer
         ProcedureReturn -1
      ElseIf startTime > endTime
         g_Error = #Err_NegativePeriod
         ProcedureReturn -1
      EndIf

      If calendar = #Default And startTime <= -12219292801  ; 1582-10-04 23:59:59
         calendar = #Julian
      EndIf

      With *p
         If \years < 0 And \months < 0 And \weeks < 0 And \days < 0 And
            \hours < 0 And \minutes < 0 And \seconds < 0
            g_Error = #Err_DateDiff
            ProcedureReturn -1
         EndIf

         y = YearQ(endTime, calendar) - YearQ(startTime, calendar)
         If AddDateQ(tmp, #PB_Date_Year, y, calendar) > endTime
            y - 1
         EndIf

         If \years > -1
            tmp = AddDateQ(tmp, #PB_Date_Year, y, calendar)
            If g_Error = #Err_YearOutOfBounds Or g_Error = #Err_DaysInMonth Or
               g_Error = #Err_SplitDate Or g_Error = #Err_LeapDay
               ProcedureReturn -1
            EndIf
            \years = y
         Else
            \years = 0
            m = 12 * y
         EndIf

         If \months > -1
            \months = m + MonthQ(endTime, calendar) - MonthQ(startTime, calendar)
            If \months < 0
               \months + 12
            EndIf
            If AddDateQ(tmp, #PB_Date_Month, \months, calendar) > endTime
               \months - 1
            EndIf
            tmp = AddDateQ(tmp, #PB_Date_Month, \months, calendar)
            If g_Error = #Err_YearOutOfBounds Or g_Error = #Err_DaysInMonth Or
               g_Error = #Err_SplitDate Or g_Error = #Err_LeapDay
               ProcedureReturn -1
            EndIf
         Else
            \months = 0
         EndIf

         rest = endTime - tmp
         Assert(rest >= 0, "startTime = " + startTime + ", endTime = " + endTime + ", calendar = " + calendar)

         If \weeks > -1
            \weeks = IntQ(rest / #SECONDS_PER_WEEK)
            rest % #SECONDS_PER_WEEK
         Else
            \weeks = 0
         EndIf

         If \days > -1
            \days = IntQ(rest / #SECONDS_PER_DAY)
            rest % #SECONDS_PER_DAY
         Else
            \days = 0
         EndIf

         If \hours > -1
            \hours = IntQ(rest / #SECONDS_PER_HOUR)
            rest % #SECONDS_PER_HOUR
         Else
            \hours = 0
         EndIf

         If \minutes > -1
            \minutes = IntQ(rest / #SECONDS_PER_MINUTE)
            rest % #SECONDS_PER_MINUTE
         Else
            \minutes = 0
         EndIf

         If \seconds > -1
            \seconds = rest
         Else
            \seconds = 0
         EndIf
      EndWith

      ProcedureReturn endTime - startTime   ; success
   EndProcedure


   Procedure.s Period2Str (*p.Period, units$=#PS_en$)
      ; in : *p    : period of time expressed in appropriate units
      ;      units$: time units in the language of your choice (default: English)
      ; out: return value: given time span expressed in given units as a string,
      ;                    or "" on error
      Protected ret$=""

      If *p = #Null
         g_Error = #Err_NullPointer
         ProcedureReturn ""
      ElseIf CountString(units$, ",") <> 13
         g_Error = #Err_TimeUnits
         ProcedureReturn ""
      EndIf

      With *p
         If \years < 0 Or \months < 0 Or \weeks < 0 Or \days < 0 Or
            \hours < 0 Or \minutes < 0 Or \seconds < 0
            g_Error = #Err_NegativeTime
            ProcedureReturn ""
         EndIf

         If \years > 0
            ret$ + ", " + \years + StringField(units$, 1, ",")
            If \years > 1
               ret$ + StringField(units$, 2, ",")
            EndIf
         EndIf

         If \months > 0
            ret$ + ", " + \months + StringField(units$, 3, ",")
            If \months > 1
               ret$ + StringField(units$, 4, ",")
            EndIf
         EndIf

         If \weeks > 0
            ret$ + ", " + \weeks + StringField(units$, 5, ",")
            If \weeks > 1
               ret$ + StringField(units$, 6, ",")
            EndIf
         EndIf

         If \days > 0
            ret$ + ", " + \days + StringField(units$, 7, ",")
            If \days > 1
               ret$ + StringField(units$, 8, ",")
            EndIf
         EndIf

         If \hours > 0
            ret$ + ", " + \hours + StringField(units$, 9, ",")
            If \hours > 1
               ret$ + StringField(units$, 10, ",")
            EndIf
         EndIf

         If \minutes > 0
            ret$ + ", " + \minutes + StringField(units$, 11, ",")
            If \minutes > 1
               ret$ + StringField(units$, 12, ",")
            EndIf
         EndIf

         If \seconds > 0 Or Asc(ret$) = 0
            ret$ + ", " + \seconds + StringField(units$, 13, ",")
            If \seconds <> 1
               ret$ + StringField(units$, 14, ",")
            EndIf
         EndIf
      EndWith

      ProcedureReturn Mid(ret$, 3)
   EndProcedure


   Procedure.q DateFromDayOfYear (year.i, dayNo.i, calendar.i=#Default)
      ; -- complementary function to DayOfYearQ()
      ; in : year    : given year
      ;      dayNo   : number of given day in that year
      ;                (e.g. 32 for the first February),
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: searched day in PureBasic format,
      ;                    or -1 on error (see DateError())
      Protected.q day1, ret

      If dayNo < 1
         g_Error = #Err_DateFromDayOfYear
         ProcedureReturn -1                ; error
      EndIf

      day1 = DateQ(year, 1, 1, 0, 0, 0, calendar)
      If g_Error = #Err_DateOutOfBounds Or g_Error = #Err_CalendarSystem
         ProcedureReturn -1                ; error
      EndIf

      ret = day1 + (dayNo-1) * #SECONDS_PER_DAY

      If ret > DateQ(year, 12, 31, 0, 0, 0, calendar)
         g_Error = #Err_DateFromDayOfYear
         ProcedureReturn -1                ; error
      Else
         ProcedureReturn ret
      EndIf
   EndProcedure


   Procedure.q nth_WeekDay (weekday.i, dayNo.i, date.q)
      ; returns
      ; - the next (or next but one etc.) 'weekday' after  'date' or
      ; - the last (or last but one etc.) 'weekday' before 'date' or
      ; - 'date' itself, if this day falls on 'weekday', and if dayNo=-1 or dayNo=1
      ;
      ; in : weekday: Monday=1, ..., Saturday=6
      ;               (Sunday can be coded as 0 or 7)
      ;      dayNo  :  1=next,  2=next but one etc. weekday AFTER  'date'
      ;               -1=last, -2=last but one etc. weekday BEFORE 'date'
      ;      date   : date in PureBasic format
      ; out: return value: searched day (+ time) in PureBasic format,
      ;                    or -1 on error (see DateError())
      Protected days.i

      If weekday < 0 Or weekday > 7 Or dayNo = 0
         g_Error = #Err_WeekDay
         ProcedureReturn -1
      EndIf

      If dayNo > 0
         days = (weekday - DayOfWeekQ(date) + 7) % 7   ; nearest subsequent weekday
         days + 7*(dayNo-1)                            ; n-th    subsequent weekday
      ElseIf dayNo < 0
         days = (weekday - DayOfWeekQ(date) - 7) % 7   ; nearest previous weekday
         days + 7*(dayNo+1)                            ; n-th    previous weekday
      EndIf

      ProcedureReturn date + days * #SECONDS_PER_DAY
   EndProcedure


   Procedure.q nth_WeekDay_InMonth (weekday.i, dayNo.i, year.i, month.i, calendar.i=#Default)
      ; -- returns the n-th weekday of a month
      ; in : weekday    : Monday=1, ..., Saturday=6
      ;                   (Sunday can be coded as 0 or 7)
      ;      dayNo      :  1=first, 2=second       etc. weekday of given month
      ;                   -1=last, -2=last but one etc. weekday of given month
      ;      year, month: wanted year and month of Julian or Gregorian calendar
      ;      calendar   : #Default, #Julian, or #Gregorian
      ; out: return value: searched day (+ time) in PureBasic format,
      ;                    or -1 on error (see DateError())
      Protected.q date, ret

      If dayNo > 0
         date = DateQ(year, month, 1, calendar)
      ElseIf dayNo < 0
         date = LastDayInMonth(year, month, calendar)
      EndIf

      ret = nth_WeekDay(weekday, dayNo, date)
      If ret = -1 Or MonthQ(ret, calendar) <> month
         g_Error = #Err_WeekDayInMonth
         ret = -1
      EndIf

      ProcedureReturn ret
   EndProcedure


   Procedure.q Easter (year.i, calendar.i=#Default)
      ; in : year    : year
      ;      calendar: #Default, #Julian, or #Gregorian
      ; out: return value: date of Easter Sunday in PureBasic format,
      ;                    or -1 on error
      ;      Gregorian calendar: range [Mar. 22; Apr. 25] (Wikipedia)
      Protected.i b, c, e
      Protected.i k, m, s, a, d, r, og, sz, oe
      Protected.i month, day

      If calendar = #Julian Or (calendar = #Default And year <= 1582)
         ; Easter algorithm by Carl Friedrich Gauss
         ; [after Heinrich Hemme (2009):
         ;  Das Buch der Ziffern, Zahlen, Maße und Symbole.
         ;  Anaconda, Köln; p. 78]
         a = year % 19
         b = year % 4
         c = year % 7
         d = (19*a + 15) % 30
         e = (2*b + 4*c + 6*d + 6) % 7
         day = 22 + d + e
      ElseIf (calendar = #Gregorian Or calendar = #Default) And year > 1582
         ; Easter algorithm by Heiner Lichtenberg (1997);
         ; <https://www.ptb.de/cms/index.php?id=957>, 2023-07-04
         k = Int(year/100)
         m = 15 + Int((3*k+3)/4) - Int((8*k+13)/25)
         s = 2 - Int((3*k+3)/4)
         a = year % 19
         d = (19*a+m) % 30
         r = Int(d/29) + (Int(d/28)-Int(d/ 29)) * Int(a/11)
         og = 21 + d - r
         sz = 7 - (year+Int(year/4)+s) % 7
         oe = 7 - (og-sz) % 7
         day = og + oe
      Else
         g_Error = #Err_Easter
         ProcedureReturn -1
      EndIf

      If day <= 31
         month = 3
      Else
         day - 31
         month = 4
      EndIf

      ProcedureReturn DateQ(year, month, day, 0, 0, 0, calendar)
   EndProcedure


   Procedure.q BlackFriday (year.i)
      ; in : year
      ; out: return value: the day following Thanksgiving in the USA, i.e. Friday
      ;                    after the 4th Thursday of November (e.g. Nov. 29 in 2004);
      ;                    range [Nov. 23; Nov. 29] (Wikipedia)
      ; Note: This is not always the same as the 4th Friday of November (e.g. Nov. 22 in 2004).

      ProcedureReturn AddDateQ(nth_WeekDay_InMonth(#Thursday, 4, year, 11), #PB_Date_Day, 1)
   EndProcedure


   Procedure.q Advent (year.i)
      ; in : year
      ; out: return value: 1st Advent Sunday for given year of the Gregorian calendar;
      ;                    range [Nov. 27; Dec. 3] (Wikipedia)
      ; Note: If December 24 is a Sunday, then it is the 4th Advent Sunday;
      ;       otherwise, the 4th Advent Sunday is the Sunday before December 24.

      ProcedureReturn nth_WeekDay(#SundayISO, -4, DateQ(year, 12, 24))
   EndProcedure


   Procedure.d PureDate2Julian (date.q)
      ; -- complementary function to Julian2PureDate()
      ; in : date in PureBasic format (seconds since 1970-01-01 00:00:00)
      ; out: return value: Julian date (used primarily in astronomy)
      ;                    = number of days that have elapsed since 1 January 4713 BC,
      ;                      12 o'clock Universal Time
      ;                      (hours, minutes, and seconds are expressed as fraction of a day)

      ProcedureReturn date / #SECONDS_PER_DAY + 2440587.5
   EndProcedure


   Procedure.q Julian2PureDate (juDate.d)
      ; -- complementary function to PureDate2Julian()
      ; in : Julian date
      ; out: return value: date in PureBasic format (seconds since 1970-01-01 00:00:00),
      ;                    or -1 on error (see DateError())

      If juDate < 1721423.5 Or juDate > 13689325.499988426  ; < 0001-01-01 00:00:00 Or > 32767-12-31 23:59:59
         g_Error = #Err_JulianDateOutOfBounds
         ProcedureReturn -1
      Else
         ProcedureReturn Round((juDate - 2440587.5) * #SECONDS_PER_DAY, #PB_Round_Nearest)
      EndIf
   EndProcedure


   Procedure.i DateError ()
      ; out: return value: code of the most recent error,
      ;                    or 0 for no error
      ; Note: For compatibility with PureBasic, many functions in this library return -1 on error.
      ;       However, for some functtions a return value of -1 can also represent a valid date
      ;       in PureBasic format, meaning 1969-12-31 23:59:59 (on the Gregorian calendar).
      ;       So don't always rely on a return value of -1 as error indicator.
      ;       Often it's better to use code like this for error checking:
      ;          v = DateEx::<function>
      ;          e = DateEx::DateError()
      ;          If e <> 0
      ;             Debug "Error " + e
      ;          Else
      ;             ; OK
      ;          EndIf
      Protected temp.i=0

      Swap temp, g_Error     ; set g_Error to 0
      ProcedureReturn temp
   EndProcedure
EndModule


CompilerIf #PB_Compiler_IsMainFile
   ;-- Demo
   EnableExplicit
   UseModule DateEx

   Define check.q, d.q, i.i, birthday.q, jd.d, out$, age.Period

   check = DateQ(2023, 5, 31)

   ; -- DateQ()
   Assert(DateQ(1970,1,1) = 0)
   Assert(DateQ() = Date())

   ; -- AddDateQ()
   Assert(AddDateQ(check, #PB_Date_Year, 2) = AddDate(check, #PB_Date_Year, 2))

   ; -- YearQ(), MonthQ(), DayQ() etc.
   d = DateQ(1582, 10, 4)
   Assert(Str(YearQ(d)) + "-" + MonthQ(d) + "-" + RSet(Str(DayQ(d)),2,"0") = "1582-10-04")
   Assert(DayOfWeekQ(d) = #Thursday)
   d = AddDateQ(d, #PB_Date_Day, 1)
   Assert(Str(YearQ(d)) + "-" + MonthQ(d) + "-" + RSet(Str(DayQ(d)),2,"0") = "1582-10-15")
   Assert(DayOfWeekQ(d) = #Friday)

   ; -- DayOfWeekQ(...)
   For i = 5 To 11           ; Monday, ..., Sunday
      d = DateQ(2023, 6, i)
      Assert(DayOfWeekQ(d) = DayOfWeek(d))
   Next
   Assert(DayOfWeekQ(DateQ(1958, 2, 21)) = 5)  ; Friday

   ; -- DayOfWeekQ(..., #True)
   For i = 5 To 11           ; Monday, ..., Sunday
      d = DateQ(2023, 6, i)
      Assert(DayOfWeekQ(d, #True) = i-4)
   Next
   Assert(DayOfWeekQ(DateQ(1958, 2, 21), #True) = 5)  ; Friday

   ; -- DayOfYearQ()
   Assert(DayOfYearQ(check) = DayOfYear(check))

   ; -- FormatDateQ()
   Assert(FormatDateQ("The date is %yyyy-%mm-%dd.", check) = "The date is 2023-05-31.")

   Debug "Now: " + FormatDateQ("%ww, %d. %mmmm %yyyy %hh:%ii:%ss")
   Debug "Now: " + FormatDateQ() + " " + TimeZoneStr()
   Debug ""

   ; -- ParseDateQ()
   Assert(ParseDateQ("%yyyyy-%mm-%dd", "32767-12-31") = DateQ(32767, 12, 31))

   ; -- LastDayInMonth()
   Assert(LastDayInMonth(2016, 2) = DateQ(2016, 2, 29))

   ; -- DaysInMonth()
   Assert(DaysInMonth(2020, 2) = 29)

   ; -- WeekNumber()
   Assert(WeekNumber(DateQ(2010, 1, 3)) = 53)

   Debug "-- LocalizedDayName()"
   out$ = ""
   For i = 1 To 7
      out$ + ", " + LocalizedDayName(i)
   Next
   Debug Mid(out$, 3)
   Debug ""

   Debug "-- LocalizedMonthName()"
   out$ = ""
   For i = 1 To 12
      out$ + ", " + LocalizedMonthName(i)
   Next
   Debug Mid(out$, 3)
   Debug ""

   Debug "-- DateDiff() and Period2Str()"
   birthday = DateQ(1958, 2, 21)
   ; Ignore hours, minutes and seconds:
   With age
      \hours   = -1
      \minutes = -1
      \seconds = -1
   EndWith
   If DateDiff(birthday, DateQ(), @age) > -1
      Debug "Current age: " + Period2Str(age)
   Else
      Debug "DateError() = " + DateError()
   EndIf

   ; -- DateFromDayOfYear()
   d = DateFromDayOfYear(2025, 365)
   Assert(FormatDateQ("%yyyy-%mm-%dd", d) = "2025-12-31")

   ; -- nth_WeekDay()
   Assert(nth_WeekDay(#Saturday, -2, DateQ(2018,3,31)) = DateQ(2018, 3, 24))

   ; -- nth_WeekDay_InMonth()
   Assert(nth_WeekDay_InMonth(#Friday, 1, 2018, 3) = DateQ(2018, 3,  2))

   ; -- Easter()
   Assert(Easter(1818) = DateQ(1818, 3, 22))
   Assert(Easter(1943) = DateQ(1943, 4, 25))

   ; -- Advent()
   Assert(Advent(2024) = DateQ(2024, 12, 1))

   ; -- Julian date
   jd = PureDate2Julian(971890963199)           ; DateQ(32767,12,31, 23,59,59)
   Assert(jd = 13689325.499988426)
   d = Julian2PureDate(jd)
   Assert(d = 971890963199)
CompilerEndIf
 
See also demo program how to easily create an annual overview calendar.


-------------------------------------------------

My best tricks & tips from 15+ years
Create arrays elegantly
Extended date library
Save JSON data with object members well-arranged
Evaluate and process math expressions
Functions for sets
Statistics with R
Thue-Morse sequence
Natural sorting
Sort array indexes and parallel arrays
Time profiling
VectorIcons
Generate focus events
Last edited by Little John on Fri Jul 25, 2025 10:57 am, edited 22 times in total.
User avatar
jacdelad
Addict
Addict
Posts: 1991
Joined: Wed Feb 03, 2021 12:46 pm
Location: Riesa

Re: DateEx: Extended Date library

Post by jacdelad »

We already have at least 2 modules for date handling with quads, however, the more the better. Thanks for sharing, I'll test it today, if I have time.
Good morning, that's a nice tnetennba!

PureBasic 6.21/Windows 11 x64/Ryzen 7900X/32GB RAM/3TB SSD
Synology DS1821+/DX517, 130.9TB+50.8TB+2TB SSD
BarryG
Addict
Addict
Posts: 4121
Joined: Thu Apr 18, 2019 8:17 am

Re: DateEx: Extended Date library

Post by BarryG »

The module I'm currently using has inline ASM, so I can't compile my app with the C backend due to it. So I'm looking forward to testing this ASM-less one.
User avatar
jacdelad
Addict
Addict
Posts: 1991
Joined: Wed Feb 03, 2021 12:46 pm
Location: Riesa

Re: DateEx: Extended Date library

Post by jacdelad »

That's why I said it's good to have another one.
I'm still looking for that one that can convert from gregorian<>julian plus works in the BC segment. Not that I need it, but this would be cool. Plus conversion to/from other date formats.
Good morning, that's a nice tnetennba!

PureBasic 6.21/Windows 11 x64/Ryzen 7900X/32GB RAM/3TB SSD
Synology DS1821+/DX517, 130.9TB+50.8TB+2TB SSD
jassing
Addict
Addict
Posts: 1885
Joined: Wed Feb 17, 2010 12:00 am

Re: DateEx: Extended Date library

Post by jassing »

I tried with pb6.00, asm - line 412 _splitdateq() "Procedure stack has been corrupted"
Little John
Addict
Addict
Posts: 4772
Joined: Thu Jun 07, 2007 3:25 pm
Location: Berlin, Germany

Re: DateEx: Extended Date library

Post by Little John »

jassing wrote: Wed Jul 05, 2023 6:15 pm I tried with pb6.00, asm - line 412 _splitdateq() "Procedure stack has been corrupted"
Can you post a short working code snippet that allows to reproduce the problem?
Did you use the 32 bit version or the 64 bit version of PureBasic? On which operating system?
jassing
Addict
Addict
Posts: 1885
Joined: Wed Feb 17, 2010 12:00 am

Re: DateEx: Extended Date library

Post by jassing »

Little John wrote: Wed Jul 05, 2023 6:48 pm
jassing wrote: Wed Jul 05, 2023 6:15 pm I tried with pb6.00, asm - line 412 _splitdateq() "Procedure stack has been corrupted"
Can you post a short working code snippet that allows to reproduce the problem?
Did you use the 32 bit version or the 64 bit version of PureBasic? On which operating system?
I just ran the code as is, no changes.
64 bit.
Little John
Addict
Addict
Posts: 4772
Joined: Thu Jun 07, 2007 3:25 pm
Location: Berlin, Germany

Re: DateEx: Extended Date library

Post by Little John »

On which operating system :?:
jassing
Addict
Addict
Posts: 1885
Joined: Wed Feb 17, 2010 12:00 am

Re: DateEx: Extended Date library

Post by jassing »

Little John wrote: Thu Jul 06, 2023 6:04 am On which operating system :?:
windows 8
Little John
Addict
Addict
Posts: 4772
Joined: Thu Jun 07, 2007 3:25 pm
Location: Berlin, Germany

Re: DateEx: Extended Date library

Post by Little John »

I can't test that, because I don't have access to Windows 8.
However, I tested the code in the first message of this thread (version 1.00) with PureBasic 6.00 LTS (x64) on Windows 11:
No problem here at all (with both the ASM backend and the C backend).
Little John
Addict
Addict
Posts: 4772
Joined: Thu Jun 07, 2007 3:25 pm
Location: Berlin, Germany

Re: DateEx: Extended Date library

Post by Little John »

New version 1.01, 2023-07-06:
a few marginal improvements
Little John
Addict
Addict
Posts: 4772
Joined: Thu Jun 07, 2007 3:25 pm
Location: Berlin, Germany

Re: DateEx: Extended Date library

Post by Little John »

New version 1.10, 2023-07-27

Changed
  • Dates prior to 1582-10-15 are now calculated according to the Julian calendar.
    So this version is not compatible with the previous one.
  • small internal changes
  • slightly improved comments
New
  • Procedure PureDate2Julian()
  • Procedure Julian2PureDate()

    Note: The Julian date must not be confused with a date in the Julian calendar.
Little John
Addict
Addict
Posts: 4772
Joined: Thu Jun 07, 2007 3:25 pm
Location: Berlin, Germany

Re: DateEx: Extended Date library

Post by Little John »

jacdelad wrote: I'm still looking for that one that can convert from gregorian<>julian plus works in the BC segment.
When I'll have some time, maybe a future version of this module will work with dates in the BC era, too. :-)
User avatar
jacdelad
Addict
Addict
Posts: 1991
Joined: Wed Feb 03, 2021 12:46 pm
Location: Riesa

Re: DateEx: Extended Date library

Post by jacdelad »

This would be awsome! If I had time I would try it (and not finish it).
Good morning, that's a nice tnetennba!

PureBasic 6.21/Windows 11 x64/Ryzen 7900X/32GB RAM/3TB SSD
Synology DS1821+/DX517, 130.9TB+50.8TB+2TB SSD
Little John
Addict
Addict
Posts: 4772
Joined: Thu Jun 07, 2007 3:25 pm
Location: Berlin, Germany

Re: DateEx: Extended Date library

Post by Little John »

New version 1.20, 2023-08-01

Changed
  • Better checking of parameters in several functions.
  • In all functions that take a weekday as parameter, Sunday now can be given as 0 (compliant with PB's date functions) or as 7 (according to ISO).
  • ParseDateQ() now behaves like PB's ParseDate(), but has an optional parameter that allows for more strict parameter checking.
  • FormatDateQ() and ParseDateQ() now handle the mask strings case-insensitive. So do PB's FormatDate() and ParseDate() – although this is not documented.
  • Small internal changes.
New
  • Many functions of the module return -1 on error (like many built-in PB date functions). However, in this module -1 also can mean the valid date 1969-12-31 23:59:59. In order to provide clear error information, the new function DateError() yields 0 on success of a function, otherwise one of 17 error codes.
Post Reply