fix(backend): 계절별 영업시간을 날짜의 달로 골라 영업 중 표시를 바로잡음 #10

Merged
ttipo merged 1 commits from fix/seasonal-hours into main 2026-11-06 09:05:02 +09:00
8 changed files with 858 additions and 187 deletions
Showing only changes of commit f752bf28f5 - Show all commits
+2 -2
View File
@@ -219,7 +219,7 @@ export class PlaceService {
private toListItem(
v: PlaceListing,
distance: number | null,
clock: { weekday: number; minutes: number },
clock: { date: string; minutes: number },
): PlaceListItemDto {
return {
id: v.id,
@@ -237,7 +237,7 @@ export class PlaceService {
imageThumbUrl: v.imageThumbUrl,
imageLicense: v.imageLicense,
hoursOrigin: v.hoursOrigin,
openNow: v.hours ? isOpenAt(v.hours, clock.weekday, clock.minutes) : null,
openNow: v.hours ? isOpenAt(v.hours, clock.date, clock.minutes) : null,
petPolicy: v.petPolicy,
petOrigin: v.petOrigin,
setting: v.setting,
+2 -2
View File
@@ -43,10 +43,10 @@ describe('parseKstTimestamp', () => {
});
describe('kstClock', () => {
it('한국 시간의 요일과 자정 이후 분을 돌려준다', () => {
it('한국 시간의 날짜와 자정 이후 분을 돌려준다', () => {
// 2026-11-01(일) 15:30 UTC = 2026-11-02(월) 00:30 KST
expect(kstClock(new Date('2026-11-01T15:30:00Z'))).toEqual({
weekday: 1,
date: '2026-11-02',
minutes: 30,
});
});
+3 -3
View File
@@ -35,11 +35,11 @@ export function parseKstTimestamp(value: unknown): Date | null {
return back.getUTCDate() === d ? date : null;
}
/** 한국 시간 기준 요일(0=일요일 … 6=토요일)과 자정 이후 분 — "지금 영업 중" 판단에 쓴다 */
export function kstClock(now: Date): { weekday: number; minutes: number } {
/** 한국 시간 기준 날짜('YYYY-MM-DD')와 자정 이후 분 — "지금 영업 중" 판단에 쓴다(계절별 시간표는 날짜의 달로 고른다) */
export function kstClock(now: Date): { date: string; minutes: number } {
const kst = new Date(now.getTime() + KST_OFFSET_MS);
return {
weekday: kst.getUTCDay(),
date: kst.toISOString().slice(0, 10),
minutes: kst.getUTCHours() * 60 + kst.getUTCMinutes(),
};
}
+273 -52
View File
@@ -1,4 +1,8 @@
import { isOpenAt, parseOpeningHours } from './opening-hours';
import {
isOpenAt,
parseOpeningHours,
type ParsedOpeningHours,
} from './opening-hours';
// 요일 번호: 0=일요일 … 6=토요일
const SUN = 0;
@@ -9,6 +13,12 @@ const THU = 4;
const FRI = 5;
const SAT = 6;
const at = (h: number, m = 0) => h * 60 + m;
/** 2026년 그 달(기본 11월)의 첫 번째 그 요일 — 'YYYY-MM-DD' */
const on = (weekday: number, month = 11): string => {
const firstWeekday = new Date(Date.UTC(2026, month - 1, 1)).getUTCDay();
const day = 1 + ((weekday - firstWeekday + 7) % 7);
return `2026-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
};
// 사례는 2026-10-29 공공데이터 점검(서울·대구)에서 실제로 받은 영업시간·쉬는날 문장이다
describe('parseOpeningHours', () => {
@@ -27,8 +37,8 @@ describe('parseOpeningHours', () => {
'연중무휴',
);
expect(p.breaks).toEqual([{ start: at(15), end: at(17) }]);
expect(isOpenAt(p, WED, at(16))).toBe(false);
expect(isOpenAt(p, WED, at(14))).toBe(true);
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
});
it('괄호 안의 준비 시간과 마지막 주문을 읽는다', () => {
@@ -39,7 +49,7 @@ describe('parseOpeningHours', () => {
expect(a.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
const b = parseOpeningHours('10:00~21:30 (마지막 주문 21:00)', '연중무휴');
expect(b.lastOrder).toBe(at(21));
expect(isOpenAt(b, FRI, at(21, 15))).toBe(true);
expect(isOpenAt(b, on(FRI), at(21, 15))).toBe(true);
});
it('평일에만 있는 준비시간은 주말에 적용하지 않는다', () => {
@@ -49,8 +59,8 @@ describe('parseOpeningHours', () => {
);
expect(p.breaksWeekdaysOnly).toBe(true);
expect(p.lastOrder).toBe(at(20, 30));
expect(isOpenAt(p, WED, at(16))).toBe(false);
expect(isOpenAt(p, SAT, at(16))).toBe(true);
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
expect(isOpenAt(p, on(SAT), at(16))).toBe(true);
expect(p.holidayClosures).toEqual(
expect.arrayContaining(['설', '추석', '1월 1일']),
);
@@ -63,57 +73,65 @@ describe('parseOpeningHours', () => {
);
expect(p.closedWeekdays).toEqual([SUN]);
expect(p.week[SUN]).toEqual([]);
expect(isOpenAt(p, SUN, at(12))).toBe(false);
expect(isOpenAt(p, on(SUN), at(12))).toBe(false);
expect(p.notes).toContain('재료 소진 시 조기 마감');
});
it('평일·주말 시간이 다르면 요일별로 나눈다', () => {
const p = parseOpeningHours('평일 11:00~22:00 / 주말 10:00~22:00', '');
expect(isOpenAt(p, SAT, at(10, 30))).toBe(true);
expect(isOpenAt(p, MON, at(10, 30))).toBe(false);
expect(isOpenAt(p, on(SAT), at(10, 30))).toBe(true);
expect(isOpenAt(p, on(MON), at(10, 30))).toBe(false);
});
it('요일 범위(화~일)와 월요일 휴관을 읽는다', () => {
const p = parseOpeningHours('화~일 10:00~18:00 (월요일 휴관)', '');
expect(p.closedWeekdays).toEqual([MON]);
expect(isOpenAt(p, SUN, at(17))).toBe(true);
expect(isOpenAt(p, MON, at(12))).toBe(false);
expect(isOpenAt(p, on(SUN), at(17))).toBe(true);
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
});
it('자정을 넘기는 영업은 다음 날 새벽까지 이어진다', () => {
const p = parseOpeningHours('18:00~02:00', '매주 월요일');
expect(p.week[SUN]).toEqual([{ start: at(18), end: at(26) }]);
// 일요일 밤에 열어 월요일 새벽 1시까지 — 월요일이 휴무여도 전날 영업은 이어진다
expect(isOpenAt(p, MON, at(1))).toBe(true);
expect(isOpenAt(p, MON, at(19))).toBe(false);
expect(isOpenAt(p, on(MON), at(1))).toBe(true);
expect(isOpenAt(p, on(MON), at(19))).toBe(false);
});
it('상시 개방과 24시간은 항상 연 것으로 본다', () => {
expect(parseOpeningHours('상시 개방').status).toBe('always_open');
const p = parseOpeningHours('24시간', '연중무휴');
expect(p.status).toBe('always_open');
expect(isOpenAt(p, WED, at(3))).toBe(true);
expect(isOpenAt(p, on(WED), at(3))).toBe(true);
});
it('점포별 상이는 정할 수 없다고 표시한다(지어내지 않는다)', () => {
expect(parseOpeningHours('점포별 상이').status).toBe('varies');
const p = parseOpeningHours('※ 점포별 상이하므로 홈페이지 참고');
expect(p.status).toBe('varies');
expect(isOpenAt(p, SAT, at(12))).toBeNull();
expect(isOpenAt(p, on(SAT), at(12))).toBeNull();
});
it('값이 없으면 미확인으로 둔다', () => {
expect(parseOpeningHours(null).status).toBe('unknown');
expect(parseOpeningHours('').status).toBe('unknown');
expect(isOpenAt(parseOpeningHours(undefined), SAT, at(12))).toBeNull();
expect(isOpenAt(parseOpeningHours(undefined), on(SAT), at(12))).toBeNull();
});
it('계절마다 다른 시간은 어느 계절인지 몰라 시각 판단을 하지 않는다', () => {
it('달을 적지 않은 하절기·동절기는 확실한 달에 그 계절 시간을 쓰고, 3·10·11월에는 두 시간이 같을 때만 답한다', () => {
const p = parseOpeningHours(
'하절기 화~일 10:00~17:00 / 동절기 화~일 11:00~16:00',
);
expect(p.seasonal).toBe(true);
expect(isOpenAt(p, WED, at(12))).toBeNull();
expect(p.seasons).toHaveLength(2);
expect(isOpenAt(p, on(WED, 7), at(10, 30))).toBe(true);
expect(isOpenAt(p, on(WED, 1), at(10, 30))).toBe(false);
// 11월은 어느 계절인지 기관마다 다르다 — 두 계절 모두 여는 시각만 연다고 답한다
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
expect(isOpenAt(p, on(WED), at(10, 30))).toBeNull();
expect(isOpenAt(p, on(WED), at(18))).toBe(false);
// 월요일은 어느 계절에도 시간이 없다
expect(isOpenAt(p, on(MON, 7), at(12))).toBeNull();
});
it('줄바꿈 태그와 안내 문구를 정리한다', () => {
@@ -142,14 +160,14 @@ describe('parseOpeningHours', () => {
);
expect(p.week[MON]).toEqual([{ start: at(10, 30), end: at(20) }]);
expect(p.week[SAT]).toEqual([{ start: at(10, 30), end: at(20, 30) }]);
expect(isOpenAt(p, MON, at(20, 15))).toBe(false);
expect(isOpenAt(p, SAT, at(20, 15))).toBe(true);
expect(isOpenAt(p, on(MON), at(20, 15))).toBe(false);
expect(isOpenAt(p, on(SAT), at(20, 15))).toBe(true);
});
it('"평일 …주말 …" 처럼 붙은 구간도 나눈다', () => {
const p = parseOpeningHours('평일 09:00~18:00주말 09:00~17:00');
expect(isOpenAt(p, MON, at(17, 30))).toBe(true);
expect(isOpenAt(p, SAT, at(17, 30))).toBe(false);
expect(isOpenAt(p, on(MON), at(17, 30))).toBe(true);
expect(isOpenAt(p, on(SAT), at(17, 30))).toBe(false);
});
it('줄바꿈은 구간 구분으로 본다', () => {
@@ -163,8 +181,8 @@ describe('parseOpeningHours', () => {
it('줄을 바꿔 쓴 괄호 안 준비 시간도 준비 시간으로 읽는다', () => {
const p = parseOpeningHours('11:00~21:00\n(준비 시간 15:30~17:00)');
expect(p.breaks).toEqual([{ start: at(15, 30), end: at(17) }]);
expect(isOpenAt(p, WED, at(16))).toBe(false);
expect(isOpenAt(p, WED, at(12))).toBe(true);
expect(isOpenAt(p, on(WED), at(16))).toBe(false);
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
});
it('대괄호 요일 머리말은 다음 줄들의 시간에 적용한다(곳 카페)', () => {
@@ -191,15 +209,18 @@ describe('parseOpeningHours', () => {
'연중무휴',
);
expect(p.breaksWeekdaysOnly).toBe(true);
expect(isOpenAt(p, WED, at(15))).toBe(false);
expect(isOpenAt(p, SAT, at(15))).toBe(true);
expect(isOpenAt(p, on(WED), at(15))).toBe(false);
expect(isOpenAt(p, on(SAT), at(15))).toBe(true);
expect(p.lastOrder).toBe(at(21, 40));
});
it('달 머리말("[1월]")은 월요일로 읽지 않고, 달마다 다르면 시각 판단을 하지 않는다', () => {
it('달 머리말("[1월]")은 월요일로 읽지 않고 그 달의 시간표로 둔다 — 적히지 않은 달은 판단하지 않는다', () => {
const p = parseOpeningHours('[1월]\n- 10:30~18:30\n[2월]\n- 10:30~19:00');
expect(p.seasonal).toBe(true);
expect(isOpenAt(p, MON, at(12))).toBeNull();
expect(isOpenAt(p, on(WED, 1), at(12))).toBe(true);
expect(isOpenAt(p, on(WED, 1), at(18, 45))).toBe(false);
expect(isOpenAt(p, on(WED, 2), at(18, 45))).toBe(true);
expect(isOpenAt(p, on(WED, 3), at(12))).toBeNull();
});
it('하이픈으로 쓴 시간 범위는 나누지 않는다', () => {
@@ -222,8 +243,8 @@ describe('parseOpeningHours', () => {
expect(p.week[WED]).toEqual([{ start: at(11), end: at(21) }]);
expect(p.breaks).toEqual([{ start: at(16), end: at(17) }]);
expect(p.lastOrder).toBe(at(20, 20));
expect(isOpenAt(p, WED, at(12, 30))).toBe(true);
expect(isOpenAt(p, WED, at(16, 30))).toBe(false);
expect(isOpenAt(p, on(WED), at(12, 30))).toBe(true);
expect(isOpenAt(p, on(WED), at(16, 30))).toBe(false);
});
it('붙은 글머리표 뒤의 마지막 주문을 여는 시각으로 읽지 않는다(서민갈비)', () => {
@@ -259,17 +280,17 @@ describe('parseOpeningHours', () => {
{ start: at(5), end: at(20) },
{ start: at(19), end: at(23, 30) },
]);
expect(isOpenAt(p, SUN, at(12))).toBe(true);
expect(isOpenAt(p, SAT, at(22))).toBe(true);
expect(isOpenAt(p, on(SUN), at(12))).toBe(true);
expect(isOpenAt(p, on(SAT), at(22))).toBe(true);
});
it('시설마다 평일·주말 시간이 다르면 어느 시설이든 여는 시간을 영업으로 본다(스파밸리)', () => {
const p = parseOpeningHours(
'[실내]- 09:00~18:00[야외]- 평일 09:00~17:00- 주말 09:30~17:30',
);
expect(isOpenAt(p, WED, at(17, 45))).toBe(true);
expect(isOpenAt(p, SAT, at(9, 15))).toBe(true);
expect(isOpenAt(p, SAT, at(18, 30))).toBe(false);
expect(isOpenAt(p, on(WED), at(17, 45))).toBe(true);
expect(isOpenAt(p, on(SAT), at(9, 15))).toBe(true);
expect(isOpenAt(p, on(SAT), at(18, 30))).toBe(false);
});
it('요일 사이의 / 는 구간 구분이 아니라 나열이다(영생곱창)', () => {
@@ -289,14 +310,14 @@ describe('parseOpeningHours', () => {
expect(p.week[WED]).toEqual([{ start: at(9), end: at(20) }]);
expect(p.week[SAT]).toEqual([{ start: at(9), end: at(18) }]);
expect(p.week[MON]).toBeNull();
expect(isOpenAt(p, WED, at(12, 30))).toBe(false);
expect(isOpenAt(p, WED, at(14))).toBe(true);
expect(isOpenAt(p, on(WED), at(12, 30))).toBe(false);
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
});
it('2시간 이하의 점심시간은 쉬는 시간, 그보다 길면 점심 영업으로 읽는다', () => {
const office = parseOpeningHours('09:00~18:00 (점심시간 13:00~14:00)');
expect(office.breaks).toEqual([{ start: at(13), end: at(14) }]);
expect(isOpenAt(office, WED, at(13, 30))).toBe(false);
expect(isOpenAt(office, on(WED), at(13, 30))).toBe(false);
const restaurant = parseOpeningHours(
'점심시간 11:30~14:30 / 저녁시간 17:00~21:00',
);
@@ -313,14 +334,14 @@ describe('parseOpeningHours', () => {
);
expect(p.breaks).toEqual([]);
expect(p.week[WED]).toEqual([{ start: at(10), end: at(17) }]);
expect(isOpenAt(p, WED, at(14))).toBe(true);
expect(isOpenAt(p, on(WED), at(14))).toBe(true);
});
// 2026-11-05 관광공사 대구 상세 점검 — 쉬는날을 잘못 읽어 영업 중 표시가 틀린 17곳
it('쉬는날의 요일 범위는 가운데 요일까지 휴무로 본다(북성로공구빵·엘리몽·대구 북구문화원)', () => {
const range = parseOpeningHours('10:00~20:00', '매주 월요일~목요일');
expect(range.closedWeekdays).toEqual([MON, TUE, WED, THU]);
expect(isOpenAt(range, TUE, at(12))).toBe(false);
expect(isOpenAt(range, on(TUE), at(12))).toBe(false);
const weekdays = parseOpeningHours('10:00~18:00', '매주 평일(월~금)');
expect(weekdays.closedWeekdays).toEqual([MON, TUE, WED, THU, FRI]);
const overSunday = parseOpeningHours(
@@ -336,8 +357,8 @@ describe('parseOpeningHours', () => {
'주말 / 공휴일',
);
expect(p.closedWeekdays).toEqual([SUN, SAT]);
expect(isOpenAt(p, SAT, at(10))).toBe(false);
expect(isOpenAt(p, WED, at(10))).toBe(true);
expect(isOpenAt(p, on(SAT), at(10))).toBe(false);
expect(isOpenAt(p, on(WED), at(10))).toBe(true);
expect(parseOpeningHours('10:30~20:30', '주말').closedWeekdays).toEqual([
SUN,
SAT,
@@ -350,8 +371,8 @@ describe('parseOpeningHours', () => {
'매주 월요일 (단, 월요일이 공휴일 때에는 공휴일 다음의 첫번째 평일) / 1월 1일 / 설날·추석 당일',
);
expect(museum.closedWeekdays).toEqual([MON]);
expect(isOpenAt(museum, MON, at(12))).toBe(false);
expect(isOpenAt(museum, TUE, at(12))).toBe(true);
expect(isOpenAt(museum, on(MON), at(12))).toBe(false);
expect(isOpenAt(museum, on(TUE), at(12))).toBe(true);
const village = parseOpeningHours(
'09:00~18:00',
'매주 월요일 / 1월 1일 / 설날·추석 연휴일 다음의 첫 번째 평일',
@@ -404,7 +425,7 @@ describe('parseOpeningHours', () => {
);
expect(p.closedWeekdays).toEqual([SUN]);
expect(p.notes[0]).toContain('마지막주 월요일');
expect(isOpenAt(p, MON, at(12))).toBe(true);
expect(isOpenAt(p, on(MON), at(12))).toBe(true);
// '정기휴무' 같은 말머리는 시설 이름이 아니다
const labeled = parseOpeningHours(
'10:00~20:00',
@@ -420,7 +441,7 @@ describe('parseOpeningHours', () => {
);
expect(p.closedWeekdays).toEqual([]);
expect(p.notes[0]).toContain('첫째주');
expect(isOpenAt(p, TUE, at(12))).toBe(true);
expect(isOpenAt(p, on(TUE), at(12))).toBe(true);
});
it('괄호 안의 주기는 앞 구절을 꾸민다 — "매주 월요일 (매월 2주, 4주)" 는 매주 휴무가 아니다(대구광역시립남부도서관)', () => {
@@ -449,28 +470,228 @@ describe('parseOpeningHours', () => {
'[북카페]- 일요일[북키즈 / 다목적홀]- 주말',
);
expect(p.closedWeekdays).toEqual([SUN]);
expect(isOpenAt(p, SAT, at(12))).toBe(true);
expect(isOpenAt(p, on(SAT), at(12))).toBe(true);
});
it('시기마다 쉬는날이 다르면 어느 시기인지 몰라 시각 판단을 하지 않는다(대구창의융합교육원)', () => {
it('시기마다 쉬는날이 다르면 날짜의 달로 그 시기의 휴무 요일을 고른다(대구창의융합교육원)', () => {
const p = parseOpeningHours(
'09:00~18:00',
'[3월~7월 / 9월~12월] - 월요일 / 공휴일[1월 / 2월 / 8월] - 일요일 / 공휴일',
);
expect(p.seasonal).toBe(true);
expect(p.closedWeekdays).toEqual([SUN, MON]);
expect(isOpenAt(p, MON, at(12))).toBeNull();
expect(p.closedWeekdays).toEqual([]);
expect(p.restSeasons.map((r) => r.closedWeekdays)).toEqual([[MON], [SUN]]);
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
expect(isOpenAt(p, on(SUN), at(12))).toBe(true);
expect(isOpenAt(p, on(MON, 8), at(12))).toBe(true);
expect(isOpenAt(p, on(SUN, 8), at(12))).toBe(false);
});
it('영업시간 문장 속 휴무 구절은 같은 구절의 여는 요일 범위를 휴무로 읽지 않는다', () => {
const p = parseOpeningHours('월~금 10:00~18:00 토요일 휴무', '');
expect(p.closedWeekdays).toEqual([SAT]);
expect(isOpenAt(p, WED, at(12))).toBe(true);
expect(isOpenAt(p, on(WED), at(12))).toBe(true);
// 괄호 안 조건 속 요일도 휴무로 읽지 않는다
const q = parseOpeningHours(
'화~일 10:00~18:00 (월요일 휴관, 단 월요일이 공휴일이면 화요일 휴관)',
);
expect(q.closedWeekdays).toEqual([MON]);
expect(isOpenAt(q, TUE, at(12))).toBe(true);
expect(isOpenAt(q, on(TUE), at(12))).toBe(true);
});
// 2026-11-06 관광공사 대구 원문 — 계절별 시간표 32곳(그때까지 영업시간 미확인 23곳, 1년 내내 같은 시간으로 읽힌 9곳)
describe('계절별 시간표', () => {
it('달을 적은 계절 머리말은 그 달에 그 계절 시간을 쓴다(대구미술관)', () => {
const p = parseOpeningHours(
'[하절기(4월~10월)]- 10:00~19:00- 입장 마감 18:00[동절기(11월~3월)]- 10:00~18:00- 입장 마감 17:00',
'매주 월요일 (단, 월요일이 공휴일인 경우 그 다음 평일 휴관) / 1월 1일 / 설·추석',
);
expect(p.seasons.map((s) => s.months)).toEqual([
[4, 5, 6, 7, 8, 9, 10],
[1, 2, 3, 11, 12],
]);
expect(isOpenAt(p, on(WED), at(18, 30))).toBe(false);
expect(isOpenAt(p, on(WED, 6), at(18, 30))).toBe(true);
expect(isOpenAt(p, on(MON), at(12))).toBe(false);
expect(p.notes).toContain('입장 마감 17:00');
});
it('시각 바로 뒤에 붙은 계절 머리말을 나눠 읽는다(수창청춘맨숀·대구문화예술회관)', () => {
const undated = parseOpeningHours(
'하절기 10:00~19:00동절기 10:00~18:00',
'매주 월요일 / 1월 1일 / 설날·추석 당일',
);
expect(isOpenAt(undated, on(WED), at(14))).toBe(true);
expect(isOpenAt(undated, on(WED), at(18, 30))).toBeNull();
expect(isOpenAt(undated, on(WED, 1), at(18, 30))).toBe(false);
expect(isOpenAt(undated, on(WED, 7), at(18, 30))).toBe(true);
const dated = parseOpeningHours(
'하절기(4월1일~10월31일) 10:00~19:00동절기(11월1일~3월31일) 10:00~18:00',
);
expect(isOpenAt(dated, on(WED), at(18, 30))).toBe(false);
expect(isOpenAt(dated, on(WED, 10), at(18, 30))).toBe(true);
});
it('달 중간에 바뀌는 계절은 그 달을 확실하지 않은 달로 둔다', () => {
const p = parseOpeningHours(
'하절기(3월 15일~10월) 09:00~18:00 / 동절기(11월~3월 14일) 09:00~17:00',
);
expect(p.seasons[0].sureMonths).not.toContain(3);
expect(isOpenAt(p, on(WED, 3), at(17, 30))).toBeNull();
expect(isOpenAt(p, on(WED, 3), at(12))).toBe(true);
});
it('"4~10월"·글머리표 뒤의 달 머리말도 계절로 읽는다(향촌문화관·달성 한일우호관·화본마을)', () => {
const short = parseOpeningHours(
'- 하절기(4~10월) 09:00~19:00- 동절기(11~3월) 09:00~18:00※ 30분 전까지 입장 가능',
);
expect(isOpenAt(short, on(WED, 3), at(18, 30))).toBe(false);
expect(isOpenAt(short, on(WED, 4), at(18, 30))).toBe(true);
const months = parseOpeningHours(
'- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00',
);
expect(isOpenAt(months, on(WED), at(17, 30))).toBe(false);
expect(isOpenAt(months, on(WED, 10), at(17, 30))).toBe(true);
const museum = parseOpeningHours(
'[박물관]- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00',
);
expect(isOpenAt(museum, on(WED), at(17, 30))).toBe(false);
expect(isOpenAt(museum, on(WED, 3), at(17, 30))).toBe(true);
});
it('대괄호 안에 달을 나열한 머리말도 계절로 읽는다(대구수목원)', () => {
const p = parseOpeningHours(
'[1월~4월/9월~12월]09:00~18:00[5월~8월]08:00~19:00',
'매주 월요일',
);
expect(isOpenAt(p, on(WED), at(8, 30))).toBe(false);
expect(isOpenAt(p, on(WED, 7), at(8, 30))).toBe(true);
expect(isOpenAt(p, on(MON, 7), at(12))).toBe(false);
});
it('머리말 없는 기본 시간은 계절 시간이 없는 달과 요일에 쓴다(숨쉴꽃 허브 체험농장)', () => {
const p = parseOpeningHours(
'- 10:00~17:00[하절기(6~8월)]- 10:00~13:00',
'매주 토요일',
);
expect(isOpenAt(p, on(WED, 7), at(15))).toBe(false);
expect(isOpenAt(p, on(WED), at(15))).toBe(true);
expect(isOpenAt(p, on(SAT), at(12))).toBe(false);
const weekendOnly = parseOpeningHours(
'09:00~18:00 / 하절기 주말 09:00~20:00',
);
expect(isOpenAt(weekendOnly, on(SAT, 7), at(19))).toBe(true);
expect(isOpenAt(weekendOnly, on(WED, 7), at(17))).toBe(true);
expect(isOpenAt(weekendOnly, on(WED, 7), at(19))).toBe(false);
});
it('계절 안의 "주말 미개방"은 그 계절의 주말 휴무다(영남제일관)', () => {
const p = parseOpeningHours(
'[하절기(4월~11월)]\n\n- 평일 09:00~18:00\n\n- 주말 10:00~18:00\n\n[동절기(12월~3월)]\n\n- 평일 09:00~18:00\n\n- 주말 미개방\n\n※ 자세한 사항은 전화문의 요망',
'연중무휴',
);
expect(p.status).toBe('parsed');
expect(isOpenAt(p, on(SAT), at(12))).toBe(true);
expect(isOpenAt(p, on(SAT, 1), at(12))).toBe(false);
expect(isOpenAt(p, on(WED, 1), at(12))).toBe(true);
});
it('괄호에 달만 적은 구간은 그 기간의 시간이고, 다른 시설 시간은 계절과 함께 연다(디아크문화관)', () => {
const p = parseOpeningHours(
'[B1-2F 전시실]- 10:00~17:30[3F카페&전망대]- 10:00~23:00 (4월~11월)- 10:00~22:00 (12월~3월)[B1 자전거종주인증]- 10:00~22:00',
'1월 1일 / 매주 월요일 (단, 공휴일 제외)',
);
expect(isOpenAt(p, on(WED, 1), at(22, 30))).toBe(false);
expect(isOpenAt(p, on(WED, 7), at(22, 30))).toBe(true);
expect(isOpenAt(p, on(WED, 1), at(11))).toBe(true);
expect(p.notes).not.toContain('4월~11월');
});
it('쉬는날에 기간만 적으면 그 기간에는 문을 열지 않는다(상주식당·강변축구장·사문진피크닉장)', () => {
const restaurant = parseOpeningHours('10:00~19:00', '매년 12월~3월');
expect(restaurant.closedPeriods.map((c) => c.months)).toEqual([
[1, 2, 3, 12],
]);
expect(isOpenAt(restaurant, on(WED, 1), at(12))).toBe(false);
expect(isOpenAt(restaurant, on(WED), at(12))).toBe(true);
const field = parseOpeningHours(
'[하절기 (4~9월)] - 조기 07:00 ~ 09:00 - 주간 09:00 ~ 19:00 - 야간 19:00 ~ 22:00 [동절기 (10~3월)] - 조기 07:00 ~ 09:00 - 주간 09:00 ~ 18:00 - 야간 18:00 ~ 22:00',
'동절기 (11월~2월)',
);
expect(isOpenAt(field, on(WED, 12), at(12))).toBe(false);
expect(isOpenAt(field, on(WED, 10), at(21))).toBe(true);
expect(isOpenAt(field, on(WED, 7), at(7, 30))).toBe(true);
const picnic = parseOpeningHours(
'10:00~18:00※ 3월~11월에 운영함으로 자세한 사항은 홈페이지 참조',
'매주 월요일 (단, 월요일이 법정공휴일인 경우 다음날 휴무) / 12월~2월',
);
expect(picnic.closedWeekdays).toEqual([MON]);
expect(isOpenAt(picnic, on(WED, 2), at(12))).toBe(false);
expect(isOpenAt(picnic, on(WED), at(12))).toBe(true);
});
it('"12월~2월 휴장"처럼 쉰다는 말이 붙은 기간도 문을 열지 않는 기간이다(영업시간·쉬는날 모두)', () => {
const hours = parseOpeningHours('10:00~18:00 / 12월~2월 휴장');
expect(isOpenAt(hours, on(WED, 1), at(12))).toBe(false);
expect(isOpenAt(hours, on(WED), at(12))).toBe(true);
const rest = parseOpeningHours(
'10:00~18:00',
'매주 월요일 / 1월~2월 휴장',
);
expect(rest.closedPeriods.map((c) => c.months)).toEqual([[1, 2]]);
expect(rest.closedWeekdays).toEqual([MON]);
expect(isOpenAt(rest, on(WED, 2), at(12))).toBe(false);
});
it('날짜("1월 1일")와 예외 문구 속 달은 문을 닫는 기간이 아니다(달성 한일우호관·도학야영장)', () => {
const museum = parseOpeningHours(
'09:00~18:00',
'매주 월요일 / 1월 1일 / 설 / 추석 당일',
);
expect(museum.closedPeriods).toEqual([]);
expect(museum.holidayClosures).toContain('1월 1일');
const camp = parseOpeningHours(
'14:00~익일 12:00',
'매주 수요일 (※ 성수기 7월~8월, 10월~11월 15일은 제외)',
);
expect(camp.closedPeriods).toEqual([]);
expect(camp.closedWeekdays).toEqual([WED]);
expect(camp.seasonal).toBe(false);
});
it('계절을 말하지만 기간을 읽지 못하면 시각 판단을 하지 않는다', () => {
const p = parseOpeningHours(
'10:00~18:00 (여름철 19:00까지)',
'매주 월요일',
);
expect(p.seasonal).toBe(true);
expect(p.seasons).toEqual([]);
expect(isOpenAt(p, on(WED, 7), at(12))).toBeNull();
expect(isOpenAt(p, on(MON, 7), at(12))).toBe(false);
});
it('계절별 시간표를 두기 전에 저장한 해석 결과는 판단하지 않는다', () => {
const stored = {
...parseOpeningHours('09:00~18:00'),
seasonal: true,
seasons: undefined,
} as unknown as ParsedOpeningHours;
expect(isOpenAt(stored, on(WED), at(12))).toBeNull();
});
it('자정을 넘긴 계절 영업은 다음 날 새벽까지 이어진다', () => {
const p = parseOpeningHours(
'[하절기(4월~10월)] 18:00~02:00 [동절기(11월~3월)] 18:00~24:00',
);
// 7월 첫 목요일 새벽 1시 — 수요일 밤 영업이 이어진다
expect(isOpenAt(p, on(THU, 7), at(1))).toBe(true);
expect(isOpenAt(p, on(THU, 1), at(1))).toBe(false);
});
it('날짜 형식이 아니면 판단하지 않는다', () => {
expect(
isOpenAt(parseOpeningHours('09:00~18:00'), '11/06', at(12)),
).toBeNull();
});
});
});
+561 -123
View File
@@ -5,6 +5,7 @@
* 자유 문장이다. 예: "11:00~21:00 (준비 시간 15:30~17:00)", "평일 11:00~22:00 / 주말 10:00~22:00",
* "상시 개방", "점포별 상이", 쉬는날 "매주 화요일", "설·추석 당일".
* AI 코스 검증("도착 시각에 영업 중인가")에 쓰도록 요일별 시간표로 바꾼다.
* "하절기(4월~10월) … 동절기(11월~3월) …" 처럼 계절마다 다르면 계절별 시간표를 두고 날짜의 달로 고른다.
* 읽을 수 없는 값은 지어내지 않고 'unknown'·'varies' 로 남긴다 — 화면에서는 '영업시간 미확인'으로 보여 준다.
*/
@@ -26,22 +27,58 @@ export type OpeningHoursStatus =
| 'varies'
| 'unknown';
/** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */
export type WeekHours = (TimeRange[] | null)[];
/** 1년 중 일부 기간(달 단위) — 계절·시기 머리말 하나 */
export interface MonthPeriod {
/** 머리말 원문("하절기(4월~10월)"). 머리말 없는 기본 시간은 빈 문자열 */
label: string;
/** 이 기간일 수 있는 달(1~12) */
months: number[];
/**
* 이 기간이 확실한 달. 달을 적지 않은 하절기·동절기는 어느 범위로 보든 그 계절인 달만 넣고,
* "4월 15일~" 처럼 달 중간에 바뀌는 달은 뺀다
*/
sureMonths: number[];
}
/** 계절·시기별 시간표 */
export interface SeasonHours extends MonthPeriod {
/** 그 기간의 요일별 영업 구간(정기 휴무 반영) */
week: WeekHours;
}
/** 시기별 정기 휴무 — "[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일" */
export interface SeasonRest extends MonthPeriod {
closedWeekdays: number[];
}
export interface ParsedOpeningHours {
status: OpeningHoursStatus;
/** 요일별 영업 구간. 인덱스 0=일요일 … 6=토요일. 빈 배열은 휴무, null 은 정보 없음 */
week: (TimeRange[] | null)[];
/** 1년 내내 같은 요일별 영업 구간. 계절별 시간표(seasons)가 있으면 정기 휴무만 남기고 비운다 */
week: WeekHours;
/** 준비시간(브레이크 타임) */
breaks: TimeRange[];
/** "준비시간(평일)" 처럼 평일에만 적용되는 브레이크인지 */
breaksWeekdaysOnly: boolean;
/** 마지막 주문 시각(분). 자정을 넘기면 1440 보다 크다 */
lastOrder: number | null;
/** 정기 휴무 요일(0=일요일) */
/** 1년 내내 쉬는 정기 휴무 요일(0=일요일) */
closedWeekdays: number[];
/** 명절·공휴일 휴무 표시(날짜 계산은 하지 않는다) */
holidayClosures: string[];
/** 하절기·동절기처럼 계절마다 다르다 — 어느 계절인지 알 수 없어 시각 판단을 하지 않는다 */
/**
* 계절·시기마다 다르다(seasons·restSeasons·closedPeriods 중 하나가 있다).
* 계절을 말하는데 기간을 읽지 못했으면 셋 다 비고 week 도 비워 시각 판단을 하지 않는다
*/
seasonal: boolean;
/** 계절·시기별 시간표 — 날짜의 달로 고른다 */
seasons: SeasonHours[];
/** 시기별 정기 휴무(closedWeekdays 에 더한다) */
restSeasons: SeasonRest[];
/** 문을 열지 않는 기간 — 쉬는날 "매년 12월~3월", "동절기 (11월~2월)" */
closedPeriods: MonthPeriod[];
/** 시간 외 안내 문구(조기 마감, 방문 전 연락 등) */
notes: string[];
}
@@ -65,12 +102,19 @@ const SINGLE_TIME = /(\d{1,2})\s*(?::\s*(\d{2})|시\s*(?:(\d{1,2})\s*분)?)/;
const SEGMENT_AFTER_TIME =
/(\d{1,2}:\d{2})(?=[월화수목금토일]요일|[월화수목금토일]\s*[~,·]\s*[월화수목금토일]|평일|주말|주중|매일|공휴일|\[)/g;
/**
* 줄바꿈 없이 이어 붙은 글머리표 — 시각·닫는 괄호 바로 뒤의 '- ' 다음에 숫자가 아닌 말이 오는 곳.
* 2026-11-04 대구 관광공사 원문 107곳: "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20".
* 나누지 않으면 영업시간이 준비시간·마지막 주문 구간에 섞여 사라진다(낮에 '영업 종료'로 보였다).
* "11:00- 21:00" 처럼 뒤에 숫자가 오면 시간 범위라 나누지 않는다.
* 시각 뒤에 이어지는 계절·달 머리말 — "10:00~19:00동절기 10:00~18:00"(수창청춘맨숀),
* "09:00~18:00 11월~2월 09:00~17:00"
*/
const BULLET_AFTER_TIME = /([\d)\]])\s*-\s+(?=[^\d\s~\-–—])/g;
const SEASON_AFTER_TIME =
/(\d{1,2}:\d{2})\s*(?=하절기|동절기|\d{1,2}\s*월\s*~)/g;
/**
* 줄바꿈 없이 이어 붙은 글머리표 — 시각·닫는 괄호 바로 뒤의 '- ' 다음에 숫자가 아닌 말이나 달 머리말이 오는 곳.
* 2026-11-04 대구 관광공사 원문 107곳: "- 11:00~21:00- 준비시간 16:00~17:00- 마지막 주문 20:20",
* 2026-11-06: "- 3월~10월 09:00~18:00- 11월~2월 09:00~17:00"(달성 한일우호관).
* 나누지 않으면 영업시간이 준비시간·마지막 주문 구간에 섞여 사라진다(낮에 '영업 종료'로 보였다).
* "11:00- 21:00" 처럼 뒤에 시각이 오면 시간 범위라 나누지 않는다.
*/
const BULLET_AFTER_TIME = /([\d)\]])\s*-\s+(?=[^\d\s~\-–—]|\d{1,2}\s*월)/g;
/** 쉬는 시간(브레이크)을 뜻하는 말 */
const BREAK_WORDS = /준비\s*시간|브레이크|휴식\s*시간|휴게\s*시간|break/i;
/**
@@ -85,6 +129,58 @@ const BEFORE_BRACKET = /(\S)\s*(?=\[)/g;
const SLASH_BETWEEN_DAYS =
/([월화수목금토일](?:요일)?)\s*\/\s*(?=[월화수목금토일](?:요일)?(?:[\s,·~]|$))/g;
const ALL_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
/** 달마다 날 수 — 그 달 끝날까지 적었는지 본다(2월 28일까지면 2월 전체로 본다) */
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** 계절을 뜻하는 말 */
const SEASON_WORDS_SOURCE = '하절기|동절기|여름철?|겨울철?|성수기|비수기';
const SEASON_WORD = new RegExp(SEASON_WORDS_SOURCE);
const SEASON_WORDS = new RegExp(SEASON_WORDS_SOURCE, 'g');
const SUMMER_WORD = /하절기|여름/;
const WINTER_WORD = /동절기|겨울/;
/** 달 범위 — "4월~10월", "4~10월", "4월1일~10월31일". 묶음: 시작 달·시작 날·끝 달·끝 날 */
const MONTH_SPAN_SOURCE = String.raw`(\d{1,2})\s*월?\s*(?:(\d{1,2})\s*일)?\s*~\s*(\d{1,2})\s*월\s*(?:(\d{1,2})\s*일)?`;
/** 달 하나 — "8월". "1월 1일" 같은 날짜는 기간이 아니다 */
const SINGLE_MONTH_SOURCE = String.raw`(\d{1,2})\s*월(?!\s*\d{1,2}\s*일)`;
const MONTH_SPAN = new RegExp(MONTH_SPAN_SOURCE, 'g');
const SINGLE_MONTH = new RegExp(SINGLE_MONTH_SOURCE, 'g');
const MONTH_ITEM_SOURCE = `(?:${MONTH_SPAN_SOURCE}|${SINGLE_MONTH_SOURCE})`;
const MONTH_LIST_SOURCE = String.raw`${MONTH_ITEM_SOURCE}(?:\s*[,·;]\s*${MONTH_ITEM_SOURCE})*`;
/** 줄 앞의 계절·달 머리말 — "하절기(4월~10월) …", "하절기 (4월~9월) …", "3월~10월 …", "하절기 화~일 …" */
const LEADING_PERIOD = new RegExp(
String.raw`^(?:(?:${SEASON_WORDS_SOURCE})(?:\s*\(\s*${MONTH_LIST_SOURCE}\s*\))?|${MONTH_LIST_SOURCE})(?=[\s::(\d-]|$)\s*[::-]?`,
);
/** 기간 표현 사이에 올 수 있는 말 — 나열·괄호·'매년' */
const PERIOD_FILLER = /[\s,·;~()[\]]|및|매년/g;
/** 계절을 말하는 문장 — 기간을 읽지 못하면 시각 판단을 하지 않는다 */
const SEASON_HINT =
/하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/;
/**
* 달을 적지 않은 계절의 기간. 2026-11-06 대구 관광공사 원문 15곳은 하절기를 3~10월·4~9월·4~10월·4~11월 등으로 달리 쓴다.
* 그래서 해당할 수 있는 달은 넓게, 확실한 달은 어느 범위로 보든 그 계절인 달만 둔다(하절기 4~9월, 동절기 12~2월).
* 3·10·11월에는 두 계절의 시간표가 같은 답을 낼 때만 답한다
*/
const UNDATED_SUMMER = {
months: [3, 4, 5, 6, 7, 8, 9, 10, 11],
sureMonths: [4, 5, 6, 7, 8, 9],
};
const UNDATED_WINTER = {
months: [1, 2, 3, 10, 11, 12],
sureMonths: [1, 2, 12],
};
/** 달을 적지 않은 성수기·비수기는 언제인지 알 수 없다 */
const UNDATED_ANY = { months: ALL_MONTHS, sureMonths: [] };
/** 문을 열지 않는다는 말 — "12월~2월 휴장" */
const CLOSED_WORDS_SOURCE = String.raw`휴장|휴무|휴관|휴업|미운영|운영\s*(?:안\s*함|하지\s*않음|중단)`;
const CLOSED_WORDS = new RegExp(`^(?:${CLOSED_WORDS_SOURCE})$`);
/** 구절 끝의 문을 열지 않는다는 말 — 쉬는날 "1월~2월 휴장" */
const CLOSED_SUFFIX = new RegExp(String.raw`\s*(?:${CLOSED_WORDS_SOURCE})$`);
/** 요일과 쉰다는 말만 있는 구절 — "주말 미개방"(영남제일관 동절기), "평일 휴관" */
const CLOSED_DAYS = /^(.+?)\s*(?:미개방|휴무|휴관|휴장|미운영)$/;
/** 요일을 나타내는 말(쉬는 요일 구절이 요일만으로 이뤄졌는지 볼 때 지운다) */
const DAY_WORDS = /평일|주말|주중|[일월화수목금토](?:요일)?|및|[\s~,·]/g;
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
/** 괄호·대괄호 안의 '/' 는 구간 구분이 아니다 — ';' 로 바꿔 둔다("[평일 / 일요일]", "(브레이크 … / 라스트오더 …)") */
function protectInnerSlashes(text: string): string {
return text
@@ -105,6 +201,7 @@ function normalize(text: string): string {
.replace(/(\d)\s*[-–—]\s*(\d{1,2}\s*[:시])/g, '$1~$2')
.replace(BULLET_AFTER_TIME, '$1 / ')
.replace(SEGMENT_AFTER_TIME, '$1 / ')
.replace(SEASON_AFTER_TIME, '$1 / ')
.replace(BEFORE_BRACKET, '$1 / ')
.replace(/(^|\/)\s*-\s*/g, '$1')
.replace(/\s+/g, ' ')
@@ -116,6 +213,14 @@ function toMinutes(hour: string, minute?: string, minuteAlt?: string): number {
return Number(hour) * 60 + Number(minute ?? minuteAlt ?? 0);
}
/** 시간 범위가 하나라도 있는가 */
function hasTimeRange(text: string): boolean {
TIME_RANGE.lastIndex = 0;
const found = TIME_RANGE.test(text);
TIME_RANGE.lastIndex = 0;
return found;
}
function parseRanges(segment: string): TimeRange[] {
const ranges: TimeRange[] = [];
for (const m of segment.matchAll(TIME_RANGE)) {
@@ -168,6 +273,67 @@ function parseDays(prefix: string): number[] | null {
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
}
/** 요일과 쉰다는 말만 있는 구절이면 쉬는 요일 — "주말 미개방" → [0, 6]. 아니면 null */
function daysClosedBy(part: string): number[] | null {
const m = CLOSED_DAYS.exec(part.trim());
if (!m || m[1].replace(DAY_WORDS, '') !== '') return null;
return parseDays(m[1]);
}
/**
* 머리말에서 기간(달)을 읽는다 — "하절기(4월~10월)", "4~10월", "[1월~4월 ; 9월~12월]", "하절기".
* 달을 적었으면 그 달, 계절 이름만 있으면 흔히 쓰는 범위로 둔다. 달도 계절도 없으면 null
*/
function parsePeriod(label: string): MonthPeriod | null {
const months = new Set<number>();
const partial = new Set<number>();
const isMonth = (m: number) => m >= 1 && m <= 12;
for (const m of label.matchAll(MONTH_SPAN)) {
const from = Number(m[1]);
const to = Number(m[3]);
if (!isMonth(from) || !isMonth(to)) continue;
for (let month = from; ; month = (month % 12) + 1) {
months.add(month);
if (month === to) break;
}
// 달 중간에 시작하거나 끝나면 그 달은 어느 쪽인지 날짜를 봐야 한다
if (m[2] && Number(m[2]) > 1) partial.add(from);
if (m[4] && Number(m[4]) < DAYS_IN_MONTH[to - 1]) partial.add(to);
}
for (const m of label.replace(MONTH_SPAN, ' ').matchAll(SINGLE_MONTH)) {
if (isMonth(Number(m[1]))) months.add(Number(m[1]));
}
if (months.size > 0) {
const sorted = [...months].sort((a, b) => a - b);
return {
label,
months: sorted,
sureMonths: sorted.filter((m) => !partial.has(m)),
};
}
if (!SEASON_WORD.test(label)) return null;
const range = SUMMER_WORD.test(label)
? UNDATED_SUMMER
: WINTER_WORD.test(label)
? UNDATED_WINTER
: UNDATED_ANY;
return {
label,
months: [...range.months],
sureMonths: [...range.sureMonths],
};
}
/** 문장 전체가 기간이면 그 기간 — "4월~11월", "동절기 (11월~2월)", "매년 12월~3월". 아니면 null */
function periodOnly(text: string): MonthPeriod | null {
const leftover = text
.replace(MONTH_SPAN, ' ')
.replace(SINGLE_MONTH, ' ')
.replace(SEASON_WORDS, ' ')
.replace(PERIOD_FILLER, '');
return leftover === '' ? parsePeriod(text.trim()) : null;
}
/**
* 쉬는날의 조건·예외 문구 — 여기 나온 요일·평일은 정기 휴무가 아니다.
* 2026-11-05 대구 관광공사 원문: "매주 월요일 (월요일이 공휴일인 경우 화요일 휴무)", "일요일을 제외한 법정공휴일",
@@ -180,8 +346,6 @@ const REST_CONDITION =
* ("공휴일 다음 첫 번째 평일" 의 '번째' 를 몇째 주로 읽어 매주 월요일 휴무까지 버렸다, 2026-11-05 국립대구박물관 등 4곳)
*/
const REST_NOT_WEEKLY = /매월|매달|격주|째|마지막\s*주|\d\s*주차/;
/** 시기 머리말 — "[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일" */
const PERIOD_HEADER = /\d{1,2}\s*월|하절기|동절기|여름|겨울|성수기|비수기/;
/** 대괄호 없이 구절 앞에 붙인 시설 이름 — "서문시장 매월 첫째주·셋째주 일요일 / 대구서문 야시장 매주 월요일~목요일" */
const INLINE_FACILITY = /^\s*(.{2,}?)\s*[::]?\s+(?=매주|매월|매달|격주)/;
/** 시설 이름이 아닌 말머리 — "정기휴무 매주 월요일", "단, 매주 …", "매월 격주 일요일" */
@@ -291,7 +455,7 @@ function restWeekdays(clauses: string[], withRanges: boolean): Set<number> {
}
/**
* 쉬는날 문장에서 정기 휴무 요일·명절 휴무·기타 안내를 뽑는다.
* 쉬는날 문장에서 정기 휴무 요일·시기별 휴무·문을 열지 않는 기간·명절 휴무·기타 안내를 뽑는다.
* @param restField 쉬는날 원문(restdatefood 등)이면 true, 영업시간 문장 속 "월요일 휴관" 구절이면 false
*/
function parseRestDays(
@@ -307,61 +471,100 @@ function parseRestDays(
if (/1월\s*1일|신정/.test(text)) out.holidayClosures.push('1월 1일');
if (/공휴일/.test(text)) out.holidayClosures.push('공휴일');
if (restField) {
// 기간만 적은 구절은 그 기간에 문을 열지 않는다는 뜻이다 — "매년 12월~3월"(상주식당), "동절기 (11월~2월)"(강변축구장),
// "1월~2월 휴장". 대괄호 머리말("[3월~7월]")은 뒤 구절에 붙는 시기라 뺀다
for (const clause of text.split(/\s*\/\s*/)) {
const period = clause.includes('[')
? null
: periodOnly(clause.replace(CLOSED_SUFFIX, ''));
if (period) out.closedPeriods.push(period);
}
}
const sections = restField
? splitRestSections(text)
: [{ header: '', body: text }];
const closedBySection: Set<number>[] = [];
let notWeekly = false;
let byPeriod = false;
for (const { header, body } of sections) {
const clauses = regularRestClauses(body, restField);
// 조건·예외만 있는 묶음은 휴무 요일을 따지지 않는다
if (clauses.length === 0) continue;
if (PERIOD_HEADER.test(header)) byPeriod = true;
// "매월 둘째·넷째 일요일" 처럼 매주가 아닌 휴무는 요일을 막지 않고 안내로만 남긴다.
// 구절마다 본다 — "매주 일요일 / 매달 마지막주 월요일" 의 매주 일요일은 휴무다(광명반점)
const weekly = clauses.filter((clause) => !REST_NOT_WEEKLY.test(clause));
if (weekly.length < clauses.length) notWeekly = true;
closedBySection.push(restWeekdays(weekly, restField));
const days = restWeekdays(weekly, restField);
// 시기 머리말("[3월~7월 / 9월~12월]")이면 그 시기에만 쉰다 — 날짜의 달로 고른다
const period = header ? parsePeriod(header) : null;
if (period) {
out.restSeasons.push({
...period,
closedWeekdays: [...days].sort((a, b) => a - b),
});
} else {
closedBySection.push(days);
}
}
if (notWeekly) out.notes.push(`휴무: ${text}`);
if (closedBySection.length === 0) return;
// 시기마다 다르면 어느 시기인지 몰라 모두 휴무로 두고 시각 판단을 하지 않는다(영업시간의 하절기·동절기와 같다).
// 시설마다 다르면 모든 시설이 쉬는 요일만 휴무다 — 어느 시설이든 열면 영업 중이다(영업시간의 시설 묶음과 같다)
const closed = byPeriod
? closedBySection.flatMap((days) => [...days])
: [...closedBySection[0]].filter((d) =>
closedBySection.every((days) => days.has(d)),
);
if (byPeriod) out.seasonal = true;
const closed = [...closedBySection[0]].filter((d) =>
closedBySection.every((days) => days.has(d)),
);
for (const d of closed) {
if (!out.closedWeekdays.includes(d)) out.closedWeekdays.push(d);
}
out.closedWeekdays.sort((a, b) => a - b);
}
/** 시설·계절 머리말 하나에 딸린 시간들 */
/**
* 머리말 하나에 딸린 시간들.
* - base: 첫 머리말 앞의 시간
* - facility: 시설 머리말("[칠성시장]") 아래 시간
* - season: 계절·달 머리말("[하절기(4월~10월)]", "3월~10월") 아래 시간
*/
type SectionKind = 'base' | 'facility' | 'season';
interface Section {
kind: SectionKind;
/** 계절·달 머리말의 기간 */
period: MonthPeriod | null;
/** 요일 지정이 없는(매일) 구간 */
general: TimeRange[];
/** 요일을 지정한 구간 — 같은 묶음의 매일 구간을 덮는다 */
/** 요일을 지정한 구간 — 같은 묶음의 매일 구간을 덮는다. 빈 구간은 그 요일 휴무 */
specific: { days: number[]; ranges: TimeRange[] }[];
}
const emptySection = (): Section => ({ general: [], specific: [] });
const newSection = (
kind: SectionKind,
period: MonthPeriod | null = null,
): Section => ({ kind, period, general: [], specific: [] });
/** 시설·계절 머리말에서 새 묶음을 연다. 지금 묶음이 비어 있으면 그대로 쓴다 */
function openSection(sections: Section[]): void {
const last = sections[sections.length - 1];
if (last.general.length > 0 || last.specific.length > 0) {
sections.push(emptySection());
}
const isEmptySection = (s: Section): boolean =>
s.general.length === 0 && s.specific.length === 0;
const periodKey = (p: MonthPeriod): string =>
`${p.months.join(',')}|${p.sureMonths.join(',')}`;
/** 같은 기간의 계절 묶음 — "[평일] 하절기 … [주말] 하절기 …" 처럼 같은 계절이 다시 나오면 이어서 쓴다 */
function findSeason(
sections: Section[],
period: MonthPeriod,
): Section | undefined {
const key = periodKey(period);
return sections.find(
(s) => s.kind === 'season' && s.period && periodKey(s.period) === key,
);
}
const emptyWeek = (): WeekHours => Array.from({ length: 7 }, () => null);
/** 묶음 하나의 요일별 시간표 — 매일 시간을 먼저 깔고, 요일을 지정한 시간으로 덮는다 */
function sectionWeek(section: Section): (TimeRange[] | null)[] {
const week: (TimeRange[] | null)[] = Array.from({ length: 7 }, () =>
function sectionWeek(section: Section): WeekHours {
const week: WeekHours = Array.from({ length: 7 }, () =>
section.general.length > 0 ? [...section.general] : null,
);
for (const { days, ranges } of section.specific) {
@@ -371,17 +574,16 @@ function sectionWeek(section: Section): (TimeRange[] | null)[] {
}
/**
* 묶음끼리는 요일별로 합친다 — "[칠성시장] 05:00~20:00 [칠성야시장] 일요일 19:00~22:30" 이면
* 시간표끼리 요일별로 합친다 — "[칠성시장] 05:00~20:00 [칠성야시장] 일요일 19:00~22:30" 이면
* 일요일은 두 구간 모두 연다(어느 시설이든 열려 있으면 영업 중). 뒤 시설의 요일 시간이 앞 시설을 지우지 않게 한다.
*/
function mergeSectionWeeks(sections: Section[]): (TimeRange[] | null)[] {
const weeks = sections.map(sectionWeek);
function mergeWeeks(weeks: WeekHours[]): WeekHours {
return Array.from({ length: 7 }, (_, d) => {
const known = weeks
.map((w) => w[d])
.filter((ranges): ranges is TimeRange[] => ranges !== null);
if (known.length === 0) return null;
if (known.length === 1) return known[0];
if (known.length === 1) return [...known[0]];
const seen = new Set<string>();
return known.flat().filter((r) => {
const key = `${r.start}-${r.end}`;
@@ -392,16 +594,186 @@ function mergeSectionWeeks(sections: Section[]): (TimeRange[] | null)[] {
});
}
/**
* 계절 묶음마다 시간표를 만든다.
* - 첫 머리말 앞의 기본 시간은 계절 시간이 적히지 않은 요일에 쓴다("10:00~17:00 [하절기(6~8월)] 10:00~13:00")
* - 시설 묶음("[전시실] … [카페] 10:00~23:00 (4월~11월)")은 어느 계절이든 함께 연다(어느 시설이든 열려 있으면 영업 중)
* - 기본·시설 시간이 있으면 계절이 확실하지 않은 달에 쓸 시간표를 하나 더 둔다
*/
function seasonHours(sections: Section[]): SeasonHours[] {
const shared = sections.filter((s) => s.kind !== 'season');
const baseWeek = mergeWeeks(
sections.filter((s) => s.kind === 'base').map(sectionWeek),
);
const facilityWeeks = sections
.filter((s) => s.kind === 'facility')
.map(sectionWeek);
const seasons: SeasonHours[] = [];
for (const s of sections) {
if (s.kind !== 'season' || !s.period) continue;
const own = sectionWeek(s).map((day, d) => day ?? baseWeek[d]);
seasons.push({ ...s.period, week: mergeWeeks([own, ...facilityWeeks]) });
}
if (shared.some((s) => !isEmptySection(s))) {
const sure = new Set(seasons.flatMap((s) => s.sureMonths));
const possible = new Set(seasons.flatMap((s) => s.months));
const months = ALL_MONTHS.filter((m) => !sure.has(m));
if (months.length > 0) {
seasons.push({
label: '',
months,
sureMonths: months.filter((m) => !possible.has(m)),
week: mergeWeeks(shared.map(sectionWeek)),
});
}
}
return seasons;
}
/** 영업시간 문장을 시설·계절 묶음으로 나눠 읽고 시간표를 만든다 */
function parseHoursText(text: string, result: ParsedOpeningHours): void {
const sections: Section[] = [newSection('base')];
let current = sections[0];
// "[토요일]" 처럼 대괄호 요일 머리말 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다
let headerDays: number[] | null = null;
/** 머리말에서 묶음을 연다. 같은 계절 묶음이 있으면 이어 쓰고, 지금 묶음이 비어 있으면 그대로 쓴다 */
const open = (kind: SectionKind, period: MonthPeriod | null): void => {
const same = period ? findSeason(sections, period) : undefined;
if (same) {
current = same;
} else if (isEmptySection(current)) {
current.kind = kind;
current.period = period;
} else {
current = newSection(kind, period);
sections.push(current);
}
};
/** 지금 묶음은 그대로 두고 그 기간의 계절 묶음을 찾거나 만든다 — "10:00~23:00 (4월~11월)" */
const seasonOf = (period: MonthPeriod): Section => {
const same = findSeason(sections, period);
if (same) return same;
const created = newSection('season', period);
sections.push(created);
return created;
};
for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) {
let segment = raw.trim();
if (!segment) continue;
// 머리말 뒤에 시간이 바로 이어 붙은 줄("[칠성시장]- 05:00~20:00")도 머리말과 나머지로 나눠 읽는다
const header = /^\[([^\]]*)\]\s*-?\s*/.exec(segment);
if (header && !/\d{1,2}\s*[:시]/.test(header[1])) {
// 계절·달 머리말("[하절기(4월~10월)]", "[1월]")이면 계절 묶음을 연다
const period = parsePeriod(header[1].trim());
// 달 머리말의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다
headerDays = period
? null
: parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' '));
// 요일이 아닌 머리말이면 시설 묶음을 연다
if (period) open('season', period);
else if (headerDays === null) open('facility', null);
segment = segment.slice(header[0].length).trim();
if (!segment) continue;
}
// 줄 앞의 계절·달 머리말 — "하절기(4월~10월) 10:00~19:00", "3월~10월 09:00~18:00"
const lead = LEADING_PERIOD.exec(segment);
const leadPeriod = lead
? parsePeriod(lead[0].replace(/[\s::-]+$/, ''))
: null;
if (lead && leadPeriod) {
const after = segment.slice(lead[0].length).trim();
if (!after || hasTimeRange(after)) {
open('season', leadPeriod);
segment = after;
if (!segment) continue;
} else if (CLOSED_WORDS.test(after)) {
// "12월~2월 휴장" — 그 기간에는 문을 열지 않는다
result.closedPeriods.push(leadPeriod);
continue;
}
}
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다).
// 괄호에 기간만 적었으면 그 구간은 그 기간의 시간이다 — "10:00~23:00 (4월~11월)"(디아크문화관)
const parens = [...segment.matchAll(/\(([^)]*)\)/g)].map((m) => ({
text: m[1],
period: periodOnly(m[1]),
}));
const qualifier = parens.find((p) => p.period !== null)?.period ?? null;
const target = qualifier ? seasonOf(qualifier) : current;
const inner = parens
.filter((p) => p.period === null)
.flatMap((p) => p.text.split(/\s*;\s*/));
const body = segment.replace(/\([^)]*\)/g, ' ');
for (const part of [body, ...inner]) {
if (BREAK_WORDS.test(part) || isLunchBreak(part)) {
result.breaks.push(...parseRanges(part));
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우,
// "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다
const weekdaysOnly =
/평일|주중/.test(part) ||
/^(평일|주중)/.test(segment) ||
(part === body && /\(\s*평일\s*\)/.test(segment));
if (weekdaysOnly) result.breaksWeekdaysOnly = true;
continue;
}
// 괄호 안에 요일 구분만 있는 경우(예: "(평일)")는 앞의 항목을 꾸미는 말이라 안내로 남기지 않는다
if (part !== body && /^\s*(평일|주말|주중)\s*$/.test(part)) continue;
if (/마지막\s*주문|라스트\s*오더|L\.?\s*O/i.test(part)) {
const m = SINGLE_TIME.exec(part);
if (m) result.lastOrder = toMinutes(m[1], m[2], m[3]);
continue;
}
const closedInHours =
/([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part);
if (closedInHours) parseRestDays(part, result, false);
const ranges = parseRanges(part);
if (ranges.length === 0) {
// "주말 미개방" — 그 묶음(계절)에서 그 요일은 쉰다
const closedDays = closedInHours ? null : daysClosedBy(part);
if (closedDays) {
target.specific.push({ days: closedDays, ranges: [] });
continue;
}
if (part !== body || !closedInHours) result.notes.push(part.trim());
continue;
}
const prefix = part.slice(0, part.search(/\d/));
const days = parseDays(prefix) ?? headerDays;
if (days) target.specific.push({ days, ranges });
else target.general.push(...ranges);
}
}
result.notes = result.notes.filter((n) => n.length > 0);
if (sections.some((s) => s.kind === 'season')) {
result.seasons = seasonHours(sections);
result.week = emptyWeek();
return;
}
result.week = mergeWeeks(sections.map(sectionWeek));
// 계절을 말하는데 기간을 읽지 못했다("여름철에는 연장") — 시간표를 비워 시각 판단을 하지 않는다
if (SEASON_HINT.test(text)) {
result.seasonal = true;
result.week = emptyWeek();
}
}
function emptyResult(status: OpeningHoursStatus): ParsedOpeningHours {
return {
status,
week: Array.from({ length: 7 }, () => null),
week: emptyWeek(),
breaks: [],
breaksWeekdaysOnly: false,
lastOrder: null,
closedWeekdays: [],
holidayClosures: [],
seasonal: false,
seasons: [],
restSeasons: [],
closedPeriods: [],
notes: [],
};
}
@@ -417,8 +789,7 @@ export function parseOpeningHours(
): ParsedOpeningHours {
const text = normalize(hours ?? '');
const rest = normalize(restDays ?? '');
const hasTime = TIME_RANGE.test(text);
TIME_RANGE.lastIndex = 0;
const hasTime = hasTimeRange(text);
let result: ParsedOpeningHours;
if (!text) {
@@ -432,105 +803,172 @@ export function parseOpeningHours(
);
} else {
result = emptyResult('parsed');
// 계절·달마다 시간이 다르면("[1월] … [2월] …") 오늘이 어느 쪽인지 몰라 시각 판단을 하지 않는다
result.seasonal =
/하절기|동절기|여름|겨울|성수기|비수기|\[\s*\d{1,2}\s*월\s*\]/.test(text);
// 시설·계절 머리말("[칠성시장]", "[하절기]")마다 묶음을 나눈다. 머리말이 없으면 묶음은 하나다
const sections: Section[] = [emptySection()];
// "[토요일]" 처럼 대괄호 요일 머리말 — 다음 머리말이 나올 때까지 이어지는 시간에 적용한다
let headerDays: number[] | null = null;
for (const raw of text.split(/\s*\/\s*|\s*※\s*/)) {
let segment = raw.trim();
if (!segment) continue;
// 머리말 뒤에 시간이 바로 이어 붙은 줄("[칠성시장]- 05:00~20:00")도 머리말과 나머지로 나눠 읽는다
const header = /^\[([^\]]*)\]\s*-?\s*/.exec(segment);
if (header && !/\d{1,2}\s*[:시]/.test(header[1])) {
// 달 머리말("[1월]")의 '월'을 월요일로 읽지 않게 숫자+월/일을 지운다
headerDays = parseDays(header[1].replace(/\d{1,2}\s*[월일]/g, ' '));
// 요일이 아닌 머리말(시설·계절)이면 새 묶음을 연다
if (headerDays === null) openSection(sections);
segment = segment.slice(header[0].length).trim();
if (!segment) continue;
}
const section = sections[sections.length - 1];
// 괄호 안의 준비시간·마지막 주문은 따로 읽고 본문에서 뺀다(괄호 안 '/' 는 ';' 로 바뀌어 있다)
const inner = [...segment.matchAll(/\(([^)]*)\)/g)].flatMap((m) =>
m[1].split(/\s*;\s*/),
);
const body = segment.replace(/\([^)]*\)/g, ' ');
for (const part of [body, ...inner]) {
if (BREAK_WORDS.test(part) || isLunchBreak(part)) {
result.breaks.push(...parseRanges(part));
// "준비시간(평일) 15:30~16:30" 처럼 '평일'이 괄호로 붙은 경우,
// "주중 11:30~22:30 (브레이크타임 …)" 처럼 평일·주중 줄의 괄호 안에 있는 경우도 평일 전용으로 본다
const weekdaysOnly =
/평일|주중/.test(part) ||
/^(평일|주중)/.test(segment) ||
(part === body && /\(\s*평일\s*\)/.test(segment));
if (weekdaysOnly) result.breaksWeekdaysOnly = true;
continue;
}
// 괄호 안에 요일 구분만 있는 경우(예: "(평일)")는 앞의 항목을 꾸미는 말이라 안내로 남기지 않는다
if (part !== body && /^\s*(평일|주말|주중)\s*$/.test(part)) continue;
if (/마지막\s*주문|라스트\s*오더|L\.?\s*O/i.test(part)) {
const m = SINGLE_TIME.exec(part);
if (m) result.lastOrder = toMinutes(m[1], m[2], m[3]);
continue;
}
const closedInHours =
/([일월화수목금토])요일\s*(?:휴무|휴관|정기\s*휴무)/.exec(part);
if (closedInHours) parseRestDays(part, result, false);
const ranges = parseRanges(part);
if (ranges.length === 0) {
if (part !== body || !closedInHours) result.notes.push(part.trim());
continue;
}
const prefix = part.slice(0, part.search(/\d/));
const days = parseDays(prefix) ?? headerDays;
if (days) section.specific.push({ days, ranges });
else section.general.push(...ranges);
}
}
result.week = mergeSectionWeeks(sections);
result.notes = result.notes.filter((n) => n.length > 0);
parseHoursText(text, result);
}
parseRestDays(rest, result, true);
for (const d of result.closedWeekdays) result.week[d] = [];
for (const d of result.closedWeekdays) {
result.week[d] = [];
for (const season of result.seasons) season.week[d] = [];
}
if (
result.seasons.length > 0 ||
result.restSeasons.length > 0 ||
result.closedPeriods.length > 0
) {
result.seasonal = true;
}
return result;
}
/**
* 해당 요일·시각에 영업 중인지 판단한다.
* @param weekday 0=일요일 … 6=토요일
* @param minutes 00:00 부터의 분
* @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인')
*/
export function isOpenAt(
/** 한 날짜에 쓰는 시간표 하나 */
interface DaySchedule {
week: WeekHours;
/** 1년 내내 쉬는 요일 + 그 시기에 쉬는 요일 */
closedWeekdays: number[];
}
/** 'YYYY-MM-DD' 에서 offset 일 떨어진 날의 달·요일. 형식이 다르면 null */
function calendarDay(
date: string,
offset = 0,
): { month: number; weekday: number } | null {
const m = ISO_DATE.exec(date);
if (!m) return null;
const day = new Date(
Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + offset),
);
return { month: day.getUTCMonth() + 1, weekday: day.getUTCDay() };
}
/** 그 달에 해당할 수 있는 기간들. 그 달이 확실한 기간이 있으면 그것만 */
function forMonth<T extends MonthPeriod>(periods: T[], month: number): T[] {
const possible = periods.filter((p) => p.months.includes(month));
const sure = possible.filter((p) => p.sureMonths.includes(month));
return sure.length > 0 ? sure : possible;
}
/** 그 달에 쓸 수 있는 시간표들 — 어느 계절인지 모르면 여럿, 그 달의 시간표가 없으면 빈 목록 */
function schedulesFor(
parsed: ParsedOpeningHours,
month: number,
): DaySchedule[] {
const seasons = parsed.seasons ?? [];
const weeks =
seasons.length > 0
? forMonth(seasons, month).map((s) => s.week)
: [parsed.week];
const restSeasons = forMonth(parsed.restSeasons ?? [], month);
const extraClosed =
restSeasons.length > 0 ? restSeasons.map((r) => r.closedWeekdays) : [[]];
return weeks.flatMap((week) =>
extraClosed.map((extra) => ({
week,
closedWeekdays: [...parsed.closedWeekdays, ...extra],
})),
);
}
/** 해당할 수 있는 시간표들의 답이 모두 같을 때만 그 답. 다르거나 시간표가 없으면 null */
function agree(answers: (boolean | null)[]): boolean | null {
if (answers.length === 0) return null;
return answers.every((a) => a === answers[0]) ? answers[0] : null;
}
/** 문을 열지 않는 기간이면 닫힘, 그 기간일 수 있으면 열렸다고 단정하지 않는다 */
function applyClosedPeriods(
parsed: ParsedOpeningHours,
month: number,
open: boolean | null,
): boolean | null {
const periods = (parsed.closedPeriods ?? []).filter((p) =>
p.months.includes(month),
);
if (periods.some((p) => p.sureMonths.includes(month))) return false;
if (periods.length > 0 && open === true) return null;
return open;
}
/** 자정을 넘기는 영업 구간이 있는가 — 없으면 전날 영업이 오늘로 이어질 일이 없다 */
function hasOvernight(parsed: ParsedOpeningHours): boolean {
const weeks = [parsed.week, ...(parsed.seasons ?? []).map((s) => s.week)];
return weeks.some((week) =>
week.some((day) => (day ?? []).some((r) => r.end > MINUTES_PER_DAY)),
);
}
/** 전날(weekday) 밤에 시작한 영업이 오늘 minutes 까지 이어지는가 */
function carriesOver(
schedule: DaySchedule,
weekday: number,
minutes: number,
): boolean {
if (schedule.closedWeekdays.includes(weekday)) return false;
return (schedule.week[weekday] ?? []).some(
(r) => r.end > MINUTES_PER_DAY && minutes + MINUTES_PER_DAY < r.end,
);
}
/** 시간표 하나로 본 그날(weekday) minutes 의 영업 여부 */
function openOn(
schedule: DaySchedule,
parsed: ParsedOpeningHours,
weekday: number,
minutes: number,
): boolean | null {
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
if (parsed.seasonal) return null;
// 전날 밤에 시작해 자정을 넘긴 영업 구간은 오늘이 휴무일이어도 이어진다
const yesterday = parsed.week[(weekday + 6) % 7];
const fromYesterday = (yesterday ?? []).some(
(r) => r.end > MINUTES_PER_DAY && minutes + MINUTES_PER_DAY < r.end,
);
if (fromYesterday) return true;
if (parsed.closedWeekdays.includes(weekday)) return false;
if (schedule.closedWeekdays.includes(weekday)) return false;
const inBreak = parsed.breaks.some(
(b) => minutes >= b.start && minutes < b.end,
);
const breakApplies =
!parsed.breaksWeekdaysOnly || (weekday >= 1 && weekday <= 5);
if (inBreak && breakApplies) return false;
const today = parsed.week[weekday];
const today = schedule.week[weekday];
if (today === null) return null;
return today.some((r) => minutes >= r.start && minutes < r.end);
}
/**
* 그 날짜·시각에 영업 중인지 판단한다. 계절별 시간표면 날짜의 달로 고르고,
* 어느 계절인지 달로 정할 수 없으면(달을 적지 않은 하절기·동절기의 3·10·11월) 해당할 수 있는 시간표가 모두 같은 답일 때만 답한다.
* @param date 한국 날짜 'YYYY-MM-DD'
* @param minutes 00:00 부터의 분
* @returns 영업 중이면 true, 아니면 false, 판단할 수 없으면 null(화면에 '영업시간 미확인')
*/
export function isOpenAt(
parsed: ParsedOpeningHours,
date: string,
minutes: number,
): boolean | null {
if (parsed.status === 'unknown' || parsed.status === 'varies') return null;
// 계절별 시간표를 두기 전(2026-11-06)에 저장한 해석 결과 — reparse-hours 로 다시 해석하기 전까지는 판단하지 않는다
if (parsed.seasonal && !parsed.seasons) return null;
const today = calendarDay(date);
const yesterday = calendarDay(date, -1);
if (!today || !yesterday) return null;
// 전날 밤에 시작해 자정을 넘긴 영업 구간은 오늘이 휴무일이어도 이어진다
const carried = hasOvernight(parsed)
? applyClosedPeriods(
parsed,
yesterday.month,
agree(
schedulesFor(parsed, yesterday.month).map((s) =>
carriesOver(s, yesterday.weekday, minutes),
),
),
)
: false;
if (carried === true) return true;
const open = applyClosedPeriods(
parsed,
today.month,
agree(
schedulesFor(parsed, today.month).map((s) =>
openOn(s, parsed, today.weekday, minutes),
),
),
);
if (open === true) return true;
return carried === null || open === null ? null : false;
}
+2 -1
View File
@@ -87,7 +87,8 @@ WanderGo AI 코스 생성이 **조건을 지키는지**, **없는 정보를 지
그래서 반려동물 사례 대부분이 `partial_with_notice`·`no_course` 다. 큐레이션으로 동반 카페·식당을 확인하면 기대 결과를 다시 본다.
- **전통시장은 AI 후보가 아니다.** 쇼핑 분류라 큐레이션에서 추천(O)·확인해야 후보가 된다(장소 DB §3). 서문시장 야시장 사례(F05·G02)는 `requires` 로 표시했다.
- **11월 대구에 축제가 없다.** 행사는 수성국제비엔날레(수성못, 10-27~11-22)와 전시뿐이다. 축제를 찾는 F01 은 '없다고 말하기'를 본다.
- **계절별 시간표는 미확인이다.** 하절기·동절기로 나뉜 곳(수창청춘맨숀·대구예술발전소·향촌문화관 등 23곳)은 지금 영업시간 미확인이라 문화시설이면 후보에서 빠진다. 구현 2단계에서 날짜로 고르게 고친다(설계 §3.3).
- **계절별 시간표는 미확인이었다.** 하절기·동절기로 나뉜 곳(수창청춘맨숀·대구예술발전소·향촌문화관 등 23곳)은 영업시간 미확인이라 문화시설이면 후보에서 빠졌다.
2026-11-06 날짜의 달로 고르게 고쳤다(설계 §3.3). 달을 적지 않은 곳(수창청춘맨숀 등 7곳)은 11월에 두 계절 시간이 같은 시각만 판단한다.
- 생성 사례는 모두 거른 뒤 후보가 8곳 이상 남는다(설계 §3.2–3.3 규칙으로 근사 계산).
정답 코스는 적지 않았습니다. 평가는 장소 DB 스냅샷 위에서 돌리므로, 정답은 "조건을 지켰는가"로 판단합니다.
+3 -3
View File
@@ -125,8 +125,8 @@
| 조건 | 규칙 |
| --- | --- |
| 영업 | 그날 요일 기준으로, 시간 범위 안에서 분류별 최소 머무는 시간(§3.6)만큼 열려 있어야 한다. `isOpenAt` 으로 10분 간격 확인(브레이크 포함). 휴무 요일이면 뺀다 |
| 영업시간 미확인 | `status` 가 unknown · varies 이거나 `seasonal` 이면 미확인이다. **자연·관광지와 행사만 남기고**(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 하절기·동절기처럼 계절별 시간표는 지금 미확인으로 읽힌다(AI 후보 23곳, 문화시설 10곳 — 수창청춘맨숀·대구예술발전소·향촌문화관 등). 달 범위가 적혀 있으면 생성 날짜로 고르도록 2단계에서 고친다 |
| 영업 | 그날 날짜 기준으로, 시간 범위 안에서 분류별 최소 머무는 시간(§3.6)만큼 열려 있어야 한다. `isOpenAt(hours, 날짜, 분)` 으로 10분 간격 확인(브레이크·휴무 요일·계절별 시간표·문을 열지 않는 기간 포함) |
| 영업시간 미확인 | 머무는 시간 중 `isOpenAt` 이 null 인 때가 있으면 미확인이다(`status` 가 unknown · varies 이거나, 그 요일 시간이 없거나, 계절을 정할 수 없는 달에 두 계절 시간이 다를 때). **자연·관광지와 행사만 남기고**(야외라 시간 영향이 작다) 식당·카페·문화시설·레저는 뺀다(§13). 계절별 시간표는 2026-11-06 부터 날짜의 달로 고른다(대구 30곳). 달을 적지 않은 하절기·동절기(7곳)는 3·10·11월에 두 계절 시간이 같은 시각만 판단한다 |
| 공휴일 | 그날이 공휴일·명절이고 `holidayClosures` 가 있으면 뺀다. 공휴일 표는 코드 상수로 두고 해마다 갱신한다 |
| 반려동물 (조건에 있을 때) | `not_allowed` 와 크기 불일치(`petSizes` 가 있는데 그 크기가 없음)는 뺀다. 실내(식당·카페·문화시설·쇼핑과 전시)는 `allowed` · `partial` 만 남긴다. 야외 분류와 축제·행사는 `unknown` 도 남기고 결과에 "동반 정보 없음" 경고를 붙인다(§13). 2026-11-05 기준 대구에 동반이 확인된 실내 장소는 0곳이라, 큐레이션 전에는 반려 코스가 야외로만 나온다 |
| 행사 시간 | `playtime_text` 를 영업시간 파서로 읽어 시간 범위와 겹쳐야 한다. 못 읽으면 남기고 "행사 시간 확인" 경고를 붙인다 |
@@ -432,7 +432,7 @@ v0.1 의 추천안을 사용자가 모두 그대로 골랐다.
## 14. 구현 순서
1. 평가셋 v2(대구) — 지역·날짜 바꾸기, 사용자 검토 (키 불필요). 2026-11-05 확정
2. 순수 로직 — 후보 거르기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트, 계절별 시간표를 날짜로 고르기(§3.3) (키 불필요)
2. 순수 로직 — 계절별 시간표를 날짜로 고르기(§3.3, 2026-11-06 반영) → 후보 거르기 · 이동 시간 · 시간표 · 검증 · 고치기 + 단위 테스트 (키 불필요)
3. 날씨(기상청 단기예보 · 격자 변환 · 캐시) + 공휴일 표 (공공데이터 키 — 실제 호출 확인은 사용자가 `!` 로)
4. 테이블 · 에러 코드 · API 골격(가짜 AI 클라이언트) + e2e (키 불필요, CI 에서도 돈다)
5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석
+12 -1
View File
@@ -293,8 +293,19 @@ node dist/commands/place-sync.js tour-kor
— 같은 구절의 "월~금 10:00~18:00" 은 여는 요일이다. 쉬는날 원문의 여는 날 안내("화~일 운영")도 휴무로 읽지 않는다.
- 매주가 아닌 휴무(매월·매달·격주·몇째 주·N주차·마지막 주)는 구절마다 판단해 안내로만 남긴다. 괄호 안 주기는 앞 구절을 꾸민다("매주 월요일 (매월 2주, 4주)").
- 시설별 쉬는날("[북카페] 일요일 [다목적홀] 주말", "서문시장 매월 … / 대구서문 야시장 매주 월요일~목요일")은 모든 시설이 쉬는 요일만 휴무로 둔다.
시기별 쉬는날("[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일")은 어느 시기인지 몰라 계절 구분(`seasonal`)으로 두고 영업 중 판단을 하지 않는다.
시기별 쉬는날("[3월~7월 / 9월~12월] 월요일 [1월 / 2월 / 8월] 일요일")은 어느 시기인지 몰라 계절 구분(`seasonal`)으로 두고 영업 중 판단을 하지 않는다(→ 2026-11-06 날짜로 고른다).
- 남은 한계: 영업시간 문장 속 시설별 휴관("[휘트니스] … 매주 일요일 휴관", 엘리바덴 월성점)은 시설 전체 휴무로 읽힌다.
- 2026-11-06 계절별 시간표를 날짜의 달로 고르게 고쳤다(`reparse-hours`, 영업 중 판단이 바뀐 곳 30곳). `isOpenAt` 은 요일 대신 한국 날짜('YYYY-MM-DD')를 받는다.
- 계절·달 머리말마다 시간표를 따로 둔다(`seasons`) — "[하절기(4월~10월)]", "하절기(4~10월) …", "- 3월~10월 …", "[1월~4월 / 9월~12월]", "10:00~23:00 (4월~11월)".
그때까지 미확인이던 23곳이 판단되고, 1년 내내 같은 시간으로 읽혀 겨울에 영업 중으로 잘못 보이던 7곳(대구수목원 아침 8시·달성 한일우호관 17시·상주식당 12~3월 등)이 바로잡혔다.
- 달을 적지 않은 하절기·동절기(7곳)는 기관마다 범위가 달라(대구 원문 15곳: 3~10월·4~9월·4~10월·4~11월 등) 하절기 4~9월, 동절기 12~2월만 확실하다고 본다.
3·10·11월에는 두 계절 시간이 같은 시각만 판단한다(수창청춘맨숀 11월 18시~19시는 미확인).
- 머리말 없는 기본 시간은 계절 시간이 없는 달·요일에 쓰고("10:00~17:00 [하절기(6~8월)] 10:00~13:00"), 시설 머리말 시간은 어느 계절이든 함께 연다.
- 쉬는날의 시기별 휴무는 그 시기에만(`restSeasons`), 기간만 적은 쉬는날("매년 12월~3월", "동절기 (11월~2월)", "… / 12월~2월")은 그 기간 내내 닫는다(`closedPeriods`, 상주식당 등 3곳).
"1월 1일" 같은 날짜와 예외 문구 속 달("성수기 7월~8월 … 제외")은 기간으로 읽지 않는다. 계절 안의 "주말 미개방"은 그 계절의 주말 휴무다(영남제일관).
- 계절을 말하는데 기간을 읽지 못하면 시간표를 비워 판단하지 않는다. 다시 해석하기 전에 저장된 결과(`seasons` 없음)도 판단하지 않는다.
- 남은 한계: 계절 안의 시설 머리말("[하절기] [A관] … [B관] …")은 읽지 못해 미확인으로 둔다. 쉬는날의 예외 기간(도학야영장 "매주 수요일 (※ 성수기 … 제외)")은 적용하지 않는다.
케이블카 출발 시각처럼 범위가 아닌 시각은 읽지 않는다(앞산 케이블카는 '점포별 상이').
- 상가정보는 `storeListInDong`(divId=signguCd, 소분류 업종 필터)로 시군구 × 데이트 업종 6개를 받는다(`--signgu=` 로 범위 변경).
끝까지 받은 (시군구, 업종) 묶음에서만 이번에 보이지 않은 업소를 `gone`(폐업 추정)으로 바꾼다(원본 장애 판단은 위와 같다).
업종이 바뀐 업소(예: 카페 → 빵집)는 같은 bizesId 로 갱신되어 그대로 노출된다. 기준년월(stdrYm)은 `source_snapshot` 에 남긴다.