diff --git a/lib/WeBWorK/Utils/DateTime.pm b/lib/WeBWorK/Utils/DateTime.pm index daf02f3c87..b691bfb3c4 100644 --- a/lib/WeBWorK/Utils/DateTime.pm +++ b/lib/WeBWorK/Utils/DateTime.pm @@ -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 @@ -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) { @@ -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'; } @@ -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. +Time zone abbreviations come from DateTime::TimeZone, except where +L has an official one: America/Vancouver shows +C from 2026-11-01, where tzdata says C. + 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. @@ -288,6 +298,9 @@ Parses C<$string> into an epoch. The format of C<$string> must be C. 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 may also be an official abbreviation from +L (C), which C 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 diff --git a/lib/WeBWorK/Utils/TimeZoneLabels.pm b/lib/WeBWorK/Utils/TimeZoneLabels.pm new file mode 100644 index 0000000000..69bf520336 --- /dev/null +++ b/lib/WeBWorK/Utils/TimeZoneLabels.pm @@ -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; diff --git a/t/time_zone_labels.t b/t/time_zone_labels.t new file mode 100644 index 0000000000..bed6a2836d --- /dev/null +++ b/t/time_zone_labels.t @@ -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();