159 lines
5.3 KiB
TypeScript
159 lines
5.3 KiB
TypeScript
const SERVER_UTC_OFFSET_MINUTES = 9 * 60;
|
|
const SERVER_UTC_OFFSET_MS = SERVER_UTC_OFFSET_MINUTES * 60_000;
|
|
|
|
export type ServerDateTimeFormat =
|
|
| 'dateTimeSeconds'
|
|
| 'dateTimeMinutes'
|
|
| 'date'
|
|
| 'timeSeconds'
|
|
| 'hourMinute'
|
|
| 'minuteSecond'
|
|
| 'monthDayTime'
|
|
| 'monthDayTimeSeconds';
|
|
|
|
export type ServerDateTimeOptions = {
|
|
format?: ServerDateTimeFormat;
|
|
fallback?: string;
|
|
};
|
|
|
|
type DateTimeParts = {
|
|
year: number;
|
|
month: number;
|
|
day: number;
|
|
hour: number;
|
|
minute: number;
|
|
second: number;
|
|
millisecond: number;
|
|
};
|
|
|
|
const SERVER_WALL_TIME_PATTERN = /^(\d{4,6})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/u;
|
|
|
|
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
|
|
|
|
const isValidParts = (parts: DateTimeParts): boolean => {
|
|
const candidate = new Date(0);
|
|
candidate.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
|
candidate.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
|
|
return (
|
|
candidate.getUTCFullYear() === parts.year &&
|
|
candidate.getUTCMonth() + 1 === parts.month &&
|
|
candidate.getUTCDate() === parts.day &&
|
|
candidate.getUTCHours() === parts.hour &&
|
|
candidate.getUTCMinutes() === parts.minute &&
|
|
candidate.getUTCSeconds() === parts.second &&
|
|
candidate.getUTCMilliseconds() === parts.millisecond
|
|
);
|
|
};
|
|
|
|
const parseServerWallTime = (value: string): DateTimeParts | null => {
|
|
const match = SERVER_WALL_TIME_PATTERN.exec(value.trim());
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const millisecondText = match[7] ?? '';
|
|
const parts: DateTimeParts = {
|
|
year: Number(match[1]),
|
|
month: Number(match[2]),
|
|
day: Number(match[3]),
|
|
hour: Number(match[4] ?? 0),
|
|
minute: Number(match[5] ?? 0),
|
|
second: Number(match[6] ?? 0),
|
|
millisecond: Number(millisecondText.padEnd(3, '0')),
|
|
};
|
|
return isValidParts(parts) ? parts : null;
|
|
};
|
|
|
|
const partsFromInstant = (value: string | Date): DateTimeParts | null => {
|
|
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return null;
|
|
}
|
|
const shifted = new Date(date.getTime() + SERVER_UTC_OFFSET_MS);
|
|
return {
|
|
year: shifted.getUTCFullYear(),
|
|
month: shifted.getUTCMonth() + 1,
|
|
day: shifted.getUTCDate(),
|
|
hour: shifted.getUTCHours(),
|
|
minute: shifted.getUTCMinutes(),
|
|
second: shifted.getUTCSeconds(),
|
|
millisecond: shifted.getUTCMilliseconds(),
|
|
};
|
|
};
|
|
|
|
const resolveParts = (value: string | Date): DateTimeParts | null => {
|
|
if (typeof value === 'string') {
|
|
const wallTime = parseServerWallTime(value);
|
|
if (wallTime) {
|
|
return wallTime;
|
|
}
|
|
}
|
|
return partsFromInstant(value);
|
|
};
|
|
|
|
const formatParts = (parts: DateTimeParts, format: ServerDateTimeFormat): string => {
|
|
const year = pad(parts.year, 4);
|
|
const month = pad(parts.month);
|
|
const day = pad(parts.day);
|
|
const hour = pad(parts.hour);
|
|
const minute = pad(parts.minute);
|
|
const second = pad(parts.second);
|
|
|
|
switch (format) {
|
|
case 'dateTimeMinutes':
|
|
return `${year}-${month}-${day} ${hour}:${minute}`;
|
|
case 'date':
|
|
return `${year}-${month}-${day}`;
|
|
case 'timeSeconds':
|
|
return `${hour}:${minute}:${second}`;
|
|
case 'hourMinute':
|
|
return `${hour}:${minute}`;
|
|
case 'minuteSecond':
|
|
return `${minute}:${second}`;
|
|
case 'monthDayTime':
|
|
return `${month}-${day} ${hour}:${minute}`;
|
|
case 'monthDayTimeSeconds':
|
|
return `${month}-${day} ${hour}:${minute}:${second}`;
|
|
case 'dateTimeSeconds':
|
|
default:
|
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Formats an instant in the service's fixed UTC+9 wall clock.
|
|
*
|
|
* Timezone-less legacy DATETIME strings are already server wall-clock values and
|
|
* therefore keep their components. This deliberate fixed offset also avoids
|
|
* historical IANA timezone rules changing ancient in-game years.
|
|
*/
|
|
export const formatServerDateTime = (
|
|
value: string | Date | null | undefined,
|
|
options: ServerDateTimeOptions = {}
|
|
): string => {
|
|
if (value === null || value === undefined || value === '') {
|
|
return options.fallback ?? '';
|
|
}
|
|
const parts = resolveParts(value);
|
|
if (!parts) {
|
|
return options.fallback ?? String(value);
|
|
}
|
|
return formatParts(parts, options.format ?? 'dateTimeSeconds');
|
|
};
|
|
|
|
export const toServerDateTimeInputValue = (value: string | Date | null | undefined): string => {
|
|
const formatted = formatServerDateTime(value, { format: 'dateTimeMinutes', fallback: '' });
|
|
return formatted ? formatted.replace(' ', 'T') : '';
|
|
};
|
|
|
|
/** Converts an HTML datetime-local value, interpreted as UTC+9 server wall time, to ISO UTC. */
|
|
export const serverDateTimeInputToIso = (value: string): string | undefined => {
|
|
const parts = parseServerWallTime(value);
|
|
if (!parts) {
|
|
return undefined;
|
|
}
|
|
const wallTime = new Date(0);
|
|
wallTime.setUTCFullYear(parts.year, parts.month - 1, parts.day);
|
|
wallTime.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
|
|
return new Date(wallTime.getTime() - SERVER_UTC_OFFSET_MS).toISOString();
|
|
};
|