alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
598 lines (505 loc) • 14 kB
text/typescript
import "dayjs/plugin/relativeTime.js";
import "dayjs/plugin/duration.js";
import "dayjs/plugin/utc.js";
import "dayjs/plugin/timezone.js";
import "dayjs/plugin/localizedFormat.js";
import "dayjs/locale/ar.js";
import "dayjs/locale/fr.js";
import { $hook, $inject, Alepha } from "alepha";
import DayjsApi, {
type Dayjs,
type ManipulateType,
type OpUnitType,
type PluginFunc,
type QUnitType,
} from "dayjs";
import dayjsDuration, { type DurationUnitType } from "dayjs/plugin/duration.js";
import dayjsLocalizedFormat from "dayjs/plugin/localizedFormat.js";
import dayjsRelativeTime from "dayjs/plugin/relativeTime.js";
import dayjsTimezone from "dayjs/plugin/timezone.js";
import dayjsUtc from "dayjs/plugin/utc.js";
export type { DurationUnitType, ManipulateType, OpUnitType, QUnitType };
export type DateTimeInput = string | number | Date | DateTime | Dayjs;
export type DurationLike = number | Duration | [number, ManipulateType];
/**
* Immutable wrapper around the underlying date-time engine.
*
* Designed to isolate consumers from the engine in use (currently dayjs).
* Methods that produce a new value return a new `DateTime` instance.
*/
export class DateTime {
protected readonly inner: Dayjs;
constructor(inner: Dayjs) {
this.inner = inner;
}
/**
* Add a duration to this date-time.
*/
add(amount: number, unit?: ManipulateType): DateTime;
add(duration: Duration): DateTime;
add(amount: number | Duration, unit?: ManipulateType): DateTime {
if (amount instanceof Duration) {
return new DateTime(this.inner.add(amount.toDayjs()));
}
return new DateTime(this.inner.add(amount, unit));
}
/**
* Subtract a duration from this date-time.
*/
subtract(amount: number, unit?: ManipulateType): DateTime;
subtract(duration: Duration): DateTime;
subtract(amount: number | Duration, unit?: ManipulateType): DateTime {
if (amount instanceof Duration) {
return new DateTime(this.inner.subtract(amount.toDayjs()));
}
return new DateTime(this.inner.subtract(amount, unit));
}
startOf(unit: OpUnitType): DateTime {
return new DateTime(this.inner.startOf(unit));
}
endOf(unit: OpUnitType): DateTime {
return new DateTime(this.inner.endOf(unit));
}
isAfter(other: DateTimeInput): boolean {
return this.inner.isAfter(toDayjs(other));
}
isBefore(other: DateTimeInput): boolean {
return this.inner.isBefore(toDayjs(other));
}
isSame(other: DateTimeInput, unit?: OpUnitType): boolean {
return this.inner.isSame(toDayjs(other), unit);
}
diff(other: DateTimeInput, unit?: QUnitType | OpUnitType): number {
return this.inner.diff(toDayjs(other), unit);
}
tz(timezone: string): DateTime {
return new DateTime(this.inner.tz(timezone));
}
locale(lang: string): DateTime {
return new DateTime(this.inner.locale(lang));
}
format(template?: string): string {
return this.inner.format(template);
}
fromNow(withoutSuffix?: boolean): string {
return this.inner.fromNow(withoutSuffix);
}
toISOString(): string {
return this.inner.toISOString();
}
toDate(): Date {
return this.inner.toDate();
}
valueOf(): number {
return this.inner.valueOf();
}
unix(): number {
return this.inner.unix();
}
toJSON(): string {
return this.inner.toISOString();
}
toString(): string {
return this.inner.toISOString();
}
/**
* Escape hatch for the underlying dayjs instance.
*
* Use sparingly — anything calling this becomes coupled to dayjs and
* will need to migrate when the engine is replaced.
*/
toDayjs(): Dayjs {
return this.inner;
}
}
/**
* Immutable wrapper around the underlying duration engine.
*/
export class Duration {
protected readonly inner: dayjsDuration.Duration;
constructor(inner: dayjsDuration.Duration) {
this.inner = inner;
}
asMilliseconds(): number {
return this.inner.asMilliseconds();
}
asSeconds(): number {
return this.inner.asSeconds();
}
asMinutes(): number {
return this.inner.asMinutes();
}
asHours(): number {
return this.inner.asHours();
}
asDays(): number {
return this.inner.asDays();
}
as(unit: DurationUnitType): number {
return this.inner.as(unit);
}
toISOString(): string {
return this.inner.toISOString();
}
/**
* Escape hatch for the underlying dayjs duration.
*/
toDayjs(): dayjsDuration.Duration {
return this.inner;
}
}
export const isDateTime = (value: unknown): value is DateTime => {
return value instanceof DateTime;
};
const toDayjs = (value: DateTimeInput): Dayjs => {
if (value instanceof DateTime) {
return value.toDayjs();
}
return DayjsApi(value as any);
};
export class DateTimeProvider {
public static PLUGINS: Array<PluginFunc<any>> = [
dayjsDuration,
dayjsRelativeTime,
dayjsUtc,
dayjsTimezone,
dayjsLocalizedFormat,
];
protected alepha = $inject(Alepha);
protected ref: DateTime | null = null;
protected readonly timeouts: Timeout[] = [];
protected readonly intervals: Interval[] = [];
constructor() {
for (const plugin of DateTimeProvider.PLUGINS) {
DayjsApi.extend(plugin);
}
}
protected readonly onStart = $hook({
on: "start",
handler: async () => {
// we start intervals now but first tick will be rejected as App is not ready yet
await Promise.all(
this.intervals.map(async (interval) => {
if (interval.timer != null) {
return;
}
await interval.run();
interval.timer = setInterval(interval.run, interval.duration);
}),
);
},
});
protected readonly onStop = $hook({
on: "stop",
handler: () => {
for (const timeout of [...this.timeouts]) {
this.clearTimeout(timeout);
}
for (const interval of this.intervals) {
clearInterval(interval.timer);
interval.duration = 0;
interval.timer = null;
}
},
});
public setLocale(locale: string): void {
DayjsApi.locale(locale);
}
public isDateTime(value: unknown): value is DateTime {
return value instanceof DateTime;
}
/**
* Create a new UTC DateTime instance.
*/
public utc(date: DateTimeInput | null | undefined): DateTime {
return new DateTime(DayjsApi.utc(unwrap(date)));
}
/**
* Create a new DateTime instance.
*/
public of(date: DateTimeInput | null | undefined): DateTime {
if (date instanceof DateTime) {
return date;
}
return new DateTime(DayjsApi(date as any));
}
/**
* Get the current date as a string.
*/
public toISOString(date: DateTimeInput = this.now()): string {
return this.of(date).toISOString();
}
/**
* Get the current date.
*/
public now(): DateTime {
return this.getCurrentDate();
}
/**
* Get the current date as a string.
*
* This is much faster than `DateTimeProvider.now().toISOString()` as it avoids creating a DateTime instance.
*/
public nowISOString(): string {
if (this.ref) {
return this.ref.toISOString();
}
return new Date().toISOString();
}
/**
* Get the current date as milliseconds since epoch.
*
* This is much faster than `DateTimeProvider.now().valueOf()` as it avoids creating a DateTime instance.
*/
public nowMillis(): number {
if (this.ref) {
return this.ref.valueOf();
}
return Date.now();
}
/**
* Get the current date as a string.
*
* @protected
*/
protected getCurrentDate(): DateTime {
if (this.ref) {
return this.ref;
}
return new DateTime(DayjsApi());
}
/**
* Create a new Duration instance.
*/
public duration = (
duration: DurationLike,
unit?: ManipulateType,
): Duration => {
if (duration instanceof Duration) {
return duration;
}
if (Array.isArray(duration)) {
return new Duration(DayjsApi.duration(duration[0], duration[1]));
}
if (typeof duration === "number") {
return new Duration(DayjsApi.duration(duration, unit || "milliseconds"));
}
return duration;
};
public isDurationLike(value: unknown): value is DurationLike {
try {
return DayjsApi.isDuration(
this.duration(value as DurationLike).toDayjs(),
);
} catch {
return false;
}
}
// -------------------------------------------------------------------------------------------------------------------
// Timer Management
/**
* Return a promise that resolves after the next tick.
* It uses `setTimeout` with 0 ms delay.
*/
public async tick(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}
/**
* Wait for a certain duration.
*
* You can clear the timeout by using the `AbortSignal` API.
* Aborted signal will resolve the promise immediately, it does not reject it.
*/
public wait(
duration: DurationLike,
options: {
signal?: AbortSignal;
now?: number;
} = {},
): Promise<void> {
return new Promise((resolve) => {
let clearTimeout: any;
let callback: any;
const timeout = this.createTimeout(
() => {
if (options.signal && clearTimeout) {
options.signal.removeEventListener("abort", callback);
}
resolve();
},
duration,
options.now,
);
if (options.signal) {
clearTimeout = () => this.clearTimeout(timeout);
callback = () => {
clearTimeout();
resolve();
};
options.signal.addEventListener("abort", callback);
}
});
}
public createInterval(
run: () => unknown,
duration: DurationLike,
start = false,
): Interval {
const interval: Interval = {
run,
duration: this.duration(duration).asMilliseconds(),
};
this.intervals.push(interval);
if (start) {
interval.timer = setInterval(interval.run, interval.duration);
}
return interval;
}
/**
* Run a callback after a certain duration.
*/
public createTimeout(
callback: () => void,
duration: DurationLike,
now?: number,
): Timeout {
if (this.ref && now) {
const next = this.of(now).add(this.duration(duration));
if (next.valueOf() < this.now().valueOf()) {
callback();
}
return {
now,
duration: 0,
callback: () => {},
clear: () => {},
};
}
const timeout: Timeout = {
now: now ?? this.now().valueOf(),
duration: this.duration(duration).asMilliseconds(),
callback,
clear: () => this.clearTimeout(timeout),
};
timeout.timer = setTimeout(() => {
const index = this.timeouts.indexOf(timeout);
if (index !== -1) {
this.timeouts.splice(index, 1);
}
timeout.callback();
}, timeout.duration);
this.timeouts.push(timeout);
return timeout;
}
public clearTimeout(timeout: Timeout): void {
clearTimeout(timeout.timer);
timeout.duration = 0;
timeout.timer = null;
const index = this.timeouts.indexOf(timeout);
if (index !== -1) {
this.timeouts.splice(index, 1);
}
}
public clearInterval(interval: Interval): void {
clearInterval(interval.timer);
interval.duration = 0;
interval.timer = null;
}
/**
* Run a function with a deadline.
*/
public async deadline<T>(
fn: (signal: AbortSignal) => Promise<T>,
duration: DurationLike,
): Promise<T> {
const abort = new AbortController();
const timeout = this.createTimeout(() => abort.abort(), duration);
try {
return await fn(abort.signal);
} finally {
this.clearTimeout(timeout);
}
}
// -------------------------------------------------------------------------------------------------------------------
// Testing
/**
* Add time to the current date.
*/
public async travel(
duration: DurationLike,
unit?: ManipulateType,
): Promise<void> {
this.ref = this.ref || this.now();
const ms = this.duration(duration, unit).asMilliseconds();
const now = this.nowMillis();
this.ref = this.ref.add(this.duration(duration, unit));
for (const timeout of [...this.timeouts]) {
if (!timeout.timer) {
continue;
}
clearTimeout(timeout.timer);
timeout.timer = null;
const spent = now - timeout.now;
timeout.duration = timeout.duration - spent - ms;
if (timeout.duration <= 0) {
const index = this.timeouts.indexOf(timeout);
if (index !== -1) {
this.timeouts.splice(index, 1);
}
timeout.callback();
} else {
timeout.timer = setTimeout(() => {
const index = this.timeouts.indexOf(timeout);
if (index !== -1) {
this.timeouts.splice(index, 1);
}
timeout.callback();
}, timeout.duration);
}
}
for (const interval of this.intervals) {
if (!interval.timer) {
continue;
}
clearInterval(interval.timer);
const repeat = Math.floor(ms / interval.duration);
for (let i = 0; i < repeat; i++) {
await interval.run();
}
// Keep intervals suspended — they only fire during travel() calls
interval.timer = null;
}
await this.tick();
}
/**
* Stop the time.
*/
public pause(): DateTime {
this.ref = this.ref || this.now();
return this.ref;
}
/**
* Reset the reference date.
*/
public reset(): void {
this.ref = null;
}
}
const unwrap = (value: DateTimeInput | null | undefined): any => {
if (value instanceof DateTime) {
return value.toDayjs();
}
return value;
};
export interface Interval {
timer?: any;
duration: number;
run: () => unknown;
}
export interface Timeout {
now: number;
timer?: any;
duration: number;
callback: () => void;
clear: () => void;
}