Files
goldenChart/frontend/src/utils/timezone.ts
T
2026-06-10 09:57:38 +09:00

233 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 앱 전역 표시 시간대 (IANA).
* DB app-settings · 설정 화면 · 하단 바에서 동일 값 사용.
*/
import { useSyncExternalStore } from 'react';
import { TickMarkType, type Time } from 'lightweight-charts';
import type { Timeframe } from '../types';
import {
formatLwcTickMarkWithPattern,
formatLwcTimeWithPattern,
getChartTimeFormat,
} from './chartTimeFormat';
export const DEFAULT_DISPLAY_TIMEZONE = 'Asia/Seoul';
export interface TimezoneOption {
id: string;
label: string;
abbr: string;
}
export const TIMEZONE_OPTIONS: TimezoneOption[] = [
{ id: 'Asia/Seoul', label: 'Asia/Seoul (KST, UTC+9)', abbr: 'KST' },
{ id: 'UTC', label: 'UTC', abbr: 'UTC' },
{ id: 'Asia/Tokyo', label: 'Asia/Tokyo (JST, UTC+9)', abbr: 'JST' },
{ id: 'Asia/Shanghai', label: 'Asia/Shanghai (CST, UTC+8)', abbr: 'CST' },
{ id: 'Asia/Hong_Kong', label: 'Asia/Hong_Kong (HKT, UTC+8)', abbr: 'HKT' },
{ id: 'Asia/Singapore', label: 'Asia/Singapore (SGT, UTC+8)', abbr: 'SGT' },
{ id: 'Europe/London', label: 'Europe/London (GMT/BST)', abbr: 'GMT' },
{ id: 'Europe/Berlin', label: 'Europe/Berlin (CET)', abbr: 'CET' },
{ id: 'America/New_York', label: 'America/New_York (ET)', abbr: 'ET' },
{ id: 'America/Chicago', label: 'America/Chicago (CT)', abbr: 'CT' },
{ id: 'America/Los_Angeles',label: 'America/Los_Angeles (PT)', abbr: 'PT' },
];
let _tz = DEFAULT_DISPLAY_TIMEZONE;
const _listeners = new Set<() => void>();
export function getDisplayTimezone(): string {
return _tz;
}
export function setDisplayTimezone(tz: string): void {
const next = normalizeTimezone(tz);
if (_tz === next) return;
_tz = next;
_listeners.forEach(l => l());
}
export function subscribeDisplayTimezone(cb: () => void): () => void {
_listeners.add(cb);
return () => _listeners.delete(cb);
}
export function useDisplayTimezone(): string {
return useSyncExternalStore(subscribeDisplayTimezone, getDisplayTimezone, () => DEFAULT_DISPLAY_TIMEZONE);
}
export function normalizeTimezone(tz: string): string {
const t = (tz || '').trim();
if (!t) return DEFAULT_DISPLAY_TIMEZONE;
if (TIMEZONE_OPTIONS.some(o => o.id === t)) return t;
try {
Intl.DateTimeFormat(undefined, { timeZone: t });
return t;
} catch {
return DEFAULT_DISPLAY_TIMEZONE;
}
}
export function getTimezoneAbbr(tz: string): string {
const id = normalizeTimezone(tz);
return TIMEZONE_OPTIONS.find(o => o.id === id)?.abbr ?? id.split('/').pop() ?? 'TZ';
}
/**
* 주어진 시각의 표시 시간대 오프셋(초). 벽시계 정시 경계 정렬에 사용.
* (예: Asia/Seoul → +32400)
*/
export function getTimezoneOffsetSec(unixSec: number, tz?: string): number {
const zone = normalizeTimezone(tz ?? _tz);
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).formatToParts(new Date(unixSec * 1000));
const map: Record<string, string> = {};
for (const p of parts) {
if (p.type !== 'literal') map[p.type] = p.value;
}
let hour = Number(map.hour);
if (hour === 24) hour = 0;
const asUTC = Date.UTC(
Number(map.year), Number(map.month) - 1, Number(map.day),
hour, Number(map.minute), Number(map.second),
) / 1000;
return asUTC - unixSec;
}
/** 하단 바·시계 등 HH:mm:ss */
export function formatUnixClock(unixSec: number, tz?: string, withSeconds = true): string {
if (!unixSec) return '';
const zone = normalizeTimezone(tz ?? _tz);
const d = new Date(unixSec * 1000);
const opts: Intl.DateTimeFormatOptions = {
timeZone: zone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
};
if (withSeconds) opts.second = '2-digit';
return new Intl.DateTimeFormat('en-GB', opts).format(d);
}
/** 차트 축·범례용 */
export function formatUnixForChart(unixSec: number, timeframe?: Timeframe, tz?: string): string {
if (!unixSec) return '';
return formatLwcTimeWithPattern(unixSec, timeframe, tz ?? _tz, getChartTimeFormat());
}
/** LWC Time → unix 초 (없으면 null) */
export function lwcTimeToUnix(time: Time): number | null {
if (typeof time === 'number') return time;
if (time && typeof time === 'object' && 'year' in time) {
const t = time as { year: number; month: number; day: number };
return Math.floor(Date.UTC(t.year, t.month - 1, t.day) / 1000);
}
return null;
}
/** LWC Time → 문자열 (크로스헤어 등) */
export function formatLwcTime(
time: number | { year: number; month: number; day: number },
timeframe?: Timeframe,
tz?: string,
): string {
return formatLwcTimeWithPattern(time, timeframe, tz ?? _tz, getChartTimeFormat());
}
/** LWC X축 눈금 — 선택한 표시 시간대 기준 (기본 포맷터는 브라우저 로컬 TZ 사용) */
export function formatLwcTickMark(
time: Time,
tickMarkType: TickMarkType,
timeframe?: Timeframe,
tz?: string,
): string {
return formatLwcTickMarkWithPattern(
time,
tickMarkType,
timeframe,
tz ?? _tz,
getChartTimeFormat(),
);
}
/** 하단 바 표시: `06:36:00 KST` */
export function formatBottomBarTime(unixSec: number, tz?: string): string {
if (!unixSec) return ` ${getTimezoneAbbr(tz ?? _tz)}`;
const zone = normalizeTimezone(tz ?? _tz);
return `${formatUnixClock(unixSec, zone, true)} ${getTimezoneAbbr(zone)}`;
}
/** 알림·드로잉 등 날짜+시간 */
export function formatUnixDateTime(unixSec: number, tz?: string): string {
if (!unixSec) return '—';
const zone = normalizeTimezone(tz ?? _tz);
const d = new Date(unixSec * 1000);
const date = new Intl.DateTimeFormat('en-GB', {
timeZone: zone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(d);
return `${date} ${formatUnixClock(unixSec, zone, true)}`;
}
/** 현재 시각 (호가창 시계 등) */
export function formatNowClock(tz?: string, withSeconds = true): string {
const zone = normalizeTimezone(tz ?? _tz);
return formatUnixClock(Math.floor(Date.now() / 1000), zone, withSeconds);
}
/** 백엔드 LocalDateTime(JSON, 타임존 없음) 저장 기준 — Asia/Seoul 벽시계 */
export const BACKEND_NAIVE_TIMEZONE = 'Asia/Seoul';
/**
* 백엔드가 내려준 ISO 문자열(타임존 없음)을 Instant로 변환.
* Docker(UTC JVM)에서 저장된 과거 UTC 벽시계 값은 +00:00, 신규는 +09:00으로 해석.
*/
export function parseBackendNaiveDateTime(iso: string): Date | null {
const raw = (iso ?? '').trim();
if (!raw) return null;
if (/[Zz]$/.test(raw) || /[+-]\d{2}:?\d{2}$/.test(raw)) {
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
const withSec = /:\d{2}/.test(normalized.slice(11)) ? normalized : `${normalized}:00`;
const d = new Date(`${withSec}+09:00`);
if (!Number.isNaN(d.getTime())) return d;
const fallback = new Date(raw);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
/** 검증게시판·API LocalDateTime → 표시 시간대(기본 앱 설정) */
export function formatIsoDateTime(
iso?: string | null,
tz?: string,
style: 'full' | 'short' = 'full',
): string {
if (!iso) return style === 'full' ? '—' : '';
const d = parseBackendNaiveDateTime(iso);
if (!d) return iso;
const zone = normalizeTimezone(tz ?? _tz);
const opts: Intl.DateTimeFormatOptions = {
timeZone: zone,
hour12: false,
hour: '2-digit',
minute: '2-digit',
};
if (style === 'full') {
opts.year = 'numeric';
opts.month = '2-digit';
opts.day = '2-digit';
opts.second = '2-digit';
} else {
opts.month = '2-digit';
opts.day = '2-digit';
}
return new Intl.DateTimeFormat('ko-KR', opts).format(d);
}