AL is missing a CalcTime function, let’s fix that!

Everybody loves the power of CalcDate, but what about calculating time? In this video, I set out to create a CalcTime function that gives the same power as CalcDate. Check out the video:

https://youtu.be/1hmqM-h65vI

In AL (Business Central’s development language), we have the incredibly useful CalcDate function for date arithmetic — but there’s no equivalent CalcTime for time calculations. In this video, Erik builds a CalcTime function from scratch, exploring how Time variables work under the hood and creating two overloads: one for Time and one for DateTime.

The Problem: No CalcTime in AL

Every AL developer is familiar with CalcDate, which lets you write expressive date formulas like '2W' (two weeks) or '-4D' (minus four days). But when it comes to time calculations — say you need to add 56 hours or 1 hour and 30 minutes — there’s nothing built in. Erik sets out to fix that.

Understanding Time Under the Hood

The key insight is that behind the scenes, a Time variable in AL is simply the number of milliseconds since midnight. With that knowledge, time arithmetic becomes straightforward — once you get past a few quirks of the AL type system.

The Conversion Trick

AL won’t let you directly assign a Time to an Integer. However, it will let you subtract two Time values, which returns an integer (the difference in milliseconds). So to convert a time to an integer, you subtract the midnight constant 000000T:

intTime := Time() - 000000T;

Interestingly, AL allows subtracting times but not adding them — because adding two times could overflow past midnight. However, you can add an integer to the midnight constant to get back to a Time:

t := 000000T + intTime;

Defining Time Units

With milliseconds as the base unit, the time constants are simple multiplication:

Minute := 1000 * 60;
Hour := Minute * 60;
Day := Hour * 24;

This lets you do things like intTime += 5 * Hour to add five hours to a time value.

Building the CalcTime Function

The design follows the same pattern as CalcDate: a string expression where integers are followed by unit postfixes. For example:

  • 1H — one hour
  • -1H — minus one hour
  • 56H — fifty-six hours
  • 1H30M — one hour and thirty minutes
  • 1H2M13S — one hour, two minutes, thirteen seconds

The Parsing Algorithm

The parser walks through each character in the time expression. Digits are accumulated into a string (Op). When a letter is encountered, the accumulated digits are evaluated as an integer, and the appropriate millisecond calculation is applied based on the unit letter:

procedure CalcTime(TimeExpression: Text; StartTime: Time): Time
var
    i: Integer;
    Op: Text;
    OpInt: Integer;
    StartTimeInteger: Integer;
begin
    StartTimeInteger := StartTime - 000000T;
    for i := 1 to strlen(TimeExpression) do begin
        if IsChar(TimeExpression[i]) then begin
            Evaluate(OpInt, Op);
            case TimeExpression[i] of
                'H':
                    StartTimeInteger += OpInt * 60 * 60 * 1000;
                'M':
                    StartTimeInteger += OpInt * 60 * 1000;
                'S':
                    StartTimeInteger += OpInt * 1000;
            end;
            Op := '';
        end else
            Op += TimeExpression[i];
    end;
    StartTimeInteger := StartTimeInteger mod (24 * 60 * 60 * 1000);
    exit(000000T + StartTimeInteger);
end;

The mod operation at the end ensures the result wraps around if it exceeds 24 hours, keeping it within valid Time bounds. A small helper function determines whether a character is a letter:

procedure IsChar(c: Char): Boolean
begin
    exit(c in ['A' .. 'Z', 'a' .. 'z']);
end;

Handling Negative Values

Erik tests negative values like -21H and discovers that AL handles the arithmetic gracefully — subtracting 21 hours from 20:00 correctly wraps around to just before midnight. The modular arithmetic and AL’s built-in handling of 000000T + intTime take care of this automatically.

The DateTime Overload

The Time overload wraps at midnight, which means adding 56 hours just gives you a time-of-day. To properly handle multi-day offsets, Erik creates a second overload that works with DateTime instead. The key change: instead of using an integer for milliseconds, use AL’s Duration type, which can be added directly to a DateTime:

procedure CalcTime(TimeExpression: Text; StartDateTime: DateTime): DateTime
var
    i: Integer;
    Op: Text;
    OpInt: Integer;
    ExpressionDuration: Duration;
