Skip to content

Commit

Permalink
Add support for processing custom GMT offsets as timezones
Browse files Browse the repository at this point in the history
  • Loading branch information
DJDavid98 committed Sep 28, 2024
1 parent 1683a95 commit 2f963da
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 18 deletions.
44 changes: 44 additions & 0 deletions src/classes/utc-offset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { UtcOffset } from './utc-offset';

describe('UtcOffset', () => {
describe('totalMinutes', () => {
it('should work for simple hours input by default', () => {
expect(new UtcOffset(1).totalMinutes).toEqual(60);
expect(new UtcOffset(-1).totalMinutes).toEqual(-60);
expect(new UtcOffset(10).totalMinutes).toEqual(600);
expect(new UtcOffset(-10).totalMinutes).toEqual(-600);
expect(new UtcOffset(69).totalMinutes).toEqual(4140);
expect(new UtcOffset(-420).totalMinutes).toEqual(-25200);
});
it('should work for minute input', () => {
expect(new UtcOffset(1, 0).totalMinutes).toEqual(60);
expect(new UtcOffset(-1, 0).totalMinutes).toEqual(-60);
expect(new UtcOffset(5, 5).totalMinutes).toEqual(305);
expect(new UtcOffset(-5, 5).totalMinutes).toEqual(-305);
expect(new UtcOffset(10, 59).totalMinutes).toEqual(659);
expect(new UtcOffset(-10, 59).totalMinutes).toEqual(-659);
expect(new UtcOffset(69, 30).totalMinutes).toEqual(4170);
expect(new UtcOffset(-420, 42).totalMinutes).toEqual(-25242);
});
});
describe('toString', () => {
it('should work for simple hours input by default', () => {
expect(new UtcOffset(1).toString()).toEqual('+01:00');
expect(new UtcOffset(-1).toString()).toEqual('-01:00');
expect(new UtcOffset(10).toString()).toEqual('+10:00');
expect(new UtcOffset(-10).toString()).toEqual('-10:00');
expect(new UtcOffset(69).toString()).toEqual('+69:00');
expect(new UtcOffset(-420).toString()).toEqual('-420:00');
});
it('should work for minute input', () => {
expect(new UtcOffset(1, 0).toString()).toEqual('+01:00');
expect(new UtcOffset(-1, 0).toString()).toEqual('-01:00');
expect(new UtcOffset(5, 5).toString()).toEqual('+05:05');
expect(new UtcOffset(-5, 5).toString()).toEqual('-05:05');
expect(new UtcOffset(10, 59).toString()).toEqual('+10:59');
expect(new UtcOffset(-10, 59).toString()).toEqual('-10:59');
expect(new UtcOffset(69, 30).toString()).toEqual('+69:30');
expect(new UtcOffset(-420, 42).toString()).toEqual('-420:42');
});
});
});
18 changes: 18 additions & 0 deletions src/classes/utc-offset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { pad } from '../utils/numbers.js';

