Files
core/hwe/ts/varTurn60.ts
T

252 lines
7.9 KiB
TypeScript

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);
}
}