fix(frontend): standardize server time display

This commit is contained in:
2026-08-13 15:26:53 +00:00
parent 2c23c1458a
commit dcba974aa4
23 changed files with 352 additions and 175 deletions
+1
View File
@@ -1,6 +1,7 @@
export * from './rng.js';
export * from './time/Clock.js';
export * from './time/GameClock.js';
export * from './time/ServerDateTime.js';
export * from './util/BytesLike.js';
export * from './util/convertBytesLikeToArrayBuffer.js';
export * from './util/convertBytesLikeToUint8Array.js';
+158
View File
@@ -0,0 +1,158 @@
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();
};
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
formatServerDateTime,
serverDateTimeInputToIso,
toServerDateTimeInputValue,
} from '../src/time/ServerDateTime.js';
describe('formatServerDateTime', () => {
it('formats ISO instants with the fixed UTC+9 service offset', () => {
expect(formatServerDateTime('2026-08-13T00:05:06.000Z')).toBe('2026-08-13 09:05:06');
expect(formatServerDateTime('0185-01-02T00:04:05.000Z')).toBe('0185-01-02 09:04:05');
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'date' })).toBe('2026-08-14');
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'hourMinute' })).toBe('03:05');
});
it('preserves timezone-less legacy wall-clock values', () => {
expect(formatServerDateTime('0185-01-02 03:04:05')).toBe('0185-01-02 03:04:05');
expect(formatServerDateTime('0185-01-02T03:04:05', { format: 'monthDayTime' })).toBe('01-02 03:04');
expect(formatServerDateTime('2026-08-13 09:05:06', { format: 'minuteSecond' })).toBe('05:06');
});
it('offers explicit shapes and predictable fallbacks', () => {
const value = '2026-08-13T00:05:06.000Z';
expect(formatServerDateTime(value, { format: 'dateTimeMinutes' })).toBe('2026-08-13 09:05');
expect(formatServerDateTime(value, { format: 'timeSeconds' })).toBe('09:05:06');
expect(formatServerDateTime(value, { format: 'monthDayTimeSeconds' })).toBe('08-13 09:05:06');
expect(formatServerDateTime(undefined, { fallback: '-' })).toBe('-');
expect(formatServerDateTime('not-a-date')).toBe('not-a-date');
});
});
describe('server datetime-local conversion', () => {
it('does not depend on the browser or process timezone', () => {
expect(serverDateTimeInputToIso('2026-08-13T09:05')).toBe('2026-08-13T00:05:00.000Z');
expect(toServerDateTimeInputValue('2026-08-13T00:05:00.000Z')).toBe('2026-08-13T09:05');
});
it('rejects invalid local input', () => {
expect(serverDateTimeInputToIso('2026-02-30T09:05')).toBeUndefined();
expect(serverDateTimeInputToIso('')).toBeUndefined();
expect(toServerDateTimeInputValue('not-a-date')).toBe('');
});
});