Blog
All Blog Posts | Next Post | Previous Post
Extend TMS WEB Core with JS Libraries with Andrew:
Luxon
Tuesday, May 24, 2022
The last couple of posts covered FlatPickr, a popular and very capable datepicker JavaScript library. We looked at many of its options and even looked at two completely different ways to add it to our TMS WEB Core projects. What we didn't really delve too much into is the whole area of date and time formats, time zones, internationalization, and the differences between date handling in Delphi and JavaScript. Well, we did a little, but just enough to get through some examples. This time out, we'll go into a bit more detail and also introduce the latest JavaScript library to our repertoire - Luxon - which describes itself as "a powerful, modern, and friendly wrapper for JavaScript dates and times." I'm not at all sure that I buy the "friendly" bit, but it is indeed powerful and modern, so let's see where it fits in. Note, also, that Luxon is the successor to a previously widely used JavaScript library, Moment.js.
Motivation.
Long-time Delphi developers are likely to be pretty familiar with TDateTime and its various advantages and shortcomings. And even if you're relatively new to Delphi and haven't had much of an opportunity to interact with TDateTime, there's not much there to trip you up initially, and, by and large, it isn't likely to garner much attention on its own. This is a good thing, of course. When dealing with JavaScript, however, the nice and comfy TDateTime is replaced with a rather sinister JavaScript date format that is none of these things. Sure, it is easy enough to get started with a JavaScript date, but it really is an entirely different beast. Moving between the two can be a challenge, and even just trying to do the tiniest bit of formatting can sometimes be a lot more trouble than might seem possible. In this post, we're going to go over a bunch of this kind of stuff. Get it all out in one fell swoop, rip off the band-aid so to speak, so we can candidly and confidently move on to other topics, but have this in our back pocket when we need it. And believe me, we'll be needing it!
Epic Epoch.
Let's dip our toe into the shallow end first and quickly go over what TDateTime is. Delphi uses the TDateTime class to encode, naturally, a date and a time. It does this in a very simple way - by using a floating point number (specifically, a double) where the whole part of the number represents the number of days since 1899-12-30 and the fractional part of the number represents the time as a fraction of a day. Noon would be represented as 0.5, 18:00 would be represented as 0.75, and so on. The choice of 1899-12-30 is somewhat arbitrary, but I ran across this link which described it this way:
It appears that the reason for Delphi starting at 30 Dec 1899 is to make it as compatible as possible with Excel while at the same time not adopting Excel's incorrectness about dates. Historically, Excel played second fiddle to Lotus 1-2-3. Lotus (which may have got this error from Visicalc) incorrectly considered 1900 to be a leap year hence a value of 60 gives you 29 Feb 1900 in Excel but is interpreted as 28 Feb 1900 in Delphi due to Delphi starting 1 day before. From the 01 Mar 1901 the two date systems give the same result for a given number.
Great, another reason to loathe Excel. Like there weren't enough already! The actual details around this aren't going to matter all that much unless, of course, you happen to work with a lot of dates around that time (or earlier) where this might be an issue. And we don't have to worry much about the other direction either. As a double, the maximum date that can be represented with TDateTime is at least 9999-12-31. It can actually represent dates after that, but things get a little squirrelly when the year is more than four digits.
Unix (and Linux) 32-bit variants store this information as integers, the number of seconds since 1970-01-01 00:00:00. Using signed 32-bit integers means that the maximum value that can be stored will overflow in 2038. Curiously, the minimum date is in 1901. Fortunately, it seems that 64-bit systems wisely updated these to use 64-bit integers, so this shouldn't be a problem. I'm sure by 2038 you'd be hard-pressed to find a toaster without a 64-bit processor. In JavaScript, a similar approach is taken, but it is the number of milliseconds since 1970-01-01 00:00:00 and I believe it has always used 64-bit integers (or, rather, something roughly equivalent), so nothing to worry about there. Of special note though is that JavaScript assumes the value to specifically be the number of milliseconds since 1970-01-01 00:00:00 UTC. Those last three letters will become important later.
Basic Delphi Usage.
Using TDateTime within Delphi is not particularly difficult and likely something you're already familiar with, so we'll not spend too much time on it. Fortunately, this all works just fine in TMS WEB Core as well. Here are a bunch of examples of the ways I use TDateTime most often. These are the kinds of things we'll want to be able to do in JavaScript as well. And while some of this might seem like a big steaming pile of headaches, in practice this isn't the case. Most of the time, date pickers are used to pick the actual dates from a calendar, or other UI elements are used, to make much of this a non-issue. And you typically wouldn't be using all of these at the same time!
... uses ... System.DateUtils, ... procedure TForm1.WebButton2Click(Sender: TObject); procedure TestTDateTime(aDateTime: TDateTime); begin console.log('TDateTime Value: '+FloatToStrF(ADateTime,ffNumber,5,3)); console.log('TDateTime = 0.0: '+DateTimeToStr(0.0)); console.log('Favourite DateTime Format: ' +FormatDateTime('yyyy-mm-dd hh:nn:ss', aDateTime)); console.log('Second Favourite DateTime Format: ' +FormatDateTime('yyyy-mmm-dd hh:nn:ss', aDateTime)); console.log('Third Favourite DateTime Format: ' +FormatDateTime('yyyy-mmm-dd (ddd) hh:nn:ss', aDateTime)); console.log('Short DateTime Format: ' +FormatDateTime('ddddd t', aDateTime)); console.log('Long DateTime Format: ' +FormatDateTime('ddddd tt', aDateTime)); console.log('Short Date Format Settings: ' +FormatSettings.ShortDateFormat); console.log('Long Date Format Settings: ' +FormatSettings.LongDateFormat); console.log('Date Separator Settings: ' +FormatSettings.DateSeparator); console.log('Short Time Format Settings: ' +FormatSettings.ShortTimeFormat); console.log('Long Time Format Settings: ' +FormatSettings.LongTimeFormat); console.log('Time Separator Settings: ' +FormatSettings.TimeSeparator); console.log('First Day of Month: ' +FormatDateTime('yyyy-mmm-dd', StartOfTheMonth(ADateTime))); console.log('Last Day of Month: ' +FormatDateTime('yyyy-mmm-dd', EndOfTheMonth(ADateTime))); console.log('First Day of Prior Month: ' +FormatDateTime('yyyy-mmm-dd', StartOfTheMonth(IncMonth(ADateTime,-1)))); console.log('Last Day of Prior Month: ' +FormatDateTime('yyyy-mmm-dd', EndOfTheMonth(IncMonth(ADateTime,-1)))); console.log('First Day of Next Month: ' +FormatDateTime('yyyy-mmm-dd', StartOfTheMonth(IncMonth(ADateTime,+1)))); console.log('Last Day of Next Month: ' +FormatDateTime('yyyy-mmm-dd', EndOfTheMonth(IncMonth(ADateTime,+1)))); console.log('Day of Year: ' +IntToStr(DayOfTheYear(aDateTime))); console.log('Day of Week: ' +FormatDateTime('dddd', ADateTime)); // Week starts on Monday console.log('ISO Day of Week Number: ' +IntToStr(DayoftheWeek(ADateTime))); console.log('ISO Week Number: ' +IntToStr(WeekOfTheYear(aDateTime))); console.log('ISO First Day of Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',StartOfTheWeek(aDateTime))); console.log('ISO Last Day of Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',EndOfTheWeek(aDateTime))); console.log('ISO First Day of Prior Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',StartOfTheWeek(aDateTime-7))); console.log('ISO Last Day of Prior Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',EndOfTheWeek(aDateTime-7))); console.log('ISO First Day of Next Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',StartOfTheWeek(aDateTime+7))); console.log('ISO Last Day of Next Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',EndOfTheWeek(aDateTime+7))); // Week starts on Sunday console.log('non-ISO Day of Week Number: ' +IntToStr(DayofWeek(ADateTime))); // If Sunday, use Monday instead because WeekOfTheYear doesn't know about Sunday weeks console.log('non-ISO Week Number: ' +IntToStr(WeekOfTheYear(aDateTime+Trunc(DayOftheWeek(ADateTime)/7)))); console.log('non-ISO First Day of Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',aDateTime + 1 - DayOfWeek(aDateTime))); console.log('non-ISO Last Day of Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',aDateTime + 7 - DayOfWeek(aDateTime))); console.log('non-ISO First Day of Prior Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',aDateTime - 6 - DayOfWeek(aDateTime))); console.log('non-ISO Last Day of Prior Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',aDateTime - DayOfWeek(aDateTime))); console.log('non-ISO First Day of Next Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',aDateTime + 8 - DayOfWeek(aDateTime))); console.log('non-ISO Last Day of Next Week: ' +FormatDateTime('yyyy-mmm-dd (ddd)',aDateTime +14 - DayOfWeek(aDateTime))); console.log('Calculating a day duration: ' +IntToStr(DaysBetween(Today,EncodeDate(1971,05,25)))); console.log('Calculating a time duration: ' +IntToStr(MillisecondsBetween(Now,Now-1))+'ms'); end; begin TestTDateTime(EncodeDateTime(2021, 12, 26, 12, 13, 14, 0)); // A Sunday TestTDateTime(EncodeDateTime(2022, 1, 2, 12, 13, 14, 0)); // Also a Sunday TestTDateTime(EncodeDateTime(2022, 1, 9, 12, 13, 14, 0)); // Yep, another Sunday end;
Gathering up the results of the three dates and putting them into a table gets us the following. We're making a distinction here between weeks that start on Monday (the ISO standard) and weeks that start on Sunday (typically Canada and the USA, but others as well). We'll address that in just a moment, but the main thing to keep in mind is that this changes where Sunday falls in terms of the actual week number. If you don't use week numbers, this is a non-issue. If you do, this is, well, this is not a non-issue at all.
2021-Dec-26 (Sunday) |
2022-Jan-02 (Sunday) |
2022-Jan-09 (Sunday) |
|
TDateTime Value |
44,556.509 |
44,563.509 |
44,570.509 |
TDateTime = 0.0 |
1899-12-30 |
1899-12-30 |
1899-12-30 |
Favorite DateTime Format |
2021-12-26 12:13:14 |
2022-01-02 12:13:14 |
2022-01-09 12:13:14 |
Second Favorite DateTime Format |
2021-Dec-26 12:13:14 |
2022-Jan-02 12:13:14 |
2022-Jan-09 12:13:14 |
Third Favorite DateTime Format |
2021-Dec-26 (Sun) 12:13:14 |
2022-Jan-02 (Sun) 12:13:14 |
2022-Jan-09 (Sun) 12:13:14 |
Short DateTime Format |
2021-12-26 12:13 |
2022-01-02 12:13 |
2022-01-09 12:13 |
Long DateTime Format |
2021-12-26 12:13:14 |
2022-01-02 12:13:14 |
2022-01-09 12:13:14 |
Short Date Format Settings |
yyyy-mm-dd |
yyyy-mm-dd |
yyyy-mm-dd |
Long Date Format Settings |
ddd, yyyy-mm-dd |
ddd, yyyy-mm-dd |
ddd, yyyy-mm-dd |
Date Separator Settings |
- |
- |
- |
Short Time Format Settings |
hh:nn |
hh:nn |
hh:nn |
Long Time Format Settings |
hh:nn:ss |
hh:nn:ss |
hh:nn:ss |
Time Separator Settings |
: |
: |
: |
First Day of Month |
2021-Dec-01 |
2022-Jan-01 |
2022-Jan-01 |
Last Day of Month |
2021-Dec-31 |
2022-Jan-31 |
2022-Jan-31 |
First Day of Prior Month |
2021-Nov-01 |
2021-Dec-01 |
2021-Dec-01 |
Last Day of Prior Month |
2021-Nov-30 |
2021-Dec-31 |
2021-Dec-31 |
First Day of Next Month |
2022-Jan-01 |
2022-Feb-01 |
2022-Feb-01 |
Last Day of Next Month |
2022-Jan-31 |
2022-Feb-28 |
2022-Feb-28 |
Day of Year |
360 |
2 |
9 |
Day of Week |
Sunday |
Sunday |
Sunday |
ISO Day of Week Number |
7 |
7 |
7 |
ISO Week Number |
51 |
52 |
1 |
ISO First Day of Week |
2021-Dec-20 (Mon) |
2021-Dec-27 (Mon) |
2022-Jan-03 (Mon) |
ISO Last Day of Week |
2021-Dec-26 (Sun) |
2022-Jan-02 (Sun) |
2022-Jan-09 (Sun) |
ISO First Day of Prior Week |
2021-Dec-13 (Mon) |
2021-Dec-20 (Mon) |
2021-Dec-27 (Mon) |
ISO Last Day of Prior Week |
2021-Dec-19 (Sun) |
2021-Dec-26 (Sun) |
2022-Jan-02 (Sun) |
ISO First Day of Next Week |
2021-Dec-27 (Mon) |
2022-Jan-03 (Mon) |
2022-Jan-10 (Mon) |
ISO Last Day of Next Week |
2022-Jan-02 (Sun) |
2022-Jan-09 (Sun) |
2022-Jan-16 (Sun) |
non-ISO Day of Week Number |
1 |
1 |
1 |
non-ISO Week Number |
52 |
1 |
2 |
non-ISO First Day of This Week |
2021-Dec-26 (Sun) |
2022-Jan-02 (Sun) |
2022-Jan-09 (Sun) |
non-ISO Last day of This Week |
2022-Jan-01 (Sat) |
2022-Jan-08 (Sat) |
2022-Jan-15 (Sat) |
non-ISO First Day of Prior Week |
2021-Dec-19 (Sun) |
2021-Dec-26 (Sun) |
2022-Jan-02 (Sun) |
non-ISO Last Day of Prior Week |
2022-Jan-01 (Sat) |
2022-Jan-01 (Sat) |
2022-Jan-08 (Sat) |
non-ISO First Day of Next Week |
2022-Jan-02 (Sun) |
2022-Jan-09 (Sun) |
2022-Jan-16 (Sun) |
non-ISO Last Day of Next Week |
2022-Jan-08 (Sat) |
2022-Jan-15 (Sat) |
2022-Jan-22 (Sat) |
Calculating a day duration |
18626 |
18626 |
18626 |
non-ISO Last day of next Week |
86400000ms |
86400000ms |
86400000ms |
There are many more functions available, of course, and your favorites may very well be different from mine, naturally. But even from this very basic list of function calls, quite a few topics of interest arise that we should probably cover before continuing on.
But a general disclaimer here. There are various conventions used throughout the world when it comes to using and displaying dates and times. What is customary where I live may very well be different in another part of the world. And people have opinions, sometimes very strongly-held opinions, myself included. And I can't think of anything off the top of my head that any two developers from different regions of the world might disagree on more strongly than something related to date formats. The main overall point I'm trying to make here is that there are options, and as TMS WEB Core developers (or any developers, really) we have a lot of tools at our disposal to make this kind of thing a pleasure for our users or customers, no matter what their particular preferred conventions might be, rather than a punishment. So here goes!
- Regardless of the development platform or programming language, it is generally considered good practice to always use the available functions provided by your environment when dealing with datetime values.
- In part, this is to save you from the headaches that come with having to think about leap years or how many days are in a month. This is also working with the assumption that system_function(a_valid_date) -> another_valid_date.
- Numerous errors can creep in when you start fiddling with things. This is not always easy though, and the further you stray from convention the more likely you are to run into problems. Even in my examples above, there's more fiddling than I'd like with the first/last day of week calculations. Fortunately, I've not yet run across anyone who has argued against weeks having seven days, so this is reasonably safe. One of the few instances where there aren't numerous opinions.
- Speaking of which, ISO8601 seems at first to be a strict standard in many respects, but on closer inspection, it isn't really all that strict at all. Except, curiously, for the start of the week, which it unequivocally states is Monday. However, in my part of the world, Canada, I honestly can't recall ever seeing a printed calendar that didn't have Sunday on the left and Saturday on the right. This means, indirectly, the week starts on Sunday. And everywhere I've worked, and everyone I've worked with, would likely have the same opinion. Yet, the exact opposite may be the case at another location, perhaps in another country or continent. And while I've never run across this situation personally, I've read that in some places the first day of the week can be Saturday. More specifically, some ethnic groups (regardless of physical location) may prefer this. So.... ??! This can be a real problem. Delphi itself even has different variations of its functions. One set of variations offers up the ability to pass in TDateTime values instead of the constituent parts (the "the" functions). Another variation relates to whether Sunday = 1 or Monday = 1. Tricky. Whatever situation you're faced with, there's a need to be careful about this and be sure that you use the same functions (and conventions) consistently, if only for your own sanity.
- Similarly, week numbers can sometimes be a cause for confusion. A large number of years ago, I remember visiting an office in early January when staff were checking out the latest swag from two separate billion-dollar-plus agriculture vendors, both from the same northern-European country that rather prides itself in its agricultural prowess. The swag in this case included very large wall calendars, showing the entire year at a glance. With equal parts horror and humor, it turned out that the calendars from the different vendors didn't have the same week numbers assigned. I think, collectively as a planet, we've since come to agree that Week #1 is considered the first week that has a Thursday in it. ISO8601 prevails. Though this is more of a "recent" realization that people have arrived at, and isn't necessarily implemented the same way everywhere. That's what we're going with here though. Conveniently, this isn't really in conflict really with my point (2) above. We'll just have to argue about which week Sundays fall into.
- TDateTime has millisecond precision, if you're interested in that level of detail. However, some systems (databases, for example) may have more precision. DB2 timestamps record microseconds (six digits instead of three), as do others. So this is something to be mindful of when moving data around, particularly if you're comparing dates and looking for exact matches. And especially when the database itself generates timestamps, which we'll cover in more detail shortly. Sometimes this also happens behind the scenes. This is why, if you're using conventional Delphi DB components to access a database, timestamps are often truncated after seconds. To ensure that the components and the database are always using the same precision.
- An example of ISO8601 being perhaps less than rigid is that dates can be expressed as either YYYY-MM-DD or YYYYMMDD - what they refer to as enhanced vs. basic. Nothing like a good standard to not take sides! So when there is a claim that something is ISO8601 compliant, you have to really not put too much faith in that alone as it may mean different things to different people. And yes, I'm looking at you, TXDataWebDataset.SetJSONData. Or FireDAC, equally to blame I suppose! As with anything else, these sorts of things have to be tested and you have to keep an eye out not only for variations in formats but also that when things are working, that they aren't causing other problems downstream. One part of a series of function calls may happily tolerate slight variations in a format whereas another will be stopped cold.
- Users have preferences. Customers have preferences. Developers have preferences. Rarely are these all in alignment. And sometimes the deciding factor isn't related to standards or coding or any of these preferences, but rather the dreaded "business rules" when someone decided, likely decades ago, that "This is the way" for that organization. It is for this reason that I've gotten into the habit of providing a general configuration option in my projects, where different periods (weeks, months, fiscal years, quarters, seasons, payroll periods, stat holidays, etc.) can be arbitrarily defined and then deployed consistently across an organization, at least within the confines of my projects. At least when they mess it up (which they also consistently do, nearly every year!), it is messed up consistently for everyone. Not sure if that has been a net benefit or not? The point is, though, that with a little (OK, a LOT of) luck your users or your customers will hopefully be internally consistent with this kind of thing, at least within their own organization.
- Then there is the concept of "internationalization". Let's call it that for now, anyway. Sometimes it's called "localization". Not confusing at all. The idea that wherever you are in the world, your region (typically your country) has some generally-agreed-upon ideas about what the date format should be, what the first day of the week should be, what the names of the days of the week and months of the year are (with variations for official languages in the region), the equivalent shorter names for days of the week and months of the year, how "medium" and "long" dates should be expressed, and so on. Often you'll see software settings in applications where you can select a different region if you happen to find your own region's preferred default settings aren't to your liking. I suspect Denmark is likely a more popular regional selection than it otherwise would be, for this very reason. One (very good!) school of thought is that as a developer you should use this system exclusively, giving good defaults to all users globally, with the option for them to use different settings if they choose. Noble even, perhaps. But perhaps misplaced.
- And finally, completely contrary to the above, is that users in a particular region may not wish to use the defaults for their region, or any region. Even if they happen to live in Denmark! And this system is certainly not without flaws of its own. For example, I regularly use a Fedora desktop system with Firefox, Thunderbird (BetterBird, actually), and Google Chrome. It seems they all (sort of) rely on the Fedora locale to do their thing. Which Fedora itself does a very poor job of in terms of offering any kind of flexibility. And thus everything downstream from there gets progressively worse. Even Chrome and Firefox may not agree on the particular options using the same system, which can be infuriating at times.
So, just to be clear, there are standards like ISO8601 that provide solid guidance on many things, but there are also routine exceptions to be aware of, and often rules that come into play that have no basis in reality. The good news is that the tools on-hand are easily capable of handling pretty much anything, so long as someone can nail down an unambiguous definition. And stick to it. Not as common as you might think!
procedure TForm1.WebFormCreate(Sender: TObject); begin asm var fp1 = flatpickr('#WebEdit1', { appendTo: WebHTMLDiv1, inline: true, weekNumbers: true, locale: { firstDayOfWeek:0, // Sunday } }); var fp2 = flatpickr('#WebEdit2', { appendTo: WebHTMLDiv2, inline: true, weekNumbers: true, locale: { firstDayOfWeek:1, // Monday } }); end; end;
I'll update the JSExtend package shortly to have this as an option in the IDE as well. But here's a better visual representation of why I chose the three dates I did for the example. We really do want this all to be bulletproof and unambiguous, but it can be a struggle at times. And we're not done with internationalization/localization, but we'll set it aside for the moment and deal with another problematic issue.
Timezones, UTC, Local Time, and Offsets.
This topic is a little simpler and at the same time a little more complex. The simpler part, believe it or not, is that everyone agrees (more or less, ha!) on what the timezones are, where the physical boundaries are, when the time changes take place, and so on. And even if the boundaries are sometimes vague, the user likely knows what timezone they are in and their system (browser, OS, etc.) likely has something in it that will help us to identify the timezone without any kind of ambiguity. Generally, you set your timezone (or, more likely, just confirm your timezone) when first setting up your computer or phone or browser or whatever you're using, and that's it - a one-time thing that you don't really have to think about anymore, thankfully. Phones usually don't even need to ask because they can get that from the carrier.
It has been a long time coming! But I think we're there now. Timezone data does change, however, so this is one of those things that comes up that requires periodic updates to software. The idea that you can create an application and walk away and never update it again is more than a little old-fashioned. Timezone changes may require updates to the underlying OS, or perhaps the libraries you're using in your project, the browser, or even your own code (let's hope not!), or even all of the above. For our purposes, we'll assume that we're using the latest version of everything and that we can blame the browser vendor if an issue ever arises. Just kidding. Mostly.
The more complex part is that Delphi assumes we know nothing about timezones. And JavaScript assumes we know everything about timezones. So there's a bit of an information gap to overcome when moving back and forth between the two. Well, TDateTime assumes we know nothing about timezones. But what are the bits that we really need to know about? Perhaps the most critical bit of information is our local offset from UTC. If we know that, we can do some things right away, like convert back and forth between local time and UTC. Curiously, the TMS WEB Core source uses this to determine the local time offset from UTC:
Function GetLocalTimeOffset : Integer; begin Result:=TJSDate.New.getTimezoneOffset(); end;
Which essentially means that it's getting the offset from JavaScript. This makes sense, as it is really only the browser that knows what is going on in a running TMS WEB Core application, so that's the only place to get it. The browser itself will get it from whatever environment it is running in, so that should work fine. The Delphi VCL is a bit more comprehensive, with support for things like TTimeZone which doesn't (yet) work in TMS WEB Core, but as we can't rely on Windows (could we ever?) we have to get this information from the new definitive source - the browser. We can actually get a little bit more information from the browser, but things start to get a bit dicey in terms of whether a browser supports these calls, whether different platforms support these calls, whether that phone from 2014 supports these calls, and so on. This may not always work. But worth a shot. Here's what we've got. Not a bad start.
procedure TForm1.WebButton1Click(Sender: TObject); var tz_name: String; tz_short: String; begin asm tz_name = Intl.DateTimeFormat().resolvedOptions().timeZone; tz_short = new Date().toLocaleString('en', {timeZoneName:'short'}).split(' ').pop(); end; console.log('Local Time: '+FormatDateTime('yyyy-mmm-dd (ddd) hh:nn:ss', Now)); console.log('Local Timezone Name: '+tz_name); console.log('Local Timezone Short: '+tz_short); console.log('UTC Offset: '+IntToStr(TJSDate.new.gettimezoneoffset())+' minutes'); console.log('UTC: '+FormatDateTime('yyyy-mmm-dd (ddd) hh:nn:ss', LocalTimeToUniversal(Now))); console.log('UTC to Local Time: '+FormatDateTime('yyyy-mmm-dd (ddd) hh:nn:ss',UniversalTimeToLocal(EncodeDateTime(2022,5,22,6,0,0,0)))); end; // console.log output: Local Time: 2022-May-21 (Sat) 22:40:14 Local Timezone Name: America/Vancouver Local Timezone Short: PDT UTC Offset: 420 minutes UTC: 2022-May-22 (Sun) 05:40:14 UTC to Local Time: 2022-May-21 (Sat) 23:00:00
Unfortunately, that's about all that we can determine easily. Converting between timezones isn't really an option so far, for example.
Server-Side Sidebar.
When working with the client end of a client/server-type application, often the client really doesn't care about timezones. It's not that they're not important, but rather that most of the time, people either work in their own timezones, so all the offsets are always the same, or if they work with data from different timezones, it is still the local time that is important - no conversion is necessary.
For example, I've spent a considerable amount of time working on time and attendance systems for commercial greenhouse operators (tomato and pepper growers mostly). With these systems, workers "punch" at a client kiosk computer throughout their workday, which then records the dates and times and activities in a local server database (the server records the time, it doesn't trust the client). Supervisors then use this data to monitor worker performance, being mindful that their times have to fall within assigned shift times, and meet various criteria based on the duration of activities that are being punched, the type of plants they are working on, and so forth.
Workers at another location (in another timezone) working for the same company might very well punch at another client kiosk computer connected to another local database.
The HR administrator (working in yet another timezone) might coalesce all this data to process the organization's weekly payroll. That person doesn't necessarily much care where the work was done and has no need to convert the local times in the remote locations to any particular timezone. The administrator just sees that workers punch in at, say, 06:00, and punch out at 14:00 so they get paid for that period of time (adjusted for paid and unpaid breaks, performance bonuses, and so on). There is no need at all to be concerned with timezones.
On the other hand, if you have one server that logs all activities to a local database from users in multiple timezones, the situation could be quite different. An XData server might, for example, log connections and service request tickets to a local database. That database might be configured to record timestamps using UTC or it might be configured to use the database server's local time (which could even be different from the XData server's timezone). When a user submits a service request ticket using an XData server endpoint, the database server might record the time in its own timezone. When a supervisor wants to check up on that ticket, however, the time returned to the supervisor needs to be converted to their timezone. From their perspective, the timezone is irrelevant, but on the server, the times recorded for all tickets will have timestamps recorded in the database server timezone that reflect the actual order the tickets were created, regardless of the timezone of the persons creating the ticket.
How can we do this without having to store the user's timezone in the database? One approach is to do the conversion only when the user or supervisor makes a subsequent request. When a service operation is invoked that needs this information, like a "ticket info" request or a user-level report of some kind, the client timezone can be sent to XData, either as an endpoint parameter, or even just included as an element in a JWT. Then, when the database SQL is executed, we pass in this information to do the conversion. The query result then has data in the correct timezone.
select timezone(TICKETTIMESTAMP, :SERVERTZ, :LOCALTZ) TICKETLOCALTIMESTAMP from TICKETS where USER='frank'
In MySQL (or MariaDB) the same approach can be used but with a different function call.
select convert_tz(TICKETTIMESTAMP, :SERVERTZ, :LOCALTZ) TICKETLOCALTIMESTAMP from TICKETS where USER='frank'
Note that in both databases, some initial prep work likely needs to be done just to ensure that the databases have current timezone information. And while I'm not certain that every database has this same level of timezone support, I'd imagine that if these two do, many others do as well.
What if you need to do something similar server-side, but outside of a database? Let's say, for example, that you want to include the current timestamp on a report generated on the server, but presented to the user in their local timezone. One way is to make use of the very impressive TZDB.pas which contains the entire (current!) IANA Time Zone Database. Simply download it and add it to your project, and then to your uses clause and you're off and running. The GitHub page has a ton of information on all the things it can do, and it is pretty extensive. Here's what you might do to timestamp a report.
function MyService.GetReport(Parameters:String):TStream; var ClientTimeZone: TBundledTimeZone; ServerTime:TDateTime; ClientTime:TDateTime; GlobalTime:TDateTime; TimeZone: String; begin ... // TimeZone value is retrieved from the JWT ... ClientTimeZone :=TBundledTimeZone.GetTimeZone(TimeZone); ServerTime := Now; GlobalTime := TTimeZone.Local.ToUniversalTime(ServerTime); ClientTime := ClientTimeZone.ToLocalTime(GlobalTime); Report.FooterLeft.Caption := FormatDateTime('yyyy-mmm-dd (ddd) hh:nn:ss', ClientTime); ... end;
This same approach could be used to pass locale information or other datetime formatting-related parameters. Which would also be good candidates to store in the JWT. Not likely someone is going to change languages frequently, or their preferred date format, during their session. Or if they did, it would be a reasonable justification to generate a new JWT anyway.
Luxon Enters the Chat.
Alright. Sidebar over. Back at the client now, we've dealt with quite a lot of things from the Delphi side, so let's dig into the JavaScript side a little further. What I'd like to start with is the JavaScript equivalent of the examples I provided for Delphi. Sure would make sense to start there, wouldn't it?
But alas, we get to the third entry where we want to format the date output and immediately get stuck. Turns out there's no real simple way in native JavaScript to do the same thing that the Delphi FormatDateTime function does. But wait just a second! I know what you're thinking. TMS WEB Core converted FormatDateTime to JavaScript and it worked just fine, right? Sure, that's exactly what I thought. So I went and checked out what they did. Most impressive! I really encourage you to go and have a look for yourself. ...\Core Source\System.SysUtils.pas. So... yeah. I'd like to take a different approach here.
For many years, by far the most popular JavaScript library for dealing with all of these kinds of things was moment.js. But recently (2021), the creators of that project announced that it had reached its end-of-life and that it was no longer going to be actively developed. They gave two primary reasons. One was that it had grown substantially, adding potentially more than 200KB to projects, and in a way that was not particularly amenable to modern JavaScript optimization techniques like "tree-shaking." The other was this idea about immutability - using the library meant using particular coding styles, or else you risked unintended side effects that weren't always obvious.
I'm not particularly clear on this last point or how to define it in terms of a Delphi equivalent. This seems more of a JavaScript language kind of problem, but they felt that it was problematic enough to just call it quits. One of the alternatives that they recommend, which is roughly on-par feature-wise with their project, is Luxon. Its main claims are that it is smaller than moment.js and that its objects are immutable. As the heir apparent to moment.js, it seems to have attracted quite a following. And part of the reason why I've chosen to feature it here is that it is also the library that supports this kind of functionality within Tabulator, which is soon to be featured in our blog series.
<script src="https://cdn.jsdelivr.net/npm/luxon@latest/build/global/luxon.min.js"></script>
There are no visual objects or IDE-related things to show off here. It is just a Helper JS Library that has a bunch of code that we can use. We could do all this without Luxon but it would be quite a bit of work. With Luxon on the job, we can then tackle converting our example from Delphi to JavaScript. The formatting tokens are naturally different, but you can find their list of tokens here. And while there is a lot to like in there, there are so many choices when it comes to what might be considered "localized" formats as to make them very nearly meaningless. And somehow, on my system, things like AM/PM and M/D/YYYY start to show up, despite my very best efforts to avoid them.
procedure TForm1.WebButton4Click(Sender: TObject); begin asm function testLuxon(aDate) { console.log('Date Value: '+aDate); console.log('Favourite DateTime Format: ' +aDate.toFormat('yyyy-LL-dd HH:mm:ss')); console.log('Second Favourite DateTime Format: ' +aDate.toFormat('yyyy-LLL-dd HH:mm:ss')); console.log('Third Favourite DateTime Format: ' +aDate.toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); console.log('Shortest DateTime Format: ' +aDate.toFormat('f')); console.log('Shorter DateTime Format: ' +aDate.toFormat('ff')); console.log('Shortish DateTime Format: ' +aDate.toFormat('fff')); console.log('Short DateTime Format: ' +aDate.toFormat('ffff')); console.log('Long DateTime Format: ' +aDate.toFormat('F')); console.log('Longish DateTime Format: ' +aDate.toFormat('FF')); console.log('Longer DateTime Format: ' +aDate.toFormat('FFF')); console.log('Longest DateTime Format: ' +aDate.toFormat('FFFF')); console.log('First Day of Month: '+aDate.startOf('month').toFormat('yyyy-LLL-dd')); console.log('Last Day of Month: '+aDate.endOf('month').toFormat('yyyy-LLL-dd')); console.log('First Day of Prior Month: '+aDate.plus({months: -1}).startOf('month').toFormat('yyyy-LLL-dd')); console.log('Last Day of Prior Month: '+aDate.plus({months: -1}).endOf('month').toFormat('yyyy-LLL-dd')); console.log('First Day of Next Month: '+aDate.plus({months: 1}).startOf('month').toFormat('yyyy-LLL-dd')); console.log('Last Day of Next Month: '+aDate.plus({months: 1}).endOf('month').toFormat('yyyy-LLL-dd')); console.log('Day of Week: ' +aDate.toFormat('cccc')); console.log('Day of Year: ' +aDate.toFormat('o')); // Start of Week = Monday console.log('ISO Day of Week Number: ' +aDate.weekday); console.log('ISO Week Number: ' +aDate.weekNumber); console.log('ISO First Day of Week: ' +aDate.startOf('week').toFormat('yyyy-LLL-dd (ccc)')); console.log('ISO Last Day of Week: ' +aDate.endOf('week').toFormat('yyyy-LLL-dd (ccc)')); console.log('ISO First Day of Prior Week: ' +aDate.plus({weeks: -1}).startOf('week').toFormat('yyyy-LLL-dd (ccc)')); console.log('ISO Last Day of Prior Week: ' +aDate.plus({weeks: -1}).endOf('week').toFormat('yyyy-LLL-dd (ccc)')); console.log('ISO First Day of Next Week: ' +aDate.plus({weeks: 1}).startOf('week').toFormat('yyyy-LLL-dd (ccc)')); console.log('ISO Last Day of Next Week: ' +aDate.plus({weeks: 1}).endOf('week').toFormat('yyyy-LLL-dd (ccc)')); // Start of Week = Sunday console.log('non-ISO Day of Week: ' +aDate.toFormat('cccc')); // Mon=1,Sun=7 >>> Sun=1, Sat=7 console.log('non-ISO Day of Week Number: ' +((aDate.weekday % 7) + 1)); // If Sunday, get weekNumber for Monday console.log('non-ISO Week Number: ' +aDate.plus({days: Math.trunc(aDate.weekday / 7)}).weekNumber); console.log('non-ISO Day of Year: ' +aDate.toFormat('o')); console.log('non-ISO First Day of Week: ' +aDate.plus({days: + 1 - ((aDate.weekday % 7) + 1)}).toFormat('yyyy-LLL-dd (ccc)')); console.log('non-ISO Last Day of Week: ' +aDate.plus({days: + 7 - ((aDate.weekday % 7) + 1)}).toFormat('yyyy-LLL-dd (ccc)')); console.log('non-ISO First Day of Prior Week: ' +aDate.plus({days: - 6 - ((aDate.weekday % 7) + 1)}).toFormat('yyyy-LLL-dd (ccc)')); console.log('non-ISO Last Day of Prior Week: ' +aDate.plus({days: - ((aDate.weekday % 7) + 1)}).toFormat('yyyy-LLL-dd (ccc)')); console.log('non-ISO First Day of Next Week: ' +aDate.plus({days: + 8 - ((aDate.weekday % 7) + 1)}).toFormat('yyyy-LLL-dd (ccc)')); console.log('non-ISO Last Day of Next Week: ' +aDate.plus({days: +14 - ((aDate.weekday % 7) + 1)}).toFormat('yyyy-LLL-dd (ccc)')); console.log('Calculating a day duration: '+Math.trunc(luxon.DateTime.now().diff(luxon.DateTime.local(1971,5,25,0,0,0,0), 'days').days)); console.log('Calculating a time duration: '+luxon.DateTime.now().diff(luxon.DateTime.now().plus({days: -1}))+'ms'); } testLuxon(luxon.DateTime.local(2021, 12, 26, 12, 13, 14, 0)); testLuxon(luxon.DateTime.local(2022, 1, 2, 12, 13, 14, 0)); testLuxon(luxon.DateTime.local(2022, 1, 9, 12, 13, 14, 0)); end; end;
The end result is that we get the same table as before, with the exception of the various date formats near the beginning. Note that if you run this in your own browser, those formats might look very different than this, but hopefully the rest of the table remains the same. That is kind of the point, after all.
2021-Dec-26 (Sunday) |
2022-Jan-02 (Sunday) |
2022-Jan-09 (Sunday) |
|
Date Value |
1640549594000 |
1641154394000 |
1641759194000 |
Favorite DateTime Format |
2021-12-26 12:13:14 |
2022-01-02 12:13:14 |
2022-01-09 12:13:14 |
Second Favorite DateTime Format |
2021-Dec-26 12:13:14 |
2022-Jan-02 12:13:14 |
2022-Jan-09 12:13:14 |
Third Favorite DateTime Format |
2021-Dec-26 (Sun) 12:13:14 |
2022-Jan-02 (Sun) 12:13:14 |
2022-Jan-09 (Sun) 12:13:14 |
Shortest DateTime Format |
12/26/2021, 12:13 PM |
1/2/2022, 12:13 PM |
1/9/2022, 12:13 PM |
Shorter DateTime Format |
Dec 26, 2021, 12:13 PM |
Jan 2, 2022, 12:13 PM |
2022-01-09 12:13:14 |
Shortish DateTime Format |
December 26, 2021, 12:13 PM PST |
January 2, 2022, 12:13 PM PST |
January 9, 2022, 12:13 PM PST |
Short DateTime Format |
Sunday, December 26, 2021, 12:13 PM Pacific Standard Time |
Sunday, January 2, 2022, 12:13 PM Pacific Standard Time |
Sunday, January 9, 2022, 12:13 PM Pacific Standard Time |
Long DateTime Format |
12/26/2021, 12:13:14 PM |
1/2/2022, 12:13:14 PM |
1/9/2022, 12:13:14 PM |
Longish DateTime Format |
Dec 26, 2021, 12:13:14 PM |
Jan 2, 2022, 12:13:14 PM |
Jan 9, 2022, 12:13:14 PM |
Longer DateTime Format |
December 26, 2021, 12:13:14 PM PST |
January 2, 2022, 12:13:14 PM PST |
January 9, 2022, 12:13:14 PM PST |
Longest DateTime Format |
Sunday, December 26, 2021, 12:13:14 PM Pacific Standard Time |
Sunday, January 2, 2022, 12:13:14 PM Pacific Standard Time |
Sunday, January 9, 2022, 12:13:14 PM Pacific Standard Time |
First Day of Month |
2021-Dec-01 |
2022-Jan-01 |
2022-Jan-01 |
Last Day of Month |
2021-Dec-31 |
2022-Jan-31 |
2022-Jan-31 |
First Day of Prior Month |
2021-Nov-01 |
2021-Dec-01 |
2021-Dec-01 |
Last Day of Prior Month |
2021-Nov-30 |
2021-Dec-31 |
2021-Dec-31 |
First Day of Next Month |
2022-Jan-01 |
2022-Feb-01 |
2022-Feb-01 |
Last Day of Next Month |
2022-Jan-31 |
2022-Feb-28 |
2022-Feb-28 |
Day of Year |
360 |
2 |
9 |
Day of Week |
Sunday |
Sunday |
Sunday |
ISO Day of Week Number |
7 |
7 |
7 |
ISO Week Number |
51 |
52 |
1 |
ISO First Day of Week |
2021-Dec-20 (Mon) |
2021-Dec-27 (Mon) |
2022-Jan-03 (Mon) |
ISO Last Day of Week |
2021-Dec-26 (Sun) |
2022-Jan-02 (Sun) |
2022-Jan-09 (Sun) |
ISO First Day of Prior Week |
2021-Dec-13 (Mon) |
2021-Dec-20 (Mon) |
2021-Dec-27 (Mon) |
ISO Last Day of Prior Week |
2021-Dec-19 (Sun) |
2021-Dec-26 (Sun) |
2022-Jan-02 (Sun) |
ISO First Day of Next Week |
2021-Dec-27 (Mon) |
2022-Jan-03 (Mon) |
2022-Jan-10 (Mon) |
ISO Last Day of Next Week |
2022-Jan-02 (Sun) |
2022-Jan-09 (Sun) |
2022-Jan-16 (Sun) |
non-ISO Day of Week Number |
1 |
1 |
1 |
non-ISO Week Number |
52 |
1 |
2 |
non-ISO First Day of This Week |
2021-Dec-26 (Sun) |
2022-Jan-02 (Sun) |
2022-Jan-09 (Sun) |
non-ISO Last day of This Week |
2022-Jan-01 (Sat) |
2022-Jan-08 (Sat) |
2022-Jan-15 (Sat) |
non-ISO First Day of Prior Week |
2021-Dec-19 (Sun) |
2021-Dec-26 (Sun) |
2022-Jan-02 (Sun) |
non-ISO Last Day of Prior Week |
2022-Jan-01 (Sat) |
2022-Jan-01 (Sat) |
2022-Jan-08 (Sat) |
non-ISO First Day of Next Week |
2022-Jan-02 (Sun) |
2022-Jan-09 (Sun) |
2022-Jan-16 (Sun) |
non-ISO Last Day of Next Week |
2022-Jan-08 (Sat) |
2022-Jan-15 (Sat) |
2022-Jan-22 (Sat) |
Calculating a day duration |
18626 |
18626 |
18626 |
non-ISO Last day of next Week |
86400000ms |
86400000ms |
86400000ms |
Alright. So now we can do in JavaScript what we could already do in Delphi. That's important, certainly, so that we are able to check our work and make sure that things remain consistent. However, we're not here just to convert Delphi to JavaScript. Nor to remind myself that I'm old! Rather, the point of all this is that we want to be able to leverage what Luxon can provide to further enhance and integrate with our Delphi and TMS WEB Core projects.
Great. So What Else Can Luxon Do?
asm var vancouver = luxon.DateTime.local(2022, 5, 22, 12, 13, 14, 0); console.log('America/Vancouver: '+vancouver.toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')+' ( timezone = '+vancouver.zoneName+' )'); console.log('America/New_York: '+vancouver.setZone('America/New_York').toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); console.log('Europe/London: '+vancouver.setZone('Europe/London').toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); console.log('Europe/Paris: '+vancouver.setZone('Europe/Paris').toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); console.log('Europe/Kiev: '+vancouver.setZone('Europe/Kiev').toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); console.log('Asia/Singapore: '+vancouver.setZone('Asia/Singapore').toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); end; // console.log output: America/Vancouver: 2022-May-22 (Sun) 12:13:14 ( timezone = America/Vancouver ) America/New_York: 2022-May-22 (Sun) 15:13:14 Europe/London: 2022-May-22 (Sun) 20:13:14 Europe/Paris: 2022-May-22 (Sun) 21:13:14 Europe/Kiev: 2022-May-22 (Sun) 22:13:14 Asia/Singapore: 2022-May-23 (Mon) 03:13:14
Different variations of how the timezone is expressed can also be used if the continent/city variation isn't to your liking.
asm console.log('America/Vancouver: '+vancouver.toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')+' ( timezone = '+vancouver.zoneName+' )'); console.log('UTC: '+vancouver.setZone('utc').toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); console.log('UTC+1: '+vancouver.setZone('utc+1').toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); console.log('CET (Central Europe): '+vancouver.setZone('cet').toFormat('yyyy-LLL-dd (ccc) HH:mm:ss')); end; // console.log output: America/Vancouver: 2022-May-22 (Sun) 12:13:14 ( timezone = America/Vancouver ) UTC: 2022-May-22 (Sun) 19:13:14 UTC+1: 2022-May-22 (Sun) 20:13:14 CET (Central Europe): 2022-May-22 (Sun) 21:13:14
We've already seen plenty of examples of adjusting a date plus/minus some interval, like 'days', 'weeks', 'months', and so on. When calculating the difference between two dates, there is the option of also returning a value that has these kinds of intervals, making it possible to have more friendly durations shown.
asm var start = luxon.DateTime.local(2022, 5, 21, 0, 13, 14); var finish = luxon.DateTime.local(2022, 5, 22, 12, 13, 14); // 36 hours later console.log('start was '+Math.trunc(finish.diff(start, 'minutes').minutes)+' minute(s) ago'); console.log('start was '+Math.trunc(finish.diff(start, 'hours').hours)+' hour(s) ago'); console.log('start was '+Math.trunc(finish.diff(start, 'days').days)+' day(s) ago'); console.log('start was '+JSON.stringify(finish.diff(start, ['days','hours','minutes']).toObject())); console.log('start was '+finish.diff(start, ['days','hours']).toHuman()+' ago'); end; start was 2160 minute(s) ago start was 36 hour(s) ago start was 1 day(s) ago start was {"days":1,"hours":12,"minutes":0} start was 1 day, 12 hours ago
Not hard to imagine all kinds of ways this could be used to present more human-readable dates.
Getting Around.
These kinds of functions can also be nicely encapsulated. I have some ideas around creating common functions that could go into our JSExtend library, via something like Luxon.pas. Here are some contenders. What functions do you think might be useful?
function TForm1.ConvertTimezone(aDateTime: TDateTime; TZ: String): TDateTime; var ayear, amonth, aday, ahour, aminute, asecond, amillisecond: word; begin DecodeDateTime(aDateTime, ayear, amonth, aday, ahour, aminute, asecond, amillisecond); asm var lDateTime = new luxon.DateTime.local(ayear, amonth, aday, ahour, aminute, asecond, amillisecond).setZone(TZ); ayear = lDateTime.year; amonth = lDateTime.month; aday = lDateTime.day; ahour = lDateTime.hour; aminute = lDateTime.minute; asecond = lDateTime.second; amillisecond = lDateTime.millisecond; end; Result := EncodeDateTime(ayear, amonth, aday, ahour, aminute, asecond, amillisecond); end;
procedure TForm1.WebButton1Click(Sender: TObject); var olddate, newdate: TDateTime; begin olddate := EncodeDateTime(2022, 5, 22, 12, 13, 14, 0); newdate := ConvertTimeZone(olddate, 'Europe/Paris'); console.log(FormatDateTime('yyyy-mmm-dd hh:nn:ss',olddate)); console.log(FormatDateTime('yyyy-mmm-dd hh:nn:ss',newdate)); end; // console.log output: 2022-May-22 12:13:14 2022-May-22 21:13:14 function TForm1.HumanDifference(nowDateTime: TDateTime; thenDateTime: TDateTime): String; var ayear, amonth, aday, ahour, aminute, asecond, amillisecond: word; byear, bmonth, bday, bhour, bminute, bsecond, bmillisecond: word; begin DecodeDateTime(nowDateTime, ayear, amonth, aday, ahour, aminute, asecond, amillisecond); DecodeDateTime(thenDateTime, byear, bmonth, bday, bhour, bminute, bsecond, bmillisecond); asm var aDateTime = new luxon.DateTime.local(ayear, amonth, aday, ahour, aminute, asecond, amillisecond); var bDateTime = new luxon.DateTime.local(byear, bmonth, bday, bhour, bminute, bsecond, bmillisecond); Result = aDateTime.diff(bDateTime, ['days','hours']).toHuman()+' ago'; end; end;
procedure TForm1.WebButton1Click(Sender: TObject); var olddate, newdate: TDateTime; begin olddate := EncodeDateTime(2022, 5, 21, 06, 13, 14, 0); newdate := EncodeDateTime(2022, 5, 22, 12, 13, 14, 0); // 30 hours later console.log(HumanDifference(newdate, olddate)); end; // console.log output: 1 day, 6 hours ago
Out of Time!
Follow Andrew on 𝕏 at @WebCoreAndMore or join our 𝕏 Web Core and More Community.
Andrew Simard
This blog post has not received any comments yet.
All Blog Posts | Next Post | Previous Post