Calendar/Date Math Package?

Is anyone working on a package for calendar operations? For example, calculating the date 3 months from today, or the number of workdays between two dates, etc. Similar calculators are provided on Uses for the Date Calculators

7 Likes

Hi, welcome to the forum!

If month means 30 days, then using the native types is sufficient:

#{
  let x = datetime.today() + 3 * duration(days: 30)
  x.display()
}

Thanks for the suggestion! I tried that method (e.g., adding 90 days for three months) but it quickly falls apart when you hit all the non-30 day months. It turns out that date math is strangely complicated.

Since I couldn’t find an existing package, I’m going to put something together myself. It seems like a great first project to work on. I’m not a programmer by trade and am just getting into Typst, so who knows how good the quality of the resulting package will be, but maybe others will find it helpful when I have it working.

5 Likes

I don’t want to put you off the idea, but this video is a good warning of the mess you’re getting into if you want to make all edge cases work (leap days, leap seconds, summer and winter time, historical changes in time zones). The video is a bit exaggerated but it is a nice overview of the pitfalls, it is harder than you think.

Good luck!

6 Likes

Hahahah love it. Yes, it is definitely a deep rabbit hole! I’m starting small and targeting the use cases that I need, and then might go from there.

OK, so I have functions written now to test for a leap year, determine the days in a month, the day of the week, and adding/subtracting days/months/years to a base-date. Working on durations next that will take into account leap years, business days, holidays, etc.

1 Like

Thankfully for the basics we don’t need to consider all that complicated stuff like leap days and leap seconds—Typst’s datetime handles this correctly (hopefully) by being based on a proper calendar library.

Adding 3 months is “hard” exactly because it is not an exact duration like duration(days: 90), but in reverse that means that we only need to handle the number of months “naively”. Here’s one way to offset dates by a number of years and months:

#let date-offset(date, years: 0, months: 0) = {
  let date = (year: date.year(), month: date.month(), day: date.day())

  // months overflow into years
  // months are 1-based, factor that in when dividing
  let month = months + date.month
  years += calc.div-euclid(month - 1, 12)
  date.month = calc.rem-euclid(month - 1, 12) + 1

  date.year += years

  // conert back into datetime -- won't work if a month is too short
  datetime(..date)
}

(I purposefully didn’t add a days parameter since it is ambiguous: if you increase 2026-02-28 by one month and one day, do you get 2026-03-29 (one day after 2026-03-28) or 2026-04-01 (one month after 2026-03-01)? For days and weeks, just use duration)

Here’s a test:

#let d = datetime(year: 2025, month: 10, day: 31)

#d.display()\
// #date-offset(d, months: 1).display()  // 2025-11-31 doesn't exist!
#date-offset(d, months: 2).display()\
#date-offset(d, months: 3).display()     // year overflows

#date-offset(d, months: -2).display()\   // negative offset
#date-offset(d, months: -9).display()\
#date-offset(d, months: -10).display()   // year overflows

#let d = datetime(year: 2024, month: 2, day: 29)
// #date-offset(d, years: 1).display()   // 2025-02-29 doesn't exist!

And here’s an implementation for leap year detection:

#let is-leap-year(year) = {
  let a = datetime(year: year, month: 2, day: 28)
  let b = datetime(year: year, month: 3, day: 1)
  b - a == duration(days: 2)
}

Weekdays are built into Typst, and you can determine the length of a month similarly to how I detected a leap year.


@Steve_Moore if your code is significantly more complex than this, then I hope this helps. Otherwise, hopefully others can benefit at least. Handling work days and holidays requires more than Typst built-in types provide, so that code will end up more complex…

Finally—not really related to writing a calendar math package, but maybe interesting for this thread: a while ago, I created a calendar for this thread: Is anyone working on something like the LaTeX tikz Kalender? - #2 by SillyFreak. It demonstrates a bit how working with datetime and duration can look like.

