import { addSeconds, addDays, differenceInCalendarDays, } from 'date-fns'; import { toZonedTime, fromZonedTime, } from 'date-fns-tz'; import { formatTime } from './util/formatTime'; import { parse as parseDF, isValid } from 'date-fns'; export const SEOUL_TZ = 'Asia/Seoul'; // 문자열이 타임존/오프셋을 포함하는지 단순 판별 (ISO 형태 위주) const TZ_OFFSET_RE = /([zZ]|[+-]\d{2}:?\d{2})$/; function tryParseWithFormats(s: string): Date | null { const fmts = [ "yyyy-MM-dd'T'HH:mm:ss", // 2025-10-31T09:30:00 'yyyy-MM-dd HH:mm:ss', // 2025-10-31 09:30:00 "yyyy-MM-dd'T'HH:mm", // 2025-10-31T09:30 'yyyy-MM-dd HH:mm', // 2025-10-31 09:30 'yyyy-MM-dd', // 2025-10-31 ]; for (const fmt of fmts) { const d = parseDF(s, fmt, new Date(0)); if (isValid(d)) return d; } return null; } /** 문자열/Date/number 입력을 UTC Date로 정규화 * - 문자열에 오프셋/타임존이 없으면 Asia/Seoul 기준 “벽시계 시간”으로 간주 * - number는 epoch ms로 간주 (epoch s면 직접 *1000 해서 넘겨줘) */ export function toUTC(input: string | Date | number): Date { if (input instanceof Date) { return new Date(input.getTime()); // 이미 절대시간 } if (typeof input === 'number') { return new Date(input); // epoch ms } const s = input.trim(); // 오프셋/타임존이 명시된 ISO라면 기본 파서로 (절대시간 유지) if (TZ_OFFSET_RE.test(s)) { const d = new Date(s); if (isValid(d)) return d; } // 오프셋이 없는 문자열 → 서울 벽시계로 파싱해서 UTC로 변환 const parsedLocal = tryParseWithFormats(s) ?? new Date(s); if (!isValid(parsedLocal)) { throw new Error(`Unparsable date string: "${input}"`); } // parsedLocal는 "그 문자열 그대로의 Y/M/D h:m:s"를 들고 있음(타임존 無) // 이를 Asia/Seoul 로컬로 간주해 UTC로 변환 const utc = fromZonedTime(parsedLocal, SEOUL_TZ); return utc; } export class VarTurn60 { static readonly TZ = 'Asia/Seoul'; static readonly MonthAdjustOffset = 2; // [turnIdx, minOffset, turnTermMinutes] static readonly min30ToTurn: Array<[number, number, number]> = [ [0, 0, 120], [0, 30, 120], [0, 60, 120], [0, 90, 120], [1, 0, 120], [1, 30, 120], [1, 60, 120], [1, 90, 120], [2, 0, 120], [2, 30, 120], [2, 60, 120], [2, 90, 120], [3, 0, 60], [3, 30, 60], [4, 0, 60], [4, 30, 60], [5, 0, 60], [5, 30, 60], [6, 0, 60], [6, 30, 60], [7, 0, 60], [7, 30, 60], [8, 0, 60], [8, 30, 60], [9, 0, 60], [9, 30, 60], [10, 0, 60], [10, 30, 60], [11, 0, 60], [11, 30, 60], [12, 0, 60], [12, 30, 60], [13, 0, 60], [13, 30, 60], [14, 0, 60], [14, 30, 60], [15, 0, 60], [15, 30, 60], [16, 0, 30], [17, 0, 30], [18, 0, 30], [19, 0, 30], [20, 0, 30], [21, 0, 30], [22, 0, 60], [22, 30, 60], [23, 0, 60], [23, 30, 60], ]; // [hour, minOffset, turnTermMinutes] for turnIdx 0..23 static readonly turnToHM: Array<[number, number, number]> = [ [0, 0, 120], [2, 0, 120], [4, 0, 120], [6, 0, 60], [7, 0, 60], [8, 0, 60], [9, 0, 60], [10, 0, 60], [11, 0, 60], [12, 0, 60], [13, 0, 60], [14, 0, 60], [15, 0, 60], [16, 0, 60], [17, 0, 60], [18, 0, 60], [19, 0, 30], [19, 30, 30], [20, 0, 30], [20, 30, 30], [21, 0, 30], [21, 30, 30], [22, 0, 60], [23, 0, 60], ]; constructor( public readonly baseDayUTC: Date, // UTC 기준 하루의 00:00 (Seoul 기준) public readonly turnIdx: number, // 0..23 public readonly secOffset: number // seconds within the turn ) { } // ----- Utilities ----- /** date → 서울 자정 (UTC 기준 Date) */ private static toSeoulMidnightUTC(dateUTC: Date): Date { const zoned = toZonedTime(dateUTC, this.TZ); const midnightInSeoul = new Date( zoned.getFullYear(), zoned.getMonth(), zoned.getDate(), 0, 0, 0, 0 ); return fromZonedTime(midnightInSeoul, this.TZ); } private static format(dateUTC: Date, withFraction = true): string { const zoned = toZonedTime(dateUTC, this.TZ); return formatTime(zoned, withFraction); } // ----- Core Logic ----- static fromDatetime(input: string | Date | number): VarTurn60 { const utc = toUTC(input); const baseDayUTC = this.toSeoulMidnightUTC(utc); const zoned = toZonedTime(utc, this.TZ); const totalSec = zoned.getHours() * 3600 + zoned.getMinutes() * 60 + zoned.getSeconds() + zoned.getMilliseconds() / 1000; const min30 = Math.floor(totalSec / 1800); if (min30 < 0 || min30 >= 48) throw new RangeError('half-hour index out of range'); const [turnIdx, minOffset] = this.min30ToTurn[min30]; const secOffset = totalSec - 1800 * min30 + minOffset * 60; return new VarTurn60(baseDayUTC, turnIdx, secOffset); } static calcTurnDiff(a: string | Date | number, b: string | Date | number): number { const aObj = this.fromDatetime(a); const bObj = this.fromDatetime(b); const dayDiff = differenceInCalendarDays(bObj.baseDayUTC, aObj.baseDayUTC); return dayDiff * 24 + (bObj.turnIdx - aObj.turnIdx); } cutTurn(withFraction = true): [string, number] { const [hour, minOffset, turnTerm] = VarTurn60.turnToHM[this.turnIdx]; const sec = (hour * 60 + minOffset) * 60; const date = addSeconds(this.baseDayUTC, sec); return [VarTurn60.format(date, withFraction), turnTerm]; } toDate(): Date { const [hour, minOffset] = VarTurn60.turnToHM[this.turnIdx]; const sec = (hour * 60 + minOffset) * 60 + this.secOffset; const date = toZonedTime(addSeconds(this.baseDayUTC, sec), SEOUL_TZ); return date; } toDateStr(withFraction = true): string { const [hour, minOffset] = VarTurn60.turnToHM[this.turnIdx]; const sec = (hour * 60 + minOffset) * 60 + this.secOffset; const date = addSeconds(this.baseDayUTC, sec); return VarTurn60.format(date, withFraction); } cutDay(withFraction = true): [string, boolean, number] { const newMonth = ((this.turnIdx + VarTurn60.MonthAdjustOffset) % 12) + 1; const moved = this.addTurn(-(newMonth - 1)); const [dateStr] = moved.cutTurn(withFraction); const yearPulled = newMonth > 3; return [dateStr, yearPulled, newMonth]; } addTurn(moreTurn: number): VarTurn60 { let dayDiff = Math.trunc(moreTurn / 24); moreTurn %= 24; let nextTurnIdx = this.turnIdx + moreTurn; if (nextTurnIdx < 0) { dayDiff -= 1; nextTurnIdx += 24; } else if (nextTurnIdx >= 24) { dayDiff += 1; nextTurnIdx -= 24; } const [, , oldTurnTerm] = VarTurn60.turnToHM[this.turnIdx]; const [, , nextTurnTerm] = VarTurn60.turnToHM[nextTurnIdx]; let nextSecOffset = this.secOffset; if (oldTurnTerm !== nextTurnTerm) { nextSecOffset = (nextSecOffset * nextTurnTerm) / oldTurnTerm; const cap = nextTurnTerm * 60 - 1; if (nextSecOffset < 0) nextSecOffset = 0; if (nextSecOffset > cap) nextSecOffset = cap; } const nextBaseDayUTC = dayDiff === 0 ? this.baseDayUTC : addDays(this.baseDayUTC, dayDiff); return new VarTurn60(nextBaseDayUTC, nextTurnIdx, nextSecOffset); } }