Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions lib/WeBWorK/Utils/DateTime.pm
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use DateTime;
use DateTime::TimeZone;
use Time::Zone qw(tz_offset);

use WeBWorK::Utils::TimeZoneLabels qw(officialZoneLabel zoneNameForLabel);

our @EXPORT_OK = qw(
before
between
Expand Down Expand Up @@ -33,11 +35,13 @@ sub formatDateTime ($date_time = 0, $format_string = 'datetime_format_short', $t

# If $format_string is a method of $dt->locale then call format_cldr on its return value.
# Otherwise assume it is a locale string meant for strftime.
if ($dt->locale->can($format_string)) {
return $dt->format_cldr($dt->locale->$format_string);
} else {
return $dt->strftime($format_string);
}
my $formatted =
$dt->locale->can($format_string)
? $dt->format_cldr($dt->locale->$format_string)
: $dt->strftime($format_string);

# Show the official abbreviation where tzdata uses another (BC: PCT, not MST).
return officialZoneLabel($formatted, $dt->time_zone->name, $dt->time_zone_short_name);
}

sub getDefaultSetDueDate ($ce) {
Expand Down Expand Up @@ -93,6 +97,8 @@ sub parseDateTime ($string, $display_tz = 'local') {

my $is_valid_zone_name = 1; # If later set to 0, then try the "offset" approach.
if (defined $zone && $zone ne '') {
# An official abbreviation that is not a tz name (PCT, written by formatDateTime).
$zone = zoneNameForLabel($zone) // $zone;
$is_valid_zone_name = DateTime::TimeZone->is_valid_name($zone);
$tz_to_use = $is_valid_zone_name ? $zone : 'UTC';
}
Expand Down Expand Up @@ -269,6 +275,10 @@ given, then the formatted string that is returned is in the specified timezone.
If C<$locale> is provided, the string returned will be in the format of that
locale. If C<$locale> is not provided, Perl defaults to using C<en-US>.

Time zone abbreviations come from DateTime::TimeZone, except where
L<WeBWorK::Utils::TimeZoneLabels> has an official one: America/Vancouver shows
C<PCT> from 2026-11-01, where tzdata says C<MST>.

If this method is used directly, then the C<$timezone> and C<$locale> should
generally be set from the course environment, and the defaults set in this
method not used.
Expand All @@ -288,6 +298,9 @@ Parses C<$string> into an epoch. The format of C<$string> must be
C<MM/DD/YYYY at HH:MM AMPM ZONE>. There is some forgiveness for spaces, and a
comma is allowed in place of "at". If C<$display_tz> is given, C<$string> is
assumed to be in that timezone. Otherwise, the server's local timezone is used.
C<ZONE> may also be an official abbreviation from
L<WeBWorK::Utils::TimeZoneLabels> (C<PCT>), which C<formatDateTime> writes into
exported set definition files.

Note that this method is only used for parsing dates when set definition files
are imported, and should NEVER be used for anything else ever again. If it is
Expand Down
51 changes: 51 additions & 0 deletions lib/WeBWorK/Utils/TimeZoneLabels.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package WeBWorK::Utils::TimeZoneLabels;

# Time zone abbreviations that a jurisdiction officially uses but tzdata does
# not, applied to the labels WeBWorK prints (formatDateTime) and accepted back
# when set definition files are imported (parseDateTime).
#
# BC has stayed on UTC-7 since 2026-03-08: no fall back on 2026-11-01. tzdata
# (DateTime::TimeZone 2.68+) models that as America/Vancouver switching from
# PDT to "MST" on 2026-11-01, but BC's official name is Pacific Time, "PCT"
# (BC Citizens' Services news release 2026CITZ0009-001073, 2026-09-17). Only
# the abbreviation tzdata gives for the instant is replaced, so summer 2026
# still shows PDT, earlier winters PST, and other zones labelled MST (Edmonton,
# Phoenix, Dawson Creek) keep it. If tzdata adopts PCT itself, the label entry
# simply stops matching.
#
# Deliberately dependency free so it can be unit tested without DateTime.

use strict;
use warnings;

use Exporter 'import';
our @EXPORT_OK = qw(officialZoneLabel zoneNameForLabel);

# zone name => { tzdata abbreviation => official abbreviation }
my %OFFICIAL_LABELS = ('America/Vancouver' => { MST => 'PCT' });

# official abbreviation => zone name, for parsing
my %ZONE_FOR_LABEL =
map {
my $zone = $_;
map { $_ => $zone } values %{ $OFFICIAL_LABELS{$zone} }
} keys %OFFICIAL_LABELS;

# Replace the tzdata abbreviation in $text, which was formatted in $zone_name at
# an instant whose tzdata abbreviation is $abbreviation.
sub officialZoneLabel {
my ($text, $zone_name, $abbreviation) = @_;
return $text unless defined $zone_name && defined $abbreviation;
my $official = $OFFICIAL_LABELS{$zone_name}{$abbreviation};
return $text unless defined $official;
return $text =~ s/\b\Q$abbreviation\E\b/$official/gr;
}

# The zone an official abbreviation stands for, or undef if it is not one.
sub zoneNameForLabel {
my ($label) = @_;
return undef unless defined $label && $label ne '';
return $ZONE_FOR_LABEL{ uc $label };
}

1;
72 changes: 72 additions & 0 deletions t/time_zone_labels.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env perl

# WeBWorK::Utils::TimeZoneLabels swaps a tzdata abbreviation for the one a
# jurisdiction officially uses. BC has stayed on UTC-7 since 2026-03-08; tzdata
# (DateTime::TimeZone 2.68+) labels America/Vancouver "MST" from 2026-11-01,
# but BC's official name is Pacific Time, "PCT".

use strict;
use warnings;

use Test::More;
use FindBin;
use lib "$FindBin::Bin/../lib";

use WeBWorK::Utils::TimeZoneLabels qw(officialZoneLabel zoneNameForLabel);

# formatDateTime output for 2026-12-01 23:59 in America/Vancouver.
is(
officialZoneLabel('December 1, 2026, 11:59:00 PM MST', 'America/Vancouver', 'MST'),
'December 1, 2026, 11:59:00 PM PCT',
'student date format: MST becomes PCT in America/Vancouver'
);
is(
officialZoneLabel('12/01/2026 at 11:59pm MST', 'America/Vancouver', 'MST'),
'12/01/2026 at 11:59pm PCT',
'set definition export format: MST becomes PCT'
);

# Only the abbreviation tzdata gave for this instant is touched.
is(
officialZoneLabel('July 14, 2026, 11:59:00 PM PDT', 'America/Vancouver', 'PDT'),
'July 14, 2026, 11:59:00 PM PDT',
'summer 2026 keeps PDT'
);
is(
officialZoneLabel('December 31, 2025, 11:59:00 PM PST', 'America/Vancouver', 'PST'),
'December 31, 2025, 11:59:00 PM PST',
'winter 2025 keeps PST'
);

# Other zones that tzdata also labels MST are really on Mountain Standard Time.
is(
officialZoneLabel('12/01/2026 at 11:59pm MST', 'America/Edmonton', 'MST'),
'12/01/2026 at 11:59pm MST',
'America/Edmonton keeps MST'
);
is(
officialZoneLabel('12/01/2026 at 11:59pm MST', 'America/Phoenix', 'MST'),
'12/01/2026 at 11:59pm MST',
'America/Phoenix keeps MST'
);
is(
officialZoneLabel('12/01/2026 at 11:59pm MST', 'America/Dawson_Creek', 'MST'),
'12/01/2026 at 11:59pm MST',
'America/Dawson_Creek (BC, but on MST for decades) keeps MST'
);

# Whole-word replacement only, and missing inputs pass the text through.
is(officialZoneLabel('MSTX 11:59pm MST', 'America/Vancouver', 'MST'), 'MSTX 11:59pm PCT',
'only the whole word changes');
is(officialZoneLabel('December 1, 2026', 'America/Vancouver', 'MST'), 'December 1, 2026', 'no label in the text');
is(officialZoneLabel('x MST', undef, undef), 'x MST', 'undefined zone and abbreviation');
is(officialZoneLabel('x MST', 'America/Vancouver', undef), 'x MST', 'undefined abbreviation');

# Parsing set definition files: PCT must lead back to the zone.
is(zoneNameForLabel('PCT'), 'America/Vancouver', 'PCT parses as America/Vancouver');
is(zoneNameForLabel('pct'), 'America/Vancouver', 'case-insensitive');
is(zoneNameForLabel('MST'), undef, 'MST is left to DateTime::TimeZone (a valid zone name)');
is(zoneNameForLabel(''), undef, 'empty');
is(zoneNameForLabel(undef), undef, 'undefined');

done_testing();
Loading