diff --git a/package.json b/package.json
index 97bc56714..c020f04d5 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,8 @@
"generate:migration": "node -r dotenv/config -r module-alias/register node_modules/typeorm/cli.js migration:generate -d dist/util/util/Database.js",
"generate:openapi": "node scripts/openapi.js",
"add:license": "node scripts/license.js",
- "migrate-from-staging": "node -r dotenv/config -r module-alias/register scripts/stagingMigration/index.js"
+ "migrate-from-staging": "node -r dotenv/config -r module-alias/register scripts/stagingMigration/index.js",
+ "node:tests": "npm run build:src && node -r dotenv/config -r module-alias/register --enable-source-maps --test --experimental-test-coverage dist/**/*.test.js"
},
"main": "dist/bundle/index.js",
"types": "src/bundle/index.ts",
diff --git a/src/util/util/DateBuilder.test.ts b/src/util/util/DateBuilder.test.ts
new file mode 100644
index 000000000..4454f4c83
--- /dev/null
+++ b/src/util/util/DateBuilder.test.ts
@@ -0,0 +1,142 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { DateBuilder } from "./DateBuilder";
+
+test("DateBuilder should be able to be initialised", () => {
+ const db = new DateBuilder();
+ assert.equal(db instanceof DateBuilder, true);
+});
+
+test("DateBuilder should be able to build current date", () => {
+ const now = new Date();
+ const db = new DateBuilder();
+ const built = db.build();
+ assert.equal(built.getFullYear(), now.getFullYear());
+ assert.equal(built.getMonth(), now.getMonth());
+ assert.equal(built.getDate(), now.getDate());
+ assert.equal(built.getHours(), now.getHours());
+ assert.equal(built.getMinutes(), now.getMinutes());
+ assert.equal(built.getSeconds(), now.getSeconds());
+});
+
+test("DateBuilder should be able to build timestamp", () => {
+ const now = new Date();
+ const db = new DateBuilder();
+ const built = db.buildTimestamp();
+ assert.equal(built, now.getTime());
+});
+
+test("DateBuilder should be able to add days", () => {
+ const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 1, 2024
+ db.addDays(30);
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2024);
+ assert.equal(built.getMonth(), 0); // January
+ assert.equal(built.getDate(), 31); // January has 31 days
+});
+
+test("DateBuilder should be able to add months", () => {
+ const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 31, 2024
+ db.addMonths(1);
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2024);
+ assert.equal(built.getMonth(), 1); // February
+});
+
+test("DateBuilder should be able to add years", () => {
+ const db = new DateBuilder(new Date(2020, 1, 1));
+ db.addYears(1);
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2021);
+ assert.equal(built.getMonth(), 1); // February
+ assert.equal(built.getDate(), 1);
+});
+
+test("DateBuilder should be able to set date", () => {
+ const db = new DateBuilder();
+ db.withDate(2022, 12, 25); // Dec 25, 2022
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2022);
+ assert.equal(built.getMonth(), 11); // December
+ assert.equal(built.getDate(), 25);
+});
+
+test("DateBuilder should be able to set time", () => {
+ const db = new DateBuilder();
+ db.withTime(15, 30, 45, 123); // 15:30:45.123
+ const built = db.build();
+ assert.equal(built.getHours(), 15);
+ assert.equal(built.getMinutes(), 30);
+ assert.equal(built.getSeconds(), 45);
+ assert.equal(built.getMilliseconds(), 123);
+});
+
+test("DateBuilder should be able to set start of day", () => {
+ const db = new DateBuilder(new Date(2024, 5, 15, 10, 20, 30, 456)); // June 15, 2024, 10:20:30.456
+ db.atStartOfDay();
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2024);
+ assert.equal(built.getMonth(), 5); // June
+ assert.equal(built.getDate(), 15);
+ assert.equal(built.getHours(), 0);
+ assert.equal(built.getMinutes(), 0);
+ assert.equal(built.getSeconds(), 0);
+ assert.equal(built.getMilliseconds(), 0);
+});
+
+test("DateBuilder should be able to set end of day", () => {
+ const db = new DateBuilder(new Date(2024, 5, 15, 10, 20, 30, 456)); // June 15, 2024, 10:20:30.456
+ db.atEndOfDay();
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2024);
+ assert.equal(built.getMonth(), 5); // June
+ assert.equal(built.getDate(), 15);
+ assert.equal(built.getHours(), 23);
+ assert.equal(built.getMinutes(), 59);
+ assert.equal(built.getSeconds(), 59);
+ assert.equal(built.getMilliseconds(), 999);
+});
+
+test("DateBuilder should be able to chain methods", () => {
+ const db = new DateBuilder(new Date(2024, 0, 1)); // Jan 1, 2024
+ db.addDays(1).addMonths(1).addYears(1).withTime(12, 0, 0).atEndOfDay();
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2025);
+ assert.equal(built.getMonth(), 1); // March
+ assert.equal(built.getDate(), 2);
+ assert.equal(built.getHours(), 23);
+ assert.equal(built.getMinutes(), 59);
+ assert.equal(built.getSeconds(), 59);
+ assert.equal(built.getMilliseconds(), 999);
+});
+
+test("DateBuilder should not mutate original date", () => {
+ const original = new Date(2024, 0, 1); // Jan 1, 2024
+ const db = new DateBuilder(original);
+ db.addDays(10);
+ const built = db.build();
+ assert.equal(original.getFullYear(), 2024);
+ assert.equal(original.getMonth(), 0); // January
+ assert.equal(original.getDate(), 1); // Original date should remain unchanged
+ assert.equal(built.getFullYear(), 2024);
+ assert.equal(built.getMonth(), 0); // January
+ assert.equal(built.getDate(), 11); // New date should be Jan 11, 2024
+});
+
+test("DateBuilder should handle leap years correctly", () => {
+ const db = new DateBuilder(new Date(2020, 1, 29)); // Feb 29, 2020 (leap year)
+ db.addYears(1);
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2021);
+ assert.equal(built.getMonth(), 2); // March
+ assert.equal(built.getDate(), 1); // March 1, 2021 (not a leap year)
+});
+
+test("DateBuilder should handle month overflow correctly", () => {
+ const db = new DateBuilder(new Date(2024, 0, 31)); // Jan 31, 2024
+ db.addDays(1);
+ const built = db.build();
+ assert.equal(built.getFullYear(), 2024);
+ assert.equal(built.getMonth(), 1); // February
+ assert.equal(built.getDate(), 1); // Feb 29, 2024 (leap year)
+});
diff --git a/src/util/util/DateBuilder.ts b/src/util/util/DateBuilder.ts
new file mode 100644
index 000000000..e9c623a90
--- /dev/null
+++ b/src/util/util/DateBuilder.ts
@@ -0,0 +1,92 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+export class DateBuilder {
+ private date: Date;
+ // constructors
+ constructor(date = new Date()) {
+ if (!(date instanceof Date)) {
+ throw new Error("Invalid date object.");
+ }
+ this.date = new Date(date.getTime()); // Create a copy to avoid mutating the original date
+ }
+
+ // methods
+ addYears(years: number) {
+ this.date.setFullYear(this.date.getFullYear() + years);
+ return this;
+ }
+
+ addMonths(months: number) {
+ this.date.setMonth(this.date.getMonth() + months);
+ return this;
+ }
+
+ addDays(days: number) {
+ this.date.setDate(this.date.getDate() + days);
+ return this;
+ }
+
+ addHours(hours: number) {
+ this.date.setHours(this.date.getHours() + hours);
+ return this;
+ }
+
+ addMinutes(minutes: number) {
+ this.date.setMinutes(this.date.getMinutes() + minutes);
+ return this;
+ }
+
+ addSeconds(seconds: number) {
+ this.date.setSeconds(this.date.getSeconds() + seconds);
+ return this;
+ }
+
+ addMillis(millis: number) {
+ this.date.setTime(this.date.getTime() + millis);
+ return this;
+ }
+
+ withDate(year: number, month: number, day: number | undefined) {
+ this.date.setFullYear(year, month - 1, day); // month is 0-based
+ return this;
+ }
+
+ withTime(hour: number, minute = 0, second = 0, millisecond = 0) {
+ this.date.setHours(hour, minute, second, millisecond);
+ return this;
+ }
+
+ atStartOfDay() {
+ this.date.setHours(0, 0, 0, 0);
+ return this;
+ }
+
+ atEndOfDay() {
+ this.date.setHours(23, 59, 59, 999);
+ return this;
+ }
+
+ build() {
+ return new Date(this.date.getTime()); // Return a copy to avoid external mutation
+ }
+
+ buildTimestamp() {
+ return this.date.getTime();
+ }
+}
\ No newline at end of file
diff --git a/src/util/util/ElapsedTime.test.ts b/src/util/util/ElapsedTime.test.ts
new file mode 100644
index 000000000..012ecfafe
--- /dev/null
+++ b/src/util/util/ElapsedTime.test.ts
@@ -0,0 +1,78 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { ElapsedTime } from "./ElapsedTime";
+
+test("ElapsedTime should be able to be initialised", () => {
+ const db = new ElapsedTime(0n);
+ assert.equal(db != null, true);
+});
+
+test("ElapsedTime should return correct total nanoseconds", () => {
+ const db = new ElapsedTime(1234567890n);
+ assert.equal(db.totalNanoseconds, 1234567890n);
+});
+
+test("ElapsedTime should return correct total microseconds", () => {
+ const db = new ElapsedTime(1234567890n);
+ assert.equal(db.totalMicroseconds, 1234567);
+});
+
+test("ElapsedTime should return correct total milliseconds", () => {
+ const db = new ElapsedTime(1234567890n);
+ assert.equal(db.totalMilliseconds, 1234);
+});
+
+test("ElapsedTime should return correct total seconds", () => {
+ const db = new ElapsedTime(5000000000n);
+ assert.equal(db.totalSeconds, 5);
+});
+
+test("ElapsedTime should return correct total minutes", () => {
+ const db = new ElapsedTime(300000000000n); // 5 minutes
+ assert.equal(db.totalMinutes, 5);
+});
+
+test("ElapsedTime should return correct total hours", () => {
+ const db = new ElapsedTime(7200000000000n); // 2 hours
+ assert.equal(db.totalHours, 2);
+});
+
+test("ElapsedTime should return correct total days", () => {
+ const db = new ElapsedTime(172800000000000n); // 2 days
+ assert.equal(db.totalDays, 2);
+});
+
+test("ElapsedTime should return correct nanoseconds", () => {
+ const db = new ElapsedTime(1234567890n);
+ assert.equal(db.nanoseconds, 890);
+});
+
+test("ElapsedTime should return correct microseconds", () => {
+ const db = new ElapsedTime(1234567890n);
+ assert.equal(db.microseconds, 567);
+});
+
+test("ElapsedTime should return correct milliseconds", () => {
+ const db = new ElapsedTime(1234567890n);
+ assert.equal(db.milliseconds, 234);
+});
+
+test("ElapsedTime should return correct seconds", () => {
+ const db = new ElapsedTime(5000000000n);
+ assert.equal(db.seconds, 5);
+});
+
+test("ElapsedTime should return correct minutes", () => {
+ const db = new ElapsedTime(300000000000n); // 5 minutes
+ assert.equal(db.minutes, 5);
+});
+
+test("ElapsedTime should return correct hours", () => {
+ const db = new ElapsedTime(7200000000000n); // 2 hours
+ assert.equal(db.hours, 2);
+});
+
+test("ElapsedTime should return correct days", () => {
+ const db = new ElapsedTime(172800000000000n); // 2 days
+ assert.equal(db.days, 2);
+});
\ No newline at end of file
diff --git a/src/util/util/ElapsedTime.ts b/src/util/util/ElapsedTime.ts
new file mode 100644
index 000000000..518025c61
--- /dev/null
+++ b/src/util/util/ElapsedTime.ts
@@ -0,0 +1,71 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+// Inspired by the dotnet Stopwatch class
+// Provides a simple interface to get elapsed time in high resolution
+
+export class ElapsedTime {
+ private readonly timeNanos: bigint;
+
+ constructor(timeNanos: bigint) {
+ this.timeNanos = timeNanos;
+ }
+
+ get totalNanoseconds(): bigint {
+ return this.timeNanos;
+ }
+ get totalMicroseconds(): number {
+ return Number(this.timeNanos / 1_000n);
+ }
+ get totalMilliseconds(): number {
+ return Number(this.timeNanos / 1_000_000n);
+ }
+ get totalSeconds(): number {
+ return Number(this.timeNanos / 1_000_000_000n);
+ }
+ get totalMinutes(): number {
+ return this.totalSeconds / 60;
+ }
+ get totalHours(): number {
+ return this.totalMinutes / 60;
+ }
+ get totalDays(): number {
+ return this.totalHours / 24;
+ }
+ get nanoseconds(): number {
+ return Number(this.timeNanos % 1_000n);
+ }
+ get microseconds(): number {
+ return Number(this.timeNanos / 1_000n) % 1000;
+ }
+ get milliseconds(): number {
+ return Number(this.timeNanos / 1_000_000n) % 1000;
+ }
+ get seconds(): number {
+ return Number(this.timeNanos / 1_000_000_000n) % 60;
+ }
+ get minutes(): number {
+ return this.totalMinutes % 60;
+ }
+ get hours(): number {
+ return this.totalHours % 24;
+ }
+ get days(): number {
+ return this.totalDays;
+ }
+}
\ No newline at end of file
diff --git a/src/util/util/Stopwatch.test.ts b/src/util/util/Stopwatch.test.ts
new file mode 100644
index 000000000..7d3d68e9b
--- /dev/null
+++ b/src/util/util/Stopwatch.test.ts
@@ -0,0 +1,52 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { Stopwatch, timePromise } from "./Stopwatch";
+
+test("Stopwatch should be able to be initialised", () => {
+ const sw = new Stopwatch();
+ assert.equal(sw != null, true);
+});
+
+test("Stopwatch should measure elapsed time", async () => {
+ const sw = Stopwatch.startNew();
+ await new Promise((resolve) => setTimeout(resolve, 101));
+ sw.stop();
+ const elapsed = sw.elapsed();
+ assert(elapsed.totalMilliseconds >= 100, `Elapsed time was ${elapsed.totalMilliseconds} ms`);
+});
+
+test("Stopwatch should reset correctly", async () => {
+ const sw = Stopwatch.startNew();
+ await new Promise((resolve) => setTimeout(resolve, 101));
+ sw.stop();
+ let elapsed = sw.elapsed();
+ assert(elapsed.totalMilliseconds >= 100, `Elapsed time was ${elapsed.totalMilliseconds} ms`);
+
+ sw.reset();
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ sw.stop();
+ elapsed = sw.elapsed();
+ assert(elapsed.totalMilliseconds >= 50 && elapsed.totalMilliseconds < 100, `Elapsed time after reset was ${elapsed.totalMilliseconds} ms`);
+});
+
+test("Stopwatch getElapsedAndReset should work correctly", async () => {
+ const sw = Stopwatch.startNew();
+ await new Promise((resolve) => setTimeout(resolve, 101));
+ sw.stop();
+ let elapsed = sw.getElapsedAndReset();
+ assert(elapsed.totalMilliseconds >= 100, `Elapsed time was ${elapsed.totalMilliseconds} ms`);
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ sw.stop();
+ elapsed = sw.elapsed();
+ assert(elapsed.totalMilliseconds >= 50 && elapsed.totalMilliseconds < 100, `Elapsed time after getElapsedAndReset was ${elapsed.totalMilliseconds} ms`);
+});
+
+test("timePromise should measure promise execution time", async () => {
+ const { result, elapsed } = await timePromise(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 101));
+ return 42;
+ });
+ assert.equal(result, 42);
+ assert(elapsed.totalMilliseconds >= 100, `Elapsed time was ${elapsed.totalMilliseconds} ms`);
+});
diff --git a/src/util/util/Stopwatch.ts b/src/util/util/Stopwatch.ts
index 1a77218b5..aaaf2b226 100644
--- a/src/util/util/Stopwatch.ts
+++ b/src/util/util/Stopwatch.ts
@@ -18,6 +18,8 @@
// Inspired by the dotnet Stopwatch class
// Provides a simple interface to get elapsed time in high resolution
+import { ElapsedTime } from "./ElapsedTime";
+
export class Stopwatch {
private startTime: bigint;
private endTime: bigint | null = null;
@@ -53,57 +55,6 @@ export class Stopwatch {
}
}
-export class ElapsedTime {
- private readonly timeNanos: bigint;
-
- constructor(timeNanos: bigint) {
- this.timeNanos = timeNanos;
- }
-
- get totalNanoseconds(): bigint {
- return this.timeNanos;
- }
- get totalMicroseconds(): number {
- return Number(this.timeNanos / 1_000n);
- }
- get totalMilliseconds(): number {
- return Number(this.timeNanos / 1_000_000n);
- }
- get totalSeconds(): number {
- return Number(this.timeNanos / 1_000_000_000n);
- }
- get totalMinutes(): number {
- return this.totalSeconds / 60;
- }
- get totalHours(): number {
- return this.totalMinutes / 60;
- }
- get totalDays(): number {
- return this.totalHours / 24;
- }
- get nanoseconds(): number {
- return Number(this.timeNanos % 1_000n);
- }
- get microseconds(): number {
- return Number(this.timeNanos / 1_000n) % 1000;
- }
- get milliseconds(): number {
- return Number(this.timeNanos / 1_000_000n) % 1000;
- }
- get seconds(): number {
- return Number(this.timeNanos / 1_000_000_000n) % 60;
- }
- get minutes(): number {
- return this.totalMinutes % 60;
- }
- get hours(): number {
- return this.totalHours % 24;
- }
- get days(): number {
- return this.totalDays;
- }
-}
-
export async function timePromise(fn: () => Promise): Promise<{ result: T; elapsed: ElapsedTime }> {
const stopwatch = Stopwatch.startNew();
const result = await fn();
diff --git a/src/util/util/Timespan.test.ts b/src/util/util/Timespan.test.ts
new file mode 100644
index 000000000..cb5918e09
--- /dev/null
+++ b/src/util/util/Timespan.test.ts
@@ -0,0 +1,120 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { TimeSpan } from "./Timespan";
+
+test("TimeSpan should be able to be initialised", () => {
+ const db = new TimeSpan();
+ assert.equal(db != null, true);
+});
+
+test("TimeSpan should be able to be initialised with start and end", () => {
+ const now = Date.now();
+ const later = now + 5000;
+ const ts = new TimeSpan(now, later);
+ assert.equal(ts.start, now);
+ assert.equal(ts.end, later);
+});
+
+test("TimeSpan should be able to be initialised with start and end (fromDates static method)", () => {
+ const now = Date.now();
+ const later = now + 5000;
+ const ts = TimeSpan.fromDates(now, later);
+ assert.equal(ts.start, now);
+ assert.equal(ts.end, later);
+});
+
+test("TimeSpan should throw error if start is greater than end", () => {
+ assert.throws(() => {
+ new TimeSpan(2000, 1000);
+ }, /Start time must be less than or equal to end time./);
+});
+
+test("TimeSpan should be able to return zero", () => {
+ const ts = new TimeSpan();
+ assert.equal(ts.totalMillis, 0);
+});
+
+test("TimeSpan should be able to return timespan from milliseconds", () => {
+ const ts = TimeSpan.fromMillis(1000);
+ assert.equal(ts.totalMillis, 1000);
+ assert.equal(ts.totalSeconds, 1);
+});
+
+test("TimeSpan should be able to return timespan from seconds", () => {
+ const ts = TimeSpan.fromSeconds(60);
+ assert.equal(ts.totalMillis, 60000);
+ assert.equal(ts.totalSeconds, 60);
+ assert.equal(ts.totalMinutes, 1);
+ assert.equal(ts.minutes, 1);
+ assert.equal(ts.hours, 0);
+ assert.equal(ts.days, 0);
+});
+
+test("TimeSpan should be pure", () => {
+ const count = 10;
+ const timestamps = [];
+ for (let i = 0; i < count; i++) {
+ timestamps.push(TimeSpan.fromMillis(8972347984));
+ for (const ts2 of timestamps) {
+ assert.equal(ts2.totalMillis, 8972347984);
+ assert.equal(ts2.totalSeconds, 8972347);
+ assert.equal(ts2.totalMinutes, 149539);
+ assert.equal(ts2.totalHours, 2492);
+ assert.equal(ts2.totalDays, 103);
+ assert.equal(ts2.totalWeeks, 14);
+ assert.equal(ts2.totalMonths, 3);
+ assert.equal(ts2.totalYears, 0);
+
+ assert.equal(ts2.millis, 984);
+ assert.equal(ts2.seconds, 7);
+ assert.equal(ts2.minutes, 19);
+ assert.equal(ts2.hours, 20);
+ assert.equal(ts2.days, 12);
+ assert.equal(ts2.weekDays, 5);
+ assert.equal(ts2.weeks, 1);
+ assert.equal(ts2.months, 3);
+ assert.equal(ts2.years, 0);
+ }
+ }
+});
+
+test("TimeSpan should be able to stringify", () => {
+ const ts = TimeSpan.fromMillis(8972347984);
+ assert.equal(ts.toString(), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds");
+ assert.equal(ts.toString(true), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds");
+ assert.equal(ts.toString(true, false), "3 months, 1 weeks, 5 days, 20 hours, 19 minutes, 7 seconds");
+ assert.equal(ts.toString(false), "3 months, 12 days, 20 hours, 19 minutes, 7 seconds, 984 milliseconds");
+ assert.equal(ts.toString(false, false), "3 months, 12 days, 20 hours, 19 minutes, 7 seconds");
+});
+
+test("TimeSpan should be able to shortStringify", () => {
+ const ts = TimeSpan.fromMillis(8972347984);
+ assert.equal(ts.toShortString(), "3mo1w5d20h19m7s984ms");
+ assert.equal(ts.toShortString(true), "3mo1w5d20h19m7s984ms");
+ assert.equal(ts.toShortString(true, false), "3mo1w5d20h19m7s");
+ assert.equal(ts.toShortString(false), "3mo12d20h19m7s984ms");
+ assert.equal(ts.toShortString(false, false), "3mo12d20h19m7s");
+});
+
+test("TimeSpan should be able to shortStringify with spaces", () => {
+ const ts = TimeSpan.fromMillis(8972347984);
+ assert.equal(ts.toShortString(undefined, undefined, true), "3mo 1w 5d 20h 19m 7s 984ms");
+ assert.equal(ts.toShortString(true, undefined, true), "3mo 1w 5d 20h 19m 7s 984ms");
+ assert.equal(ts.toShortString(true, false, true), "3mo 1w 5d 20h 19m 7s");
+ assert.equal(ts.toShortString(false, undefined, true), "3mo 12d 20h 19m 7s 984ms");
+ assert.equal(ts.toShortString(false, false, true), "3mo 12d 20h 19m 7s");
+});
+
+test("TimeSpan should be able to return start date", () => {
+ const now = Date.now();
+ const later = now + 5000;
+ const ts = new TimeSpan(now, later);
+ assert.equal(ts.startDate.getTime(), now);
+});
+
+test("TimeSpan should be able to return end date", () => {
+ const now = Date.now();
+ const later = now + 5000;
+ const ts = new TimeSpan(now, later);
+ assert.equal(ts.endDate.getTime(), later);
+});
\ No newline at end of file
diff --git a/src/util/util/Timespan.ts b/src/util/util/Timespan.ts
new file mode 100644
index 000000000..c07bf52ec
--- /dev/null
+++ b/src/util/util/Timespan.ts
@@ -0,0 +1,130 @@
+/**
+ * Represents a timespan with a start and end time.
+ */
+export class TimeSpan {
+ public readonly start: number;
+ public readonly end: number;
+ // constructors
+ constructor(start = Date.now(), end = Date.now()) {
+ if (start > end) {
+ throw new Error("Start time must be less than or equal to end time.");
+ }
+ this.start = start;
+ this.end = end;
+ }
+
+ static fromDates(startDate: number, endDate: number) {
+ return new TimeSpan(startDate, endDate);
+ }
+
+ static fromMillis(millis: number) {
+ return new TimeSpan(0, millis);
+ }
+
+ static fromSeconds(seconds: number) {
+ return TimeSpan.fromMillis(seconds * 1000);
+ }
+
+ // methods
+ get totalMillis() {
+ return this.end - this.start;
+ }
+
+ get millis() {
+ return Math.floor(this.totalMillis % 1000);
+ }
+
+ get totalSeconds() {
+ return Math.floor(this.totalMillis / 1000);
+ }
+
+ get seconds() {
+ return Math.floor((this.totalMillis / 1000) % 60);
+ }
+
+ get totalMinutes() {
+ return Math.floor(this.totalMillis / 1000 / 60);
+ }
+
+ get minutes() {
+ return Math.floor((this.totalMillis / 1000 / 60) % 60);
+ }
+
+ get totalHours() {
+ return Math.floor(this.totalMillis / 1000 / 60 / 60);
+ }
+
+ get hours() {
+ return Math.floor((this.totalMillis / 1000 / 60 / 60) % 24);
+ }
+
+ get totalDays() {
+ return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24);
+ }
+
+ get days() {
+ return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24) % 30.44); // Average days in a month
+ }
+
+ get weekDays() {
+ return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24) % 7);
+ }
+
+ get totalWeeks() {
+ return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 7);
+ }
+
+ get weeks() {
+ return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24 / 7) % 4.345); // Average weeks in a month
+ }
+
+ get totalMonths() {
+ return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 30.44); // Average days in a month
+ }
+
+ get months() {
+ return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24 / 30.44) % 12); // Average days in a month
+ }
+
+ get totalYears() {
+ return Math.floor(this.totalMillis / 1000 / 60 / 60 / 24 / 365.25); // Average days in a year
+ }
+
+ get years() {
+ return Math.floor((this.totalMillis / 1000 / 60 / 60 / 24 / 365.25)); // Average days in a year
+ }
+
+ toString(includeWeeks = true, includeMillis = true) {
+ const parts = [];
+ if (this.totalYears >= 1) parts.push(`${this.totalYears} years`);
+ if (this.totalMonths >= 1) parts.push(`${this.months} months`);
+ if (includeWeeks && this.totalWeeks >= 1) parts.push(`${this.weeks} weeks`);
+ if (this.totalDays >= 1) parts.push(`${includeWeeks ? this.weekDays : this.days} days`);
+ if (this.totalHours >= 1) parts.push(`${this.hours} hours`);
+ if (this.totalMinutes >= 1) parts.push(`${this.minutes} minutes`);
+ if (this.totalSeconds >= 1) parts.push(`${this.seconds} seconds`);
+ if (includeMillis) parts.push(`${this.millis} milliseconds`);
+ return parts.join(", ");
+ }
+
+ toShortString(includeWeeks = true, includeMillis = true, withSpaces = false) {
+ const parts = [];
+ if (this.totalYears >= 1) parts.push(`${this.totalYears}y`);
+ if (this.totalMonths >= 1) parts.push(`${this.months}mo`);
+ if (includeWeeks && this.totalWeeks >= 1) parts.push(`${this.weeks}w`);
+ if (this.totalDays >= 1) parts.push(`${includeWeeks ? this.weekDays : this.days}d`);
+ if (this.totalHours >= 1) parts.push(`${this.hours}h`);
+ if (this.totalMinutes >= 1) parts.push(`${this.minutes}m`);
+ if (this.totalSeconds >= 1) parts.push(`${this.seconds}s`);
+ if (includeMillis) parts.push(`${this.millis}ms`);
+ return parts.join(withSpaces ? " " : "");
+ }
+
+ get startDate() {
+ return new Date(this.start);
+ }
+
+ get endDate() {
+ return new Date(this.end);
+ }
+}
\ No newline at end of file
diff --git a/src/util/util/index.ts b/src/util/util/index.ts
index 920c330db..1cdc3caf9 100644
--- a/src/util/util/index.ts
+++ b/src/util/util/index.ts
@@ -24,7 +24,9 @@ export * from "./cdn";
export * from "./Config";
export * from "./Constants";
export * from "./Database";
+export * from "./DateBuilder";
export * from "./email";
+export * from "./ElapsedTime";
export * from "./Event";
export * from "./FieldError";
export * from "./Intents";
@@ -40,6 +42,7 @@ export * from "./Rights";
export * from "./Snowflake";
export * from "./Stopwatch";
export * from "./String";
+export * from "./Timespan";
export * from "./Token";
export * from "./TraverseDirectory";
export * from "./WebAuthn";