Thank you for the ideas! That is much simpler than what I’ve done so far. I ended up including a while loop to be able to add years, months, and days to any base date. I wrote similar but slightly different functions for adding and subtracting dates, since the while loops count the days in different directions in each case. There’s a good chance the way I wrote things is clunky and inelegant :smile: but it works so far. It handles all the edge cases I could think to try out and rolls over years and months appropriately, even if you do weird things like add 2 years, 4 months, and then 100 days. Would it be helpful for me to post the code here? Or should I just setup something on github for it?

1 Like

Here’s how I handled leap years, which is different from yours. I see that you’re taking advantage of the built-in leap year checking of the duration function. I hadn’t thought of that so I just checked the various conditions directly.

#let is-leap-year(year) = {
  if calc.rem(year, 4) == 0 {
    if calc.rem(year, 100) == 0 {
      if calc.rem(year, 400) == 0 {
        true
      } else {
        false
      }
    } else {
      true
    }
  } else {
    false
  }
}

As part of the day addition/subtraction I wrote a helper function to return the days in a given month of given year.

#let days-in-month(year, month) = {
  if month in (4, 6, 9, 11) {
    30
  } else if month == 2 {
    if is-leap-year(year) {
      29
    } else {
      28
    }
  } else {
    31
  }
}

The addition and subtraction functions are more complicated because I included days. I’m sure how I write variables could be more concise, too, but I’m just fumbling through this. :smiley: Here’s what it looks like right now for the addition function.

#let date-add(date, ..args) = {

  let original-year = date.year()
  let original-month = date.month()
  let original-day = date.day()

  let years-to-add = args.at("years-to-add", default: 0)
  let months-to-add = args.at("months-to-add", default: 0)
  let days-to-add = args.at("days-to-add", default: 0)

  let pattern = args.at("pattern", default: "yyyy-MM-dd")

  // Validate inputs
  if type(date) != datetime {
    panic("Invalid date: must be a datetime object, got " + str(type(date)))
  }

  if type(years-to-add) != int {
    panic("Invalid years-to-add: must be an integer, got " + str(type(years-to-add)))
  }

  if type(months-to-add) != int {
    panic("Invalid months-to-add: must be an integer, got " + str(type(months-to-add)))
  }

  if type(days-to-add) != int {
    panic("Invalid days-to-add: must be an integer, got " + str(type(days-to-add)))
  }

  // Convert years to months and add all months together, subtracting 1 for the starting month
  let total-months = (original-year * 12) + (original-month - 1) + (years-to-add * 12) + months-to-add

  // Calculate the new year and month and day (without adding new days yet)
  let new-year = calc.floor(total-months / 12)
  let new-month = calc.rem(total-months, 12) + 1
  let max-days-in-new-month = days-in-month(new-year, new-month)
  let new-day = calc.min(max-days-in-new-month, original-day)

  // Add days if days-to-add is provided
  while days-to-add > 0 {

      // Determine the number of days remaining in the current month
      let days-remaining-in-month = days-in-month(new-year, new-month) - new-day + 1

      // Add days in same month or rollover to next month/year
      if days-to-add < days-remaining-in-month {
        new-day += days-to-add
        days-to-add = 0
      } else {
        days-to-add -= days-remaining-in-month
        new-day = 1
        new-month += 1
        if new-month > 12 {
          new-month = 1
          new-year += 1
        }
      }
    }

  // Create a datetime for the new date
  let new-date = datetime(
    year: new-year,
    month: new-month,
    day: new-day,
  )

  return new-date
}
1 Like

Thanks for that Calendar example. And yeah, the holidays and workdays will definitely be more complicated. That’s what I’m working on next. I think the while loop method that I used for counting days will be helpful, since I can add a check for the day of the week to exclude, for example, weekends from the count. The same method will work for determining the date of the 3rd Thursday in June, for example.

Holidays will be hard. I’m focused on US Federal holidays because that’s what I care about for my work. Handling all international holidays is out of scope right now. But maybe I can write it such that an array of holidays could be provided by the user.

Both forum and Github have their pros and cons:

  • If you solve your own problem, you should at least post enough here in the thread that you can mark your question as solved (assuming you actually consider it solved, of course).
  • Code here is licensed CC-BY-4.0, so sharing it here makes it legally safe for others to reuse it. On Github, you have to add your own license or others may have problems.
  • But copying code from a forum is not the most efficient, Github is a cleaner way of sharing code.