begin
    for i := 1 to strlen(TimeExpression) do begin
        if IsChar(TimeExpression[i]) then begin
            Evaluate(OpInt, Op);
            case TimeExpression[i] of
                'W':
                    ExpressionDuration += OpInt * 7 * 24 * 60 * 60 * 1000;
                'D':
                    ExpressionDuration += OpInt * 24 * 60 * 60 * 1000;
                'H':
                    ExpressionDuration += OpInt * 60 * 60 * 1000;
                'M':
                    ExpressionDuration += OpInt * 60 * 1000;
                'S':
                    ExpressionDuration += OpInt * 1000;
            end;
            Op := '';
        end else
            Op += TimeExpression[i];
    end;
    exit(StartDateTime + ExpressionDuration);
end;

This version also adds support for days (D) and weeks (W), since crossing day boundaries now makes perfect sense with DateTime. Months are intentionally excluded — they have variable lengths, which would complicate things significantly (and that’s what CalcDate is for).

Testing the DateTime Overload

Erik tests with expressions like 56H, -56H, and 1W4H (one week and four hours). The results correctly span across days and even months — 1W4H from late May correctly lands on June 5th in the early morning hours.

The Complete Extension

Here’s the full source code for the extension, including both overloads and the test trigger:

pageextension 50100 CustomerListExt extends "Customer List"
{
    trigger OnOpenPage()
    var
        t: Time;
        intTime: Integer;
        Hour: Integer;
        Minute: Integer;
        Day: Integer;
    begin
        intTime := Time() - 000000T;
        Minute := 1000 * 60;
        Hour := Minute * 60;
        Day := Hour * 24;

        intTime += 5 * Hour;

        t := 000000T + intTime;
        message('%1 = %2', 'CalcTime', CalcTime('1W4H', CurrentDateTime()));
    end;

    procedure CalcTime(TimeExpression: Text; StartDateTime: DateTime): DateTime
    var
        i: Integer;
        Op: Text;
        OpInt: Integer;
        ExpressionDuration: Duration;
    begin
        for i := 1 to strlen(TimeExpression) do begin
            if IsChar(TimeExpression[i]) then begin
                Evaluate(OpInt, Op);
                case TimeExpression[i] of
                    'W':
                        ExpressionDuration += OpInt * 7 * 24 * 60 * 60 * 1000;
                    'D':
                        ExpressionDuration += OpInt * 24 * 60 * 60 * 1000;
                    'H':
                        ExpressionDuration += OpInt * 60 * 60 * 1000;
                    'M':
                        ExpressionDuration += OpInt * 60 * 1000;
                    'S':
                        ExpressionDuration += OpInt * 1000;
                end;
                Op := '';
            end else
                Op += TimeExpression[i];
        end;
        exit(StartDateTime + ExpressionDuration);
    end;

    procedure CalcTime(TimeExpression: Text; StartTime: Time): Time
    var
        i: Integer;
        Op: Text;
        OpInt: Integer;
        StartTimeInteger: Integer;
    begin
        StartTimeInteger := StartTime - 000000T;
        for i := 1 to strlen(TimeExpression) do begin
            if IsChar(TimeExpression[i]) then begin
                Evaluate(OpInt, Op);
                case TimeExpression[i] of
                    'H':
                        StartTimeInteger += OpInt * 60 * 60 * 1000;
                    'M':
                        StartTimeInteger += OpInt * 60 * 1000;
                    'S':
                        StartTimeInteger += OpInt * 1000;
                end;
                Op := '';
            end else
                Op += TimeExpression[i];
        end;
        StartTimeInteger := StartTimeInteger mod (24 * 60 * 60 * 1000);
        exit(000000T + StartTimeInteger);
    end;

    procedure IsChar(c: Char): Boolean
    begin
        exit(c in ['A' .. 'Z', 'a' .. 'z']);
    end;
}

Key Takeaways

  • Time is milliseconds since midnight — this is the fundamental insight that makes time arithmetic possible in AL.
  • Subtraction trick — you can’t cast Time to Integer directly, but Time - 000000T gives you the integer value.
  • Addition trick000000T + Integer gets you back to a Time value.
  • Two overloads serve different needs — the Time overload wraps at midnight (useful for time-of-day calculations), while the DateTime overload using Duration handles multi-day spans correctly.
  • Negative values work — AL handles the modular arithmetic for negative time offsets gracefully.
  • Months are excluded by design — variable-length months don’t belong in a time calculation function; that’s what CalcDate handles.

This is roughly 100 lines of code that fills a genuine gap in the AL language. Erik mentions considering a pull request to Microsoft’s System App or Base App — if you’d like to see this become a first-class citizen in Business Central, let the community know!