forked from devsam/core
252 lines
7.9 KiB
TypeScript
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);
|
|
}
|
|
} |