I’d say the amount of code you posted is on the longer end of what makes sense to share through the forum. Anything more probably goes better into a repository.


I have some code advice; feel free to ignore it though: as long as your code works and you understand it it’s fine :slight_smile: but if you feel there’s room for improvement but are not sure where, maybe this helps:

Code review

When you have ifs that result in either true or false, you can simplify that to just the condition:

if calc.rem(year, 400) == 0 {
  true
} else {
  false
}

is the same as

calc.rem(year, 400) == 0

So progressively, you can simplify to

let is-leap-year(year) = {
  if calc.rem(year, 4) == 0 {
    if calc.rem(year, 100) == 0 {
      calc.rem(year, 400) == 0
    } else {
      true
    }
  } else {
    false
  }
}

then

let is-leap-year(year) = {
  if calc.rem(year, 4) == 0 {
    not calc.rem(year, 100) == 0 or calc.rem(year, 400) == 0
  } else {
    false
  }
}

and then

let is-leap-year(year) = {
  calc.rem(year, 4) == 0 and (not calc.rem(year, 100) == 0 or calc.rem(year, 400) == 0)
}

of course, somewhere in there the term “simplify” stops being accurate :stuck_out_tongue:


In date-add, I’d use regular named arguments instead of ..args. What you write should be equivalent to this:

#let date-add(
  date,
  pattern: "yyyy-MM-dd",
  years-to-add: 0,
  months-to-add: 0,
  days-to-add: 0,
) = {
  ...
}

and finally you can simplify the if ... panic code by using assert instead:

assert.eq(type(date), datetime, message: "Invalid date: must be a datetime object, got " + str(type(date)))
1 Like

Awesome, thanks! Once I add the last few functions I’ll consider it solved and mark it accordingly.

The licensing I’m not concerned about here, the CC-BY-4.0 license looks pretty good to me for what this is. (I’m merely re-inventing the wheel in Typst :roll_eyes:)

I appreciate the code suggestions. I explored using and/or/not to add additional conditions for an if/then statement but something didn’t work correctly. It is much cleaner though so I’ll try that out.

I also think I tried listing named arguments at first but ran into an issue. It’s likely that I changed how I wrote those as an attempt to fix a problem that had a different root cause, and then didn’t go back and undo the change.

I wondered how assert would work, that’s helpful. Thank you!

1 Like

I’ve finished writing the main functions and have added a few extras. I just need to get everything cleaned up and in a package format before I publish. The available functions are:

  • is-leap-year(year) determines whether a given year is a leap year.
  • count-leap-years(year1, year2) returns the number of leap years between two years (I just added this for fun, there isn’t a good reason to use it yet)
  • days-in-month(year, month) returns the number of days in a given month of a given year
  • days-in-year(year) returns the number of days in a given year
  • weekend-offset(date, direction: “closest”) adjusts a date that falls on a weekend to the “closest” weekday, or “forward” to monday
  • nth-weekday(n, weekday, year, month) returns the day of the nth instance of a weekday in a given month of a given year
  • last-weekday(weekday, year, month) returns the day of the last instance of a weekday in a given month of a given year
  • is-weekday(year, month, day) determines whether a given day is a weekday
  • is-holiday(year, month, day) determines whether a given date is a holiday (based on the weekend offset of the holiday)
  • holiday-name(date) returns the name of a holiday if the date provided is a holiday
  • holidays-in-year(year) returns an array of dates of holidays in a given year
  • date-add(date, years-to-add, months-to-add, days-to-add) adds years, months, and/or days to a given base date
  • date-subtract(date, years-to-subtract, months-to-subtract, days-to-subtract) subtracts years, months, and/or days from a given base date
  • date-duration(start-date, end-date, inclusive, weekends, holidays) counts the days between a start date and an end date and can optionally include the end date, exclude weekends, and exclude holidays
  • age(birthday, as-of-date) returns the age in years as of today or a given date
1 Like

When I publish the package, I’ll link it here as a solution.