export class UtcOffset {
get totalMinutes(): number {
return (60 * this.hours) + (this.hours < 0 ? -1 : 1) * this.minutes;
}

constructor(public readonly hours: number = 0, public readonly minutes: number = 0) {
}

toString(padHours = true): string {
let outputHours: number | string = Math.abs(this.hours);
if (padHours) {
outputHours = pad(outputHours, 2);
}
return `${this.hours < 0 ? '-' : '+'}${outputHours}:${pad(this.minutes, 2)}`;
}
}
4 changes: 2 additions & 2 deletions src/commands/at.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export const atCommand: BotChatInputCommand = {
let localMoment: Moment;
try {
if (gmtZoneRegex.test(timezone)) {
const utcOffset = Math.round(constrain(getGmtTimezoneValue(timezone, 0), -16, 16));
localMoment = moment.tz('UTC').utcOffset(utcOffset);
const utcOffset = getGmtTimezoneValue(timezone, 0);
localMoment = moment.tz('UTC').utcOffset(utcOffset.toString());
} else {
localMoment = moment.tz(timezone);
}
Expand Down
36 changes: 36 additions & 0 deletions src/utils/numbers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { pad, PadDirection } from './numbers.js';

describe('pad', () => {
it('should pad from the left by default', () => {
expect(pad(0, 2)).toEqual('00');
expect(pad(5, 2)).toEqual('05');
expect(pad(9, 2)).toEqual('09');
expect(pad(100, 2)).toEqual('100');
expect(pad(100, 4)).toEqual('0100');
expect(pad(100, 5)).toEqual('00100');
});
it('should be able to pad from the right', () => {
expect(pad(0, 2, PadDirection.RIGHT)).toEqual('00');
expect(pad(5, 2, PadDirection.RIGHT)).toEqual('50');
expect(pad(9, 2, PadDirection.RIGHT)).toEqual('90');
expect(pad(100, 2, PadDirection.RIGHT)).toEqual('100');
expect(pad(100, 4, PadDirection.RIGHT)).toEqual('1000');
expect(pad(100, 5, PadDirection.RIGHT)).toEqual('10000');
});
it('should handle negative numbers', () => {
expect(pad(-0, 2)).toEqual('00');
expect(pad(-1, 2)).toEqual('-01');
expect(pad(-5, 2)).toEqual('-05');
expect(pad(-9, 2)).toEqual('-09');
expect(pad(-100, 2)).toEqual('-100');
expect(pad(-100, 4)).toEqual('-0100');
expect(pad(-100, 5)).toEqual('-00100');
expect(pad(0, 2, PadDirection.RIGHT)).toEqual('00');
expect(pad(-1, 2, PadDirection.RIGHT)).toEqual('-10');
expect(pad(-5, 2, PadDirection.RIGHT)).toEqual('-50');
expect(pad(-9, 2, PadDirection.RIGHT)).toEqual('-90');
expect(pad(-100, 2, PadDirection.RIGHT)).toEqual('-100');
expect(pad(-100, 4, PadDirection.RIGHT)).toEqual('-1000');
expect(pad(-100, 5, PadDirection.RIGHT)).toEqual('-10000');
});
});
13 changes: 13 additions & 0 deletions src/utils/numbers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export enum PadDirection {
LEFT,
RIGHT,
}

export const pad = (n: number | string, length: number, direction: PadDirection = PadDirection.LEFT): string => {
const sign = (typeof n === 'number' ? n < 0 : n.startsWith('-')) ? '-' : '';
const nString = `${n}`.replace(/^-/, '');
if (nString.length >= length) return sign + nString;

const padStr = new Array(length - (nString.length - 1)).join('0');
return sign + (direction === PadDirection.LEFT ? padStr + nString : nString + padStr);
};
32 changes: 31 additions & 1 deletion src/utils/time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import {
constrain,
findTimezone,
formattedResponse,
getGmtTimezoneValue,
gmtTimezoneOptions,
pad,
supportedFormats,
} from './time.js';
import { MessageTimestamp, MessageTimestampFormat } from '../classes/message-timestamp.js';
import { TimezoneError } from '../classes/timezone-error.js';
import { ResponseColumnChoices } from '../types/localization.js';
import { pad } from './numbers';

describe('time utils', () => {
const nowInSeconds = 1650802953;
Expand Down Expand Up @@ -49,6 +50,7 @@ describe('time utils', () => {
expect(findTimezone('gmt+2')).toEqual(['GMT+2']);
expect(findTimezone('gmt-1')).toEqual(['GMT-1', 'GMT-10', 'GMT-11', 'GMT-12', 'GMT-13', 'GMT-14', 'GMT-15', 'GMT-16']);
expect(findTimezone('gmt-12')).toEqual(['GMT-12']);
expect(findTimezone('gmt+5:30')).toEqual(['GMT+5:30']);
expect(findTimezone('budapest')).toEqual(['Europe/Budapest']);
expect(findTimezone('london')).toEqual(['Europe/London']);
expect(findTimezone('los angeles')).toEqual(['America/Los_Angeles']);
Expand Down Expand Up @@ -129,3 +131,31 @@ describe('time utils', () => {
});
});
});

describe('getGmtTimezoneValue', () => {
it('should work for simple hours input', () => {
expect(getGmtTimezoneValue('GMT+1')).toEqual({ hours: 1, minutes: 0 });
expect(getGmtTimezoneValue('GMT-1')).toEqual({ hours: -1, minutes: 0 });
expect(getGmtTimezoneValue('GMT+10')).toEqual({ hours: 10, minutes: 0 });
expect(getGmtTimezoneValue('GMT-10')).toEqual({ hours: -10, minutes: 0 });
expect(getGmtTimezoneValue('GMT+69')).toEqual({ hours: 69, minutes: 0 });
expect(getGmtTimezoneValue('GMT-420')).toEqual({ hours: -420, minutes: 0 });
});
it('should work for minute input', () => {
expect(getGmtTimezoneValue('GMT+1:00')).toEqual({ hours: 1, minutes: 0 });
expect(getGmtTimezoneValue('GMT-1:00')).toEqual({ hours: -1, minutes: 0 });
expect(getGmtTimezoneValue('GMT+5:05')).toEqual({ hours: 5, minutes: 5 });
expect(getGmtTimezoneValue('GMT-5:05')).toEqual({ hours: -5, minutes: 5 });
expect(getGmtTimezoneValue('GMT+10:59')).toEqual({ hours: 10, minutes: 59 });
expect(getGmtTimezoneValue('GMT-10:59')).toEqual({ hours: -10, minutes: 59 });
expect(getGmtTimezoneValue('GMT+69:30')).toEqual({ hours: 69, minutes: 30 });
expect(getGmtTimezoneValue('GMT-420:42')).toEqual({ hours: -420, minutes: 42 });
});
it('should work for partial input', () => {
expect(getGmtTimezoneValue('GMT+1:')).toEqual({ hours: 1, minutes: 0 });
expect(getGmtTimezoneValue('GMT+3:3')).toEqual({ hours: 3, minutes: 30 });
expect(getGmtTimezoneValue('GMT+5:5')).toEqual({ hours: 5, minutes: 50 });
expect(getGmtTimezoneValue('GMT+10:')).toEqual({ hours: 10, minutes: 0 });
expect(getGmtTimezoneValue('GMT+69:0')).toEqual({ hours: 69, minutes: 0 });
});
});
39 changes: 24 additions & 15 deletions src/utils/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { unitOfTime } from 'moment';
import { TimezoneError } from '../classes/timezone-error.js';
import { MessageTimestamp, MessageTimestampFormat } from '../classes/message-timestamp.js';
import { ResponseColumnChoices } from '../types/localization.js';
import { UtcOffset } from '../classes/utc-offset.js';
import { pad, PadDirection } from './numbers.js';

export const gmtTimezoneOptions = [
'GMT',
Expand Down Expand Up @@ -41,26 +43,28 @@ export const gmtTimezoneOptions = [
'GMT+16',
];

export const pad = (n: number, length: number): string => {
const nString = `${n}`;
if (nString.length >= length) return nString;
export const gmtZoneRegex = /^(?:GMT|UTC)([+-]\d+)?(?::([0-5]\d?)?)?$/i;

return new Array(length).join('0') + nString;
};

export const gmtZoneRegex = /^GMT([+-]\d+)?$/i;
export const getGmtTimezoneValue = (gmtTimezone: string, fallbackHours = NaN): UtcOffset => {
const match = gmtTimezone.match(gmtZoneRegex);
if (!match) {
return new UtcOffset(fallbackHours);
}

export const getGmtTimezoneValue = (gmtTimezone: string, fallback = NaN): number => {
const offsetString = gmtTimezone.replace(gmtZoneRegex, '$1');
return offsetString.length === 0 ? fallback : parseInt(offsetString, 10);
const hours = parseInt(match[1], 10);
let minutes = 0;
if (match[2]) {
minutes = parseInt(pad(match[2], 2, PadDirection.RIGHT), 10);
}
return new UtcOffset(hours, minutes);
};

const compareGmtStrings = (a: string, b: string) => {
const aValue = getGmtTimezoneValue(a);
const bValue = getGmtTimezoneValue(b);
if (isNaN(aValue)) return -1;
if (isNaN(bValue)) return 1;
return Math.abs(aValue) - Math.abs(bValue);
if (isNaN(aValue.hours)) return -1;
if (isNaN(bValue.hours)) return 1;
return Math.abs(aValue.totalMinutes) - Math.abs(bValue.totalMinutes);
};

export const getSortedNormalizedTimezoneNames = (): string[] => [
Expand Down Expand Up @@ -91,9 +95,14 @@ export const timezoneIndex: Record<string, string> = timezoneNames.reduce((recor
}, {});

export const findTimezone = (value: string): string[] => {
if (gmtZoneRegex.test(value)) {
const utcOffset = getGmtTimezoneValue(value);
if (!isNaN(utcOffset.hours)) {
const inputUppercase = value.toUpperCase();
return gmtTimezoneOptions.filter(option => option.startsWith(inputUppercase)).sort(compareGmtStrings);
const results = gmtTimezoneOptions.filter(option => option.startsWith(inputUppercase)).sort(compareGmtStrings);
if (results.length === 0) {
return [`GMT${utcOffset.toString(false)}`];
}
return results;
}

const lowerValue = value.toLowerCase().replace(/\s+/g, '_');
Expand Down

0 comments on commit 2f963da

Please sign in to comment.