refactor(xtream): split the server-clock primitives out of the timezone util

Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 23:40:00 +02:00
1 parent 0e3b44e2e8
commit cd364525fa
2 files changed
+224 -189

No files matched your search

@@ -0,0 +1,203 @@
/**
* Wall-clock primitives behind `xtream-server-timezone.util.ts`: the two
* stored timezone forms (an ICU-resolvable name, or a fixed `UTC±HH:MM`
* snapshot), and the conversions between an instant and the wall clock a
* zone shows for it. Nothing here knows about Xtream — that policy lives in
* the timezone util.
*/
/** Stored form for a panel whose timezone name is unusable: `UTC`, `UTC+03:00`, `UTC-03:30`. */
const FIXED_OFFSET_TIMEZONE_PATTERN = /^UTC(?:([+-])(\d{2}):(\d{2}))?$/;
const SERVER_LOCAL_DATE_TIME_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/;
export const MINUTE_MS = 60_000;
const timeZoneSupportCache = new Map<string, boolean>();
export interface WallClockParts {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
/**
* Whether the runtime can format dates in the named zone. `Intl` accepts
* IANA names and a few aliases (`UTC`, `GMT`, `Etc/GMT+3`) and throws a
* `RangeError` for anything else (`UTC+3`, an empty string, garbage).
*/
export function isSupportedTimeZoneName(name: string): boolean {
const cached = timeZoneSupportCache.get(name);
if (cached !== undefined) {
return cached;
}
let supported = false;
try {
new Intl.DateTimeFormat('en-US', { timeZone: name });
supported = true;
} catch {
supported = false;
}
timeZoneSupportCache.set(name, supported);
return supported;
}
/** Parses the stored `UTC±HH:MM` form; `null` for anything else. */
export function parseFixedOffsetTimeZone(value: string): number | null {
const match = FIXED_OFFSET_TIMEZONE_PATTERN.exec(value);
if (!match) {
return null;
}
if (!match[1]) {
return 0;
}
const minutes = Number(match[2]) * 60 + Number(match[3]);
return match[1] === '-' ? -minutes : minutes;
}
export function formatFixedOffsetTimeZone(offsetMinutes: number): string {
if (offsetMinutes === 0) {
return 'UTC';
}
const sign = offsetMinutes < 0 ? '-' : '+';
const absolute = Math.abs(offsetMinutes);
return `UTC${sign}${padTwo(Math.floor(absolute / 60))}:${padTwo(absolute % 60)}`;
}
/**
* The wall clock a zone shows for an instant. Accepts both stored forms;
* with no usable zone the viewer's local clock is the only remaining guess.
*/
export function wallClockPartsAt(
instantMs: number,
timeZone: string | null | undefined
): WallClockParts {
if (timeZone) {
const fixedOffset = parseFixedOffsetTimeZone(timeZone);
if (fixedOffset !== null) {
return utcParts(new Date(instantMs + fixedOffset * MINUTE_MS));
}
if (isSupportedTimeZoneName(timeZone)) {
return zoneParts(instantMs, timeZone);
}
}
const date = new Date(instantMs);
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
};
}
/** The named zone's UTC offset, in minutes, at a given instant. */
export function zoneOffsetMinutesAt(
instantMs: number,
timeZone: string
): number {
const parts = zoneParts(instantMs, timeZone);
const asUtcMs = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
);
return Math.round((asUtcMs - instantMs) / MINUTE_MS);
}
/**
* `YYYY-MM-DD HH:mm[:ss]` read as if it were UTC; `null` when malformed.
* `Date.UTC` rolls out-of-range fields over (`2026-13-01 25:00` becomes a
* real instant in 2027), so only a string that reads back unchanged counts.
*/
export function parseNaiveUtcMs(
value: string | null | undefined
): number | null {
const match = SERVER_LOCAL_DATE_TIME_PATTERN.exec(
String(value ?? '').trim()
);
if (!match) {
return null;
}
const [, year, month, day, hour, minute, second = '0'] = match;
const fields = [
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second),
];
const ms = Date.UTC(
fields[0],
fields[1],
fields[2],
fields[3],
fields[4],
fields[5]
);
if (!Number.isFinite(ms)) {
return null;
}
const readBack = utcParts(new Date(ms));
const unchanged = [
readBack.year,
readBack.month - 1,
readBack.day,
readBack.hour,
readBack.minute,
readBack.second,
].every((field, index) => field === fields[index]);
return unchanged ? ms : null;
}
export function padTwo(value: number): string {
return String(value).padStart(2, '0');
}
function zoneParts(instantMs: number, timeZone: string): WallClockParts {
// `hourCycle: 'h23'` — the `hour12: false` spelling still yields "24"
// for midnight in some ICU/locale combinations. The option is typed
// from es2020.intl while the web app compiles against an es2018 lib,
// hence the assertion; every supported runtime honours it.
const options = {
timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hourCycle: 'h23',
} as Intl.DateTimeFormatOptions;
const parts = new Intl.DateTimeFormat('en-US', options).formatToParts(
new Date(instantMs)
);
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((part) => part.type === type)?.value ?? 0);
return {
year: read('year'),
month: read('month'),
day: read('day'),
hour: read('hour') % 24,
minute: read('minute'),
second: read('second'),
};
}
function utcParts(date: Date): WallClockParts {
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: date.getUTCHours(),
minute: date.getUTCMinutes(),
second: date.getUTCSeconds(),
};
}
@@ -1,5 +1,5 @@
/**
* Xtream server clock helpers.
* Xtream server clock policy.
*
* An Xtream panel interprets every wall-clock string it exchanges with a
* client in ITS OWN timezone: the `start` / `end` strings of
@@ -15,69 +15,37 @@
* conversion helpers below accept either form, so a panel whose timezone
* name the runtime cannot resolve (`UTC+3`, a typo, an unknown alias) still
* gets its catch-up requests and EPG strings converted correctly instead of
* silently falling back to the viewer's local clock (issue #1562).
* silently falling back to the viewer's local clock (issue #1562). The
* zone-agnostic primitives live in `xtream-server-clock.util.ts`.
*/
import {
formatFixedOffsetTimeZone,
isSupportedTimeZoneName,
MINUTE_MS,
padTwo,
parseFixedOffsetTimeZone,
parseNaiveUtcMs,
wallClockPartsAt,
zoneOffsetMinutesAt,
} from './xtream-server-clock.util';
export {
formatFixedOffsetTimeZone,
isSupportedTimeZoneName,
parseFixedOffsetTimeZone,
} from './xtream-server-clock.util';
export interface XtreamServerClockInfo {
timezone?: string | null;
time_now?: string | null;
timestamp_now?: number | string | null;
}
/** Stored form for a panel whose timezone name is unusable: `UTC`, `UTC+03:00`, `UTC-03:30`. */
const FIXED_OFFSET_TIMEZONE_PATTERN = /^UTC(?:([+-])(\d{2}):(\d{2}))?$/;
const SERVER_LOCAL_DATE_TIME_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/;
/** Real-world UTC offsets span -12:00 … +14:00. */
const MAX_UTC_OFFSET_MINUTES = 14 * 60;
/** Offsets are multiples of 15 min; snapping absorbs second-level clock skew. */
const OFFSET_GRANULARITY_MINUTES = 15;
const MINUTE_MS = 60_000;
const timeZoneSupportCache = new Map<string, boolean>();
/**
* Whether the runtime can format dates in the named zone. `Intl` accepts
* IANA names and a few aliases (`UTC`, `GMT`, `Etc/GMT+3`) and throws a
* `RangeError` for anything else (`UTC+3`, an empty string, garbage).
*/
export function isSupportedTimeZoneName(name: string): boolean {
const cached = timeZoneSupportCache.get(name);
if (cached !== undefined) {
return cached;
}
let supported = false;
try {
new Intl.DateTimeFormat('en-US', { timeZone: name });
supported = true;
} catch {
supported = false;
}
timeZoneSupportCache.set(name, supported);
return supported;
}
/** Parses the stored `UTC±HH:MM` form; `null` for anything else. */
export function parseFixedOffsetTimeZone(value: string): number | null {
const match = FIXED_OFFSET_TIMEZONE_PATTERN.exec(value);
if (!match) {
return null;
}
if (!match[1]) {
return 0;
}
const minutes = Number(match[2]) * 60 + Number(match[3]);
return match[1] === '-' ? -minutes : minutes;
}
export function formatFixedOffsetTimeZone(offsetMinutes: number): string {
if (offsetMinutes === 0) {
return 'UTC';
}
const sign = offsetMinutes < 0 ? '-' : '+';
const absolute = Math.abs(offsetMinutes);
return `UTC${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
}
/**
* The server's UTC offset in minutes derived from its own clock report:
@@ -145,7 +113,7 @@ export function formatXtreamCatchupStart(
serverTimezone: string | null | undefined
): string {
const parts = wallClockPartsAt(timestampSeconds * 1000, serverTimezone);
return `${parts.year}-${pad(parts.month)}-${pad(parts.day)}:${pad(parts.hour)}-${pad(parts.minute)}`;
return `${parts.year}-${padTwo(parts.month)}-${padTwo(parts.day)}:${padTwo(parts.hour)}-${padTwo(parts.minute)}`;
}
/**
@@ -180,139 +148,3 @@ export function parseXtreamServerLocalDateTime(
zoneOffsetMinutesAt(firstGuessMs, serverTimezone) * MINUTE_MS;
return Math.floor(resolvedMs / 1000);
}
interface WallClockParts {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
}
function wallClockPartsAt(
instantMs: number,
serverTimezone: string | null | undefined
): WallClockParts {
if (serverTimezone) {
const fixedOffset = parseFixedOffsetTimeZone(serverTimezone);
if (fixedOffset !== null) {
return utcParts(new Date(instantMs + fixedOffset * MINUTE_MS));
}
if (isSupportedTimeZoneName(serverTimezone)) {
return zoneParts(instantMs, serverTimezone);
}
}
const date = new Date(instantMs);
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hour: date.getHours(),
minute: date.getMinutes(),
second: date.getSeconds(),
};
}
function zoneParts(instantMs: number, timeZone: string): WallClockParts {
// `hourCycle: 'h23'` — the `hour12: false` spelling still yields "24"
// for midnight in some ICU/locale combinations. The option is typed
// from es2020.intl while the web app compiles against an es2018 lib,
// hence the assertion; every supported runtime honours it.
const options = {
timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hourCycle: 'h23',
} as Intl.DateTimeFormatOptions;
const parts = new Intl.DateTimeFormat('en-US', options).formatToParts(
new Date(instantMs)
);
const read = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((part) => part.type === type)?.value ?? 0);
return {
year: read('year'),
month: read('month'),
day: read('day'),
hour: read('hour') % 24,
minute: read('minute'),
second: read('second'),
};
}
function zoneOffsetMinutesAt(instantMs: number, timeZone: string): number {
const parts = zoneParts(instantMs, timeZone);
const asUtcMs = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second
);
return Math.round((asUtcMs - instantMs) / MINUTE_MS);
}
function utcParts(date: Date): WallClockParts {
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: date.getUTCHours(),
minute: date.getUTCMinutes(),
second: date.getUTCSeconds(),
};
}
/** `YYYY-MM-DD HH:mm[:ss]` read as if it were UTC; `null` when malformed. */
function parseNaiveUtcMs(value: string | null | undefined): number | null {
const match = SERVER_LOCAL_DATE_TIME_PATTERN.exec(
String(value ?? '').trim()
);
if (!match) {
return null;
}
const [, year, month, day, hour, minute, second = '0'] = match;
const fields = [
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second),
];
const ms = Date.UTC(
fields[0],
fields[1],
fields[2],
fields[3],
fields[4],
fields[5]
);
if (!Number.isFinite(ms)) {
return null;
}
// `Date.UTC` rolls out-of-range fields over (`2026-13-01 25:00` becomes
// a real instant in 2027); only a string that reads back unchanged is a
// date the panel actually wrote.
const date = new Date(ms);
const readBack = [
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
];
return fields.every((field, index) => field === readBack[index])
? ms
: null;
}
function pad(value: number): string {
return String(value).padStart(2, '0');
}