208 lines
6.9 KiB
TypeScript
208 lines
6.9 KiB
TypeScript
/**
|
||
* 앱 전역 표시 시간대 (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';
|
||
}
|
||
|
||
/** 하단 바·시계 등 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);
|
||
}
|