/** * 앱 전역 표시 시간대 (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); }