Skip to content

feat(scheduling): 캘린더 통합 가용시간 산출 프레임워크 (Date 기반 어댑터) - #16

Merged
Siul49 merged 2 commits into
devfrom
feature/15-availability-aggregation
May 27, 2026
Merged

Siul49 merged 2 commits into
devfrom
feature/15-availability-aggregation

Conversation

@Siul49

@Siul49 Siul49 commented May 7, 2026

Copy link
Copy Markdown
Owner

🚀 작업 내용 (What)

  • provider-agnostic CalendarEvent 표준 타입과 TimeRange/SearchWindow 정의 (src/types/calendar-event.ts)
  • Date 기반 슬롯 타입 DateTimeSlot, ParticipantDateAvailabilityschedule.ts에 추가
  • time-slot.tssortSlots/mergeOverlapping 구현 + sortDateSlots/mergeOverlappingDateSlots 추가
  • free-slots.ts 신규: busy 이벤트를 검색 윈도우 내 free 슬롯으로 반전 (busyEventsToFree, eventsToBusyRanges)
  • findCommonDateSlots(participants, { durationMinutes }) 신규: Date 기반 교집합 + 회의 길이 필터
  • CalendarAdapter<TRaw> 인터페이스와 iCloud(ParsedEvent)·Google(GoogleEvent)·Manual(TimeSlot) 어댑터 구현
  • AI 사진 추출 어댑터(adapters/photo.ts)는 후속 이슈 자리만 잡아둔 스텁
  • 신규 단위 테스트 28건 추가, 기존 테스트 92건 모두 그대로 통과 (총 120/120)

📣 핵심 변경 이유 (Why)

  • 기존 findCommonSlots는 요일+정수 시간 단위라 30분 단위 슬롯·실제 Date 이벤트(Google/iCloud)·회의 길이 필터를 처리할 수 없었다.
  • 향후 Google/iCloud 외에 AI 사진 추출, .ics, 수동 입력 등 입력 경로가 늘어나는데 raw 데이터 형태가 모두 다르므로, 가용시간 산출 로직 앞단에 provider-agnostic 변환 계층(어댑터)이 필요했다.
  • 기존 시간단위 findCommonSlots는 그대로 두어 호환성을 유지하고, 신규 코드만 Date 기반으로 마이그레이션할 수 있도록 두 파이프라인을 공존시켰다.

⚠️ 체크리스트 (Checklist)

  • 브랜치 컨벤션(feature/00-name)을 지켰나요?
  • 커밋 컨벤션(feat:, fix: 등)을 지켰나요?
  • 작업 전에 관련 이슈를 생성하고 연결했나요?
  • 내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요?

🔗 관련 이슈 (Issue)

Close #15

- CalendarEvent 표준 타입과 SearchWindow/TimeRange 정의
- DateTimeSlot, ParticipantDateAvailability 타입을 schedule.ts에 추가
- time-slot.ts: sortSlots/mergeOverlapping 구현 + Date 기반 변종 추가
- free-slots.ts: busy 이벤트를 윈도우 내 free 슬롯으로 반전
- findCommonDateSlots: durationMinutes 필터를 가진 Date 기반 교집합
- CalendarAdapter 인터페이스와 iCloud/Google/Manual 어댑터 구현
- photo 어댑터는 후속 이슈를 위한 스텁

Refs #15
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

Failed to post review comments

Summary by CodeRabbit

  • New Features

    • Google·iCloud 캘린더 통합으로 외부 일정 가져오기 지원
    • 수동으로 선택한 가용 시간(When2meet 스타일)을 모임 가능 시간으로 변환
    • 참가자 간 공통 가용 시간 계산(절대 시각 기반 옵션 포함)
    • 모임 생성 UI 및 호스트/참여자 링크 생성 흐름 추가
  • Tests

    • 캘린더 어댑터, 스케줄링 유틸, E2E 호스트/참여자 흐름을 포함한 광범위한 테스트 추가

Walkthrough

캘린더 제공자별 원본 이벤트를 표준 CalendarEvent로 정규화하는 어댑터 패턴을 추가하고, Date 기반 시간 범위 교집합·병합·busy→free 역산 유틸 및 이를 검증하는 단위/E2E 테스트와 테스트용 인메모리 스토어와 API/UI 연동을 도입했다.

Changes

캘린더 어댑터 및 Date 기반 스케줄링 프레임워크

Layer / File(s) Summary
Type system & adapter contract
src/types/calendar-event.ts, src/types/schedule.ts, src/lib/calendar/adapter.ts
CalendarEvent, TimeRange, SearchWindow, DateTimeSlot, ParticipantDateAvailability 타입과 CalendarAdapter<TRaw> 인터페이스 추가.
Adapters implementation
src/lib/calendar/adapters/{google.ts,icloud.ts,manual.ts,photo.ts}
googleAdapter, icloudAdapter, manualSlotsToFreeEvents 구현. Google: cancelled 필터·all-day 파싱. iCloud: uid→id 변환. manual: weekStart 기준 Date 계산. photoAdapter: 스텁(미구현).
Adapter tests
src/lib/calendar/adapters/__tests__/*
구글/아이클라우드/수동 어댑터 매핑 규칙과 엣지 케이스 검증(빈 제목 대체, cancel 필터, all-day).
Scheduling core utilities
src/lib/scheduling/time-slot.ts, src/lib/scheduling/free-slots.ts, src/lib/scheduling/availability.ts
sortSlots/mergeOverlapping 구현, sortDateSlots/mergeOverlappingDateSlots 추가. eventsToBusyRanges, busyEventsToFree, findCommonDateSlots(옵션 durationMinutes) 및 intersectDateSlots 추가.
Scheduling tests
src/lib/scheduling/__tests__/*
Date 기반 교집합·병합·역산과 요일 기반 정렬/병합 경계 케이스 테스트 추가.
Test schedule store & tests
src/lib/schedule-test/store.ts, src/lib/schedule-test/__tests__/store.test.ts
인메모리 테스트 스토어로 schedule 생성/조회/참여자 추가 구현; 토큰 생성·타임싱크 비교·입력 검증 포함, 관련 단위 테스트.
App pages & client components
src/app/*, src/app/schedule/*
홈/생성/참여 페이지와 CreateScheduleClient, ScheduleRoomClient 구현 및 페이지 레벨 변경.
API routes
src/app/api/schedules/*.ts, src/app/api/schedules/[id]/availability/route.ts, src/app/api/schedules/[id]/route.ts
스케줄 생성 POST, 스케줄 GET(hostToken 분기), 참가자 availability POST 엔드포인트 추가 (runtime/dynamic exports 포함).
E2E tests
e2e/host-flow.spec.ts, e2e/participant-flow.spec.ts
Playwright 기반 호스트/참여자 흐름 E2E 테스트 추가.

Sequence Diagram

sequenceDiagram
    actor User
    participant Adapter as Calendar Adapter (google/icloud/manual)
    participant SchedulingCore as Scheduling Logic
    participant Results as Free Slots

    User->>Adapter: Raw Event Data (GoogleEvent[] / ParsedEvent[] / TimeSlot[])
    activate Adapter
    Adapter->>Adapter: Filter + Map (normalize to CalendarEvent[])
    Adapter-->>SchedulingCore: CalendarEvent[]
    deactivate Adapter

    activate SchedulingCore
    SchedulingCore->>SchedulingCore: eventsToBusyRanges (clip & merge)
    SchedulingCore->>SchedulingCore: busyEventsToFree (invert busy→free)
    SchedulingCore-->>Results: TimeRange[] (free slots)
    deactivate SchedulingCore

    Results-->>User: Available Time Windows
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • #13: Playwright E2E flows implemented in this PR align with #13's objective to add concrete host/participant E2E tests.

Suggested labels

feature


지적사항 (핵심, 간결 — TDD·방어적 코딩 강제)

  1. intersectDateSlots 경계값 방어 부족 — Why: 외부 어댑터 입력에 0-길이 또는 역순 시간 가능. How: 오버랩 계산에서 non-positive 길이 필터 추가.
    코드:
// availability.ts
const overlapStart = Math.max(a.startAt.getTime(), b.startAt.getTime());
const overlapEnd = Math.min(a.endAt.getTime(), b.endAt.getTime());
if (overlapStart >= overlapEnd) continue; // 방어: 0 또는 음수 길이 무시
results.push({ startAt: new Date(overlapStart), endAt: new Date(overlapEnd) });
  1. getThisMonday의 시간대(로컬 vs UTC) 의존성 명시 및 테스트 훅 — Why: weekStart 계산이 로컬시간에 따라 달라져 테스트 불안정. How: 함수에 옵션 timezone 주입이나 docstring과 테스트 전용 weekStart 사용 강제.
    코드:
function getThisMonday(now = new Date()): Date {
  const day = (now.getDay() + 6) % 7; // Mon=0..Sun=6
  const monday = new Date(now);
  monday.setHours(0,0,0,0);
  monday.setDate(monday.getDate() - day);
  return monday;
}

(테스트에서는 weekStart 인자를 항상 전달하도록 권장)

  1. manual adapter의 externalId 정책 누락 — Why: Google/iCloud는 externalId 제공, manual은 현재 없음 → 추적/중복 판단 불명확. How: stable externalId 생성 또는 명시적 주석.
    코드:
externalId: `manual:${slot.dayCode}:${slot.startHour}-${slot.endHour}:${index}`,
  1. mergeOverlappingDateSlots 0-duration/정렬 내성 테스트 추가 — Why: busy→free에서 0-duration 범위나 비정상 입력이 생길 수 있음. How: 단위 테스트로 0-duration 무시 및 역순 입력 보장.
    테스트 스니펫:
it('ignores zero-duration slots and handles unordered input', () => {
  const s = [
    { startAt: d('2025-01-02T10:00:00Z'), endAt: d('2025-01-02T10:00:00Z') },
    { startAt: d('2025-01-02T09:00:00Z'), endAt: d('2025-01-02T11:00:00Z') }
  ];
  expect(mergeOverlappingDateSlots(s)).toEqual([{ startAt: d('2025-01-02T09:00:00Z'), endAt: d('2025-01-02T11:00:00Z') }]);
});
  1. API error-string 분기 대신 에러 타입 검사 권장 — Why: 문자열 비교("schedule not found") 취약. How: 전용 Error 클래스 또는 코드 필드를 반환.
    코드:
class NotFoundError extends Error {}
// 사용처:
if (err instanceof NotFoundError) return NextResponse.json({ error: err.message }, { status: 404 });
  1. 테스트 보강: adapter location/description 전달 검증 — Why: 메타정보가 downstream 기능(미래 검색/AI 추천)에 필요. How: google/icloud 테스트에 location/description 필드 assertion 추가.

끝.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning 일부 변경이 #15 범위를 초과한다. 특히 E2E 테스트(host-flow.spec.ts, participant-flow.spec.ts), 홈 페이지 UI(page.tsx), 일정 생성/조회 API 라우트(src/app/api), 테스트 저장소(schedule-test/store.ts)는 명시적 요구사항이 없다. API 라우트·E2E 테스트·UI·테스트 저장소는 #15 요구사항 외 추가 작업이다. 이들을 별도 PR으로 분리하거나 #15 범위 확대를 명시하시오.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 'feat:' 접두사로 시작하며 기본 요구사항을 충족한다.
Description check ✅ Passed PR 설명이 구체적으로 작업 내용(What), 변경 이유(Why), 체크리스트, 관련 이슈를 명시한다.
Linked Issues check ✅ Passed PR은 #15의 모든 주요 요구사항을 충족한다: CalendarEvent 표준 타입, CalendarAdapter 인터페이스, 어댑터 구현(iCloud/Google/Manual), busyEventsToFree, time-slot 정렬/병합, findCommonDateSlots(durationMinutes) 추가.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/15-availability-aggregation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/calendar/adapter.ts`:
- Around line 8-12: 주석이 존재하지 않는 심볼을 안내하고 있어 기여자 혼란을 초래합니다: "ADAPTER_REGISTRY에
등록한다" 문구를 삭제하거나 ADAPTER_REGISTRY가 실제로 구현될 계획이면 명확한 TODO를 추가하세요; 예를 들어 단계 1의
createXxxAdapter()와 단계 2의 toCalendarEvents(원시 → CalendarEvent 매핑) 참조는 유지하되
ADAPTER_REGISTRY를 언급하지 않거나 "TODO: implement ADAPTER_REGISTRY and register
adapters" 같은 문구로 대체해 주세요.

In `@src/lib/calendar/adapters/__tests__/google.test.ts`:
- Around line 31-46: Update the test that calls googleAdapter.toCalendarEvents
to also assert the parsed startAt and endAt values for the all-day event: after
expecting isAllDay and title, verify startAt and endAt represent the date-only
boundaries in UTC (e.g. compare startAt.toISOString() to
"2026-05-12T00:00:00.000Z" and endAt.toISOString() to "2026-05-13T00:00:00.000Z"
or use UTC getters like getUTCFullYear/getUTCMonth/getUTCDate) so the test
catches timezone/parsing regressions in googleAdapter.toCalendarEvents for
date-only events.
- Around line 72-86: Update the GoogleEvent type so summary is optional (make
GoogleEvent.summary: string | undefined | null) and adjust the conversion in
googleAdapter.toCalendarEvents to treat undefined/null/empty string as "(제목
없음)"; then change the test to omit the summary (or set it to undefined) instead
of using an empty string to avoid TypeScript errors and ensure the adapter
handles missing summaries.

In `@src/lib/calendar/adapters/google.ts`:
- Around line 51-54: parseAllDay currently uses new Date(y, m-1, d) which
creates a local-midnight Date and causes multi-timezone off-by-hours; update
parseAllDay to produce a UTC-midnight Date by using Date.UTC(y, m-1, d) (i.e.,
construct the Date from Date.UTC) so server-side behavior is consistent, and if
full correctness per-calendar is required, extend the function to accept an IANA
timezone (from event.start.timeZone or the calendar owner) and convert the
yyyymmdd into that zone using a timezone-aware library (e.g.,
Intl.DateTimeFormat with timeZone or a library like luxon) before returning the
Date.
- Around line 27-36: The toCalendarEvent conversion silently creates Invalid
Date because start.dateTime/end.dateTime are optional; update toCalendarEvent to
explicitly check for presence of dateTime when isAllDay is false and throw or
return a handled error (or skip the event) instead of casting with `as string`,
referencing start.dateTime and end.dateTime in the function; additionally
propagate user timezone into parseAllDay (add a timezone parameter to
parseAllDay and change its callers in toCalendarEvent) so parseAllDay(y,m,d, tz)
produces the user's local-midnight-to-next-midnight range (implement using a
timezone-aware approach or a library rather than new Date(y,m-1,d) to avoid
server-local timezone drift).

In `@src/lib/calendar/adapters/icloud.ts`:
- Around line 12-31: The string literal "icloud" is duplicated in
icloudAdapter.source and inside toCalendarEvent; extract a single constant
(e.g., const ICLOUD_SOURCE = "icloud") and replace both occurrences (use
ICLOUD_SOURCE for icloudAdapter.source and when setting source and id prefix in
toCalendarEvent, e.g., id: `${ICLOUD_SOURCE}:${event.uid}`) so the source value
is defined in one place and reused.

In `@src/lib/calendar/adapters/manual.ts`:
- Around line 33-51: The function manualSlotsToFreeEvents is impure because it
falls back to calling getThisMonday() when options.weekStart is not provided;
remove that fallback so the function only uses the provided options.weekStart
(i.e., do not call getThisMonday() inside manualSlotsToFreeEvents) and export
getThisMonday from the module so callers/tests must compute and inject
weekStart; update callers/tests to call the exported getThisMonday() and pass it
into manualSlotsToFreeEvents (referencing manualSlotsToFreeEvents and
getThisMonday).

In `@src/lib/calendar/adapters/photo.ts`:
- Around line 13-16: Clarify the semantics of PhotoExtractionResult.busy by
adding a focused comment on PhotoExtractionResult and the busy field: state
whether each entry in busy represents a user-occupied interval (i.e., should map
directly to CalendarEvent with free/busy=busy) or represents an extracted free
interval that must be inverted by downstream helpers like busyEventsToFree;
reference the conversion flow (toCalendarEvents and busyEventsToFree) and give
an explicit example of expected input→output (e.g., "busy entries are occupied
times → toCalendarEvents produces busy CalendarEvents; use busyEventsToFree to
invert"). Update comments near the PhotoExtractionResult interface and the
toCalendarEvents/busyEventsToFree usages so future implementers know the
required semantics and transformation order.
- Around line 18-25: Replace the generic runtime throw in
photoAdapter.toCalendarEvents with a clear, distinguishable "not implemented"
error: define and export a custom NotImplementedError class (or reuse a shared
one) and throw new NotImplementedError("photoAdapter.toCalendarEvents: 후속 이슈(AI
사진 추출)에서 구현 예정") inside the toCalendarEvents method of the exported photoAdapter
(type CalendarAdapter<PhotoExtractionResult>) so callers can detect stub
behavior via instanceof NotImplementedError and avoid unexpected crashes when
photoAdapter is present in a CalendarAdapter<T>[] registry.

In `@src/lib/scheduling/__tests__/availability-date.test.ts`:
- Around line 86-94: Split the combined assertion into two independent tests
following AAA: create one test that arranges an empty participants array and
asserts findCommonDateSlots([]) returns [], and a second test that arranges a
single ParticipantDateAvailability (use the existing solo object with
slot(...)), calls findCommonDateSlots([solo]) and asserts it equals
solo.available; keep test names descriptive (e.g., "참여자 0명 → 빈 배열" and "참여자 1명 →
본인 가용시간 그대로") and reuse the existing symbols findCommonDateSlots,
ParticipantDateAvailability, and slot to locate the setup.

In `@src/lib/scheduling/__tests__/time-slot.test.ts`:
- Around line 80-128: Add two tests: (1) For sortDateSlots, add an immutability
test similar to the existing sortSlots test (create a copy of input, call
sortDateSlots, assert the original input array remains unchanged) to catch
implementations that call Array.prototype.sort in-place; reference sortDateSlots
and the existing sortSlots immutability pattern. (2) For
mergeOverlappingDateSlots, add an edge-case test that passes an empty array and
asserts the result is an empty array (length 0), mirroring the mergeOverlapping
empty-array test pattern; reference mergeOverlappingDateSlots and the existing
mergeOverlapping test that covers empty input.

In `@src/lib/scheduling/free-slots.ts`:
- Around line 44-46: The early-return path exposes the original window Date
objects (window.startAt/window.endAt) which can be mutated by callers; change
the return to provide defensive copies by constructing new Date objects for both
startAt and endAt (i.e., mirror the rest of the function’s use of new
Date(cursor)/new Date(windowEnd)) so callers get fresh Date instances and the
input window cannot be polluted.

In `@src/lib/scheduling/time-slot.ts`:
- Around line 53-73: mergeOverlappingDateSlots currently returns references to
the original Date objects (and assigns slot.endAt directly into last.endAt),
which causes caller-side mutations to also mutate the input; update
mergeOverlappingDateSlots to create defensive copies of Date values whenever
constructing or updating results (e.g., push { startAt: new
Date(slot.startAt.getTime()), endAt: new Date(slot.endAt.getTime()) } and when
merging set last.endAt = new Date(slot.endAt.getTime())); keep using
sortDateSlots and preserve the DateTimeSlot shape so callers receive fresh Date
instances and no aliasing to the input slots occurs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b3f4408c-e0cc-4ffe-b16d-6aa46d5fab55

📥 Commits

Reviewing files that changed from the base of the PR and between 3a152d8 and fdbed67.

📒 Files selected for processing (16)
  • src/lib/calendar/adapter.ts
  • src/lib/calendar/adapters/__tests__/google.test.ts
  • src/lib/calendar/adapters/__tests__/icloud.test.ts
  • src/lib/calendar/adapters/__tests__/manual.test.ts
  • src/lib/calendar/adapters/google.ts
  • src/lib/calendar/adapters/icloud.ts
  • src/lib/calendar/adapters/manual.ts
  • src/lib/calendar/adapters/photo.ts
  • src/lib/scheduling/__tests__/availability-date.test.ts
  • src/lib/scheduling/__tests__/free-slots.test.ts
  • src/lib/scheduling/__tests__/time-slot.test.ts
  • src/lib/scheduling/availability.ts
  • src/lib/scheduling/free-slots.ts
  • src/lib/scheduling/time-slot.ts
  • src/types/calendar-event.ts
  • src/types/schedule.ts

Comment on lines +8 to +12
* 새로운 provider를 추가할 때:
* 1. `adapters/<provider>.ts`에 `createXxxAdapter()` 팩토리를 만들고
* 2. `toCalendarEvents`에서 provider raw → CalendarEvent 매핑을 작성한 뒤
* 3. 본 모듈의 `ADAPTER_REGISTRY`에 등록한다.
*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

존재하지 않는 ADAPTER_REGISTRY 참조 — 주석이 잘못된 기여 가이드를 제시한다

Line 11의 ADAPTER_REGISTRY에 등록한다는 이 파일에 실제로 구현되지 않은 구조를 가리킨다. 향후 기여자가 이 주석을 그대로 따르면 존재하지 않는 심볼을 찾거나 잘못된 위치에 코드를 추가할 위험이 있다.

레지스트리를 이후 PR에서 구현할 계획이라면 TODO로 표시하거나, 없다면 해당 단계를 제거해야 한다.

✏️ 수정 예시
- * 3. 본 모듈의 `ADAPTER_REGISTRY`에 등록한다.
+ * 3. (향후) 공통 레지스트리에 등록한다 — TODO: ADAPTER_REGISTRY 구현 후 갱신.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/calendar/adapter.ts` around lines 8 - 12, 주석이 존재하지 않는 심볼을 안내하고 있어 기여자
혼란을 초래합니다: "ADAPTER_REGISTRY에 등록한다" 문구를 삭제하거나 ADAPTER_REGISTRY가 실제로 구현될 계획이면 명확한
TODO를 추가하세요; 예를 들어 단계 1의 createXxxAdapter()와 단계 2의 toCalendarEvents(원시 →
CalendarEvent 매핑) 참조는 유지하되 ADAPTER_REGISTRY를 언급하지 않거나 "TODO: implement
ADAPTER_REGISTRY and register adapters" 같은 문구로 대체해 주세요.

Comment on lines +31 to +46
test("date 기반 종일 이벤트는 isAllDay=true로 변환된다", () => {
const events: GoogleEvent[] = [
{
id: "g-allday",
summary: "휴가",
start: { date: "2026-05-12" },
end: { date: "2026-05-13" },
status: "confirmed",
},
];

const result = googleAdapter.toCalendarEvents(events);

expect(result[0].isAllDay).toBe(true);
expect(result[0].title).toBe("휴가");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

startAt/endAt Date 검증 누락 — 종일 이벤트 파싱 결과를 검증해야 한다

date-only 문자열 "2026-05-12" 파싱은 new Date("2026-05-12")UTC midnight(2026-05-12T00:00:00Z)을 반환하므로, UTC+9 환경에서는 startAt.toLocaleDateString()2026-05-12가 아닌 2026-05-11로 보일 수 있다. 어댑터가 이를 올바르게 처리하는지 검증이 없으면 파싱 버그가 숨겨진다.

🛡️ 검증 보강 예시
  expect(result[0].isAllDay).toBe(true);
  expect(result[0].title).toBe("휴가");
+ // date-only 파싱은 UTC midnight 기준으로 검증
+ expect(result[0].startAt.toISOString()).toBe("2026-05-12T00:00:00.000Z");
+ expect(result[0].endAt.toISOString()).toBe("2026-05-13T00:00:00.000Z");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/calendar/adapters/__tests__/google.test.ts` around lines 31 - 46,
Update the test that calls googleAdapter.toCalendarEvents to also assert the
parsed startAt and endAt values for the all-day event: after expecting isAllDay
and title, verify startAt and endAt represent the date-only boundaries in UTC
(e.g. compare startAt.toISOString() to "2026-05-12T00:00:00.000Z" and
endAt.toISOString() to "2026-05-13T00:00:00.000Z" or use UTC getters like
getUTCFullYear/getUTCMonth/getUTCDate) so the test catches timezone/parsing
regressions in googleAdapter.toCalendarEvents for date-only events.

Comment on lines +72 to +86
test("summary가 비면 '(제목 없음)' 으로 대체한다", () => {
const events: GoogleEvent[] = [
{
id: "g-empty",
summary: "",
start: { dateTime: "2026-05-10T10:00:00Z" },
end: { dateTime: "2026-05-10T11:00:00Z" },
status: "confirmed",
},
];

expect(googleAdapter.toCalendarEvents(events)[0].title).toBe(
"(제목 없음)",
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# GoogleEvent 타입에서 summary 필드의 optional 여부 확인
rg -n "summary" --type=ts -C2 src/types/google-calendar.ts 2>/dev/null || \
  fd -e ts "google-calendar" --exec cat {}

Repository: Siul49/moim

Length of output: 450


🏁 Script executed:

rg -n "toCalendarEvents" --type=ts -A 20 src/lib/calendar/adapters/ | head -100

Repository: Siul49/moim

Length of output: 6949


🏁 Script executed:

fd -e ts -path "*google*" src/lib/calendar/adapters/ --exec cat {} \;

Repository: Siul49/moim

Length of output: 224


🏁 Script executed:

cat -n src/lib/calendar/adapters/google.ts | head -60

Repository: Siul49/moim

Length of output: 1928


🌐 Web query:

Google Calendar API event summary field optional specification

💡 Result:

In the Google Calendar API v3, the Event resource field summary (the event title) is a string that is optional in the sense that you may omit it when creating or updating an event; it can be provided in the event payload as summary, and it will be returned when available. The official Events reference lists summary as part of the Event resource representation, alongside other metadata fields such as description and location.[1] When using the strongly-typed client libraries, summary is represented as an optional/nullable field (e.g., in the Node.js googleapis typings for events.list parameters, summary?: string | null;), which reflects that callers are not required to supply it and that it may be absent or null depending on context.[2] Practical guidance: - If you need an event title, set summary. - If you omit it, the request still remains valid; other event fields (like start/end) are what you must provide. References: the Events resource schema includes summary as a string field in the event representation.[1]

Citations:


타입 정의가 API 스펙과 불일치 — 먼저 GoogleEvent.summary를 optional로 수정하세요

Google Calendar API에서 summary는 선택 필드지만, 현재 타입은 string(필수)으로 정의되어 있다. 제안된 summary: undefined 테스트는 TypeScript 컴파일 에러가 발생한다.

Why: API 응답이 summary 필드를 생략하거나 null로 반환할 수 있으므로, 타입도 이를 반영해야 한다. 현재 구현의 event.summary || "(제목 없음)"은 빈 문자열만 처리하고, 필드 자체가 없는 경우는 보장할 수 없다.

How:

export interface GoogleEvent {
  id: string;
- summary: string;
+ summary?: string;
  description?: string;

그 후 테스트 추가:

+ test("summary가 undefined이면 '(제목 없음)'으로 대체한다", () => {
+   const events: GoogleEvent[] = [
+     {
+       id: "g-no-summary",
+       summary: undefined,
+       start: { dateTime: "2026-05-10T10:00:00Z" },
+       end: { dateTime: "2026-05-10T11:00:00Z" },
+       status: "confirmed",
+     },
+   ];
+   expect(googleAdapter.toCalendarEvents(events)[0].title).toBe("(제목 없음)");
+ });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/calendar/adapters/__tests__/google.test.ts` around lines 72 - 86,
Update the GoogleEvent type so summary is optional (make GoogleEvent.summary:
string | undefined | null) and adjust the conversion in
googleAdapter.toCalendarEvents to treat undefined/null/empty string as "(제목
없음)"; then change the test to omit the summary (or set it to undefined) instead
of using an empty string to avoid TypeScript errors and ensure the adapter
handles missing summaries.

Comment on lines +27 to +36
function toCalendarEvent(event: GoogleEvent): CalendarEvent {
const isAllDay = Boolean(event.start.date && event.end.date);

const startAt = isAllDay
? parseAllDay(event.start.date as string)
: new Date(event.start.dateTime as string);

const endAt = isAllDay
? parseAllDay(event.end.date as string)
: new Date(event.end.dateTime as string);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# GoogleEvent 타입 정의 확인
rg -n "GoogleEvent" --type=ts -A 20

Repository: Siul49/moim

Length of output: 16621


🏁 Script executed:

cat -n src/lib/calendar/adapters/google.ts

Repository: Siul49/moim

Length of output: 1928


🏁 Script executed:

cat -n src/lib/calendar/adapters/__tests__/google.test.ts

Repository: Siul49/moim

Length of output: 3017


as string 캐스트가 런타임 Invalid Date를 묵살한다.

GoogleEventstart.dateTime, end.dateTime은 모두 옵셔널 필드다. L28의 isAllDay 판별이 date 존재만 확인하기 때문에, isAllDay === false인데 dateTimenull 또는 undefined이면 new Date(undefined as string)Invalid Date가 조용히 생성된다. 이 값은 NaN으로 비교 연산에 참여해 가용시간 산출 전체를 오염시킨다.

🐛 방어 코드 추가 제안
-  const startAt = isAllDay
-    ? parseAllDay(event.start.date as string)
-    : new Date(event.start.dateTime as string);
-
-  const endAt = isAllDay
-    ? parseAllDay(event.end.date as string)
-    : new Date(event.end.dateTime as string);
+  const startRaw = isAllDay ? event.start.date : event.start.dateTime;
+  const endRaw   = isAllDay ? event.end.date   : event.end.dateTime;
+
+  if (!startRaw || !endRaw) {
+    throw new Error(
+      `[googleAdapter] 이벤트 ${event.id}: start/end 날짜 필드 누락 (isAllDay=${isAllDay})`,
+    );
+  }
+
+  const startAt = isAllDay ? parseAllDay(startRaw) : new Date(startRaw);
+  const endAt   = isAllDay ? parseAllDay(endRaw)   : new Date(endRaw);

parseAllDay가 타임존 정보 없이 로컬 자정을 생성한다.

L51-54의 new Date(y, m - 1, d)는 로컬 타임존(또는 서버 환경의 시스템 타임존) 기준 자정을 생성한다. 주석 L10-11에서 "종일 이벤트는 사용자 로컬 자정~다음 날 자정으로 해석한다"고 명시했으나, 함수가 사용자 타임존 정보를 받지 않으므로 구현과 의도가 불일치한다. 서버가 UTC이고 사용자가 KST(+9)인 경우, 실제 자정보다 9시간 늦은 시간이 기록되어 가용시간 블로킹이 오염된다.

🔧 타임존 인자 추가 제안
-function parseAllDay(yyyymmdd: string): Date {
+function parseAllDay(yyyymmdd: string, userTimeZone: string = 'UTC'): Date {
   const [y, m, d] = yyyymmdd.split("-").map(Number);
-  return new Date(y, m - 1, d);
+  // 사용자 로컬 자정을 UTC로 변환
+  const formatter = new Intl.DateTimeFormat('en-CA', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    timeZone: userTimeZone,
+  });
+  // 또는 date-fns/day.js 같은 라이브러리 이용
+  // 임시: 호출자가 타임존 변환 후 UTC 기준 Date 전달
 }

(또는 호출자(toCalendarEvent)에 사용자 타임존을 매개변수로 전달하는 구조 권장)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/calendar/adapters/google.ts` around lines 27 - 36, The
toCalendarEvent conversion silently creates Invalid Date because
start.dateTime/end.dateTime are optional; update toCalendarEvent to explicitly
check for presence of dateTime when isAllDay is false and throw or return a
handled error (or skip the event) instead of casting with `as string`,
referencing start.dateTime and end.dateTime in the function; additionally
propagate user timezone into parseAllDay (add a timezone parameter to
parseAllDay and change its callers in toCalendarEvent) so parseAllDay(y,m,d, tz)
produces the user's local-midnight-to-next-midnight range (implement using a
timezone-aware approach or a library rather than new Date(y,m-1,d) to avoid
server-local timezone drift).

Comment thread src/lib/calendar/adapters/google.ts
Comment on lines +18 to +25
export const photoAdapter: CalendarAdapter<PhotoExtractionResult> = {
source: "photo",
toCalendarEvents(_raw) {
throw new Error(
"photoAdapter.toCalendarEvents: 후속 이슈(AI 사진 추출)에서 구현 예정",
);
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Stub throw가 다형성 컨테이너에서 런타임 크래시를 유발할 수 있다

CalendarAdapter<T>[] 리스트나 레지스트리에 photoAdapter가 포함된 채 toCalendarEvents가 호출되면 런타임에 터진다. 현재 레지스트리가 없어도 향후 실수로 등록될 위험이 있다. Error 메시지만으로는 TypeScript 타입 시스템이 이를 막지 못한다.

Guard로 환경을 좁히거나, 최소한 custom error class를 써서 "의도된 NotImplemented"임을 구별 가능하게 해야 한다.

🛡️ NotImplementedError 분리 제안
+class NotImplementedError extends Error {
+  constructor(message: string) {
+    super(message);
+    this.name = "NotImplementedError";
+  }
+}
+
 export const photoAdapter: CalendarAdapter<PhotoExtractionResult> = {
   source: "photo",
   toCalendarEvents(_raw) {
-    throw new Error(
+    throw new NotImplementedError(
       "photoAdapter.toCalendarEvents: 후속 이슈(AI 사진 추출)에서 구현 예정",
     );
   },
 };

호출부에서 instanceof NotImplementedError로 stub과 진짜 오류를 구분할 수 있다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/calendar/adapters/photo.ts` around lines 18 - 25, Replace the generic
runtime throw in photoAdapter.toCalendarEvents with a clear, distinguishable
"not implemented" error: define and export a custom NotImplementedError class
(or reuse a shared one) and throw new
NotImplementedError("photoAdapter.toCalendarEvents: 후속 이슈(AI 사진 추출)에서 구현 예정")
inside the toCalendarEvents method of the exported photoAdapter (type
CalendarAdapter<PhotoExtractionResult>) so callers can detect stub behavior via
instanceof NotImplementedError and avoid unexpected crashes when photoAdapter is
present in a CalendarAdapter<T>[] registry.

Comment on lines +86 to +94
test("참여자 0명이면 빈 배열, 1명이면 본인 가용시간 그대로", () => {
expect(findCommonDateSlots([])).toEqual([]);

const solo: ParticipantDateAvailability = {
userId: "solo",
available: [slot("2026-05-10T10:00:00Z", "2026-05-10T12:00:00Z")],
};
expect(findCommonDateSlots([solo])).toEqual(solo.available);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

하나의 test에 독립된 두 동작이 혼재한다 — AAA 패턴 위반.

"참여자 0명 → 빈 배열"과 "참여자 1명 → 본인 가용시간 그대로"는 각각 별개의 행동 규약이다. 두 케이스를 하나의 test에 넣으면, 어느 쪽이 실패해도 같은 테스트 이름으로 리포트되어 원인 진단이 어려워진다.

♻️ 테스트 분리 제안
-  test("참여자 0명이면 빈 배열, 1명이면 본인 가용시간 그대로", () => {
-    expect(findCommonDateSlots([])).toEqual([]);
-
-    const solo: ParticipantDateAvailability = {
-      userId: "solo",
-      available: [slot("2026-05-10T10:00:00Z", "2026-05-10T12:00:00Z")],
-    };
-    expect(findCommonDateSlots([solo])).toEqual(solo.available);
-  });
+  test("참여자가 0명이면 빈 배열을 반환한다", () => {
+    expect(findCommonDateSlots([])).toEqual([]);
+  });
+
+  test("참여자가 1명이면 해당 참여자의 가용시간을 그대로 반환한다", () => {
+    const solo: ParticipantDateAvailability = {
+      userId: "solo",
+      available: [slot("2026-05-10T10:00:00Z", "2026-05-10T12:00:00Z")],
+    };
+    expect(findCommonDateSlots([solo])).toEqual(solo.available);
+  });

코딩 가이드라인에서 "AAA 패턴(Arrange-Act-Assert)을 따르는지 확인"을 명시하고 있다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test("참여자 0명이면 빈 배열, 1명이면 본인 가용시간 그대로", () => {
expect(findCommonDateSlots([])).toEqual([]);
const solo: ParticipantDateAvailability = {
userId: "solo",
available: [slot("2026-05-10T10:00:00Z", "2026-05-10T12:00:00Z")],
};
expect(findCommonDateSlots([solo])).toEqual(solo.available);
});
test("참여자가 0명이면 빈 배열을 반환한다", () => {
expect(findCommonDateSlots([])).toEqual([]);
});
test("참여자가 1명이면 해당 참여자의 가용시간을 그대로 반환한다", () => {
const solo: ParticipantDateAvailability = {
userId: "solo",
available: [slot("2026-05-10T10:00:00Z", "2026-05-10T12:00:00Z")],
};
expect(findCommonDateSlots([solo])).toEqual(solo.available);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/scheduling/__tests__/availability-date.test.ts` around lines 86 - 94,
Split the combined assertion into two independent tests following AAA: create
one test that arranges an empty participants array and asserts
findCommonDateSlots([]) returns [], and a second test that arranges a single
ParticipantDateAvailability (use the existing solo object with slot(...)), calls
findCommonDateSlots([solo]) and asserts it equals solo.available; keep test
names descriptive (e.g., "참여자 0명 → 빈 배열" and "참여자 1명 → 본인 가용시간 그대로") and reuse
the existing symbols findCommonDateSlots, ParticipantDateAvailability, and slot
to locate the setup.

Comment on lines +80 to +128
describe("sortDateSlots — Date 기반 정렬", () => {
test("startAt 오름차순으로 정렬한다", () => {
const input: DateTimeSlot[] = [
{ startAt: iso("2026-05-10T14:00:00Z"), endAt: iso("2026-05-10T15:00:00Z") },
{ startAt: iso("2026-05-09T10:00:00Z"), endAt: iso("2026-05-09T11:00:00Z") },
];

const result = sortDateSlots(input);

expect(result[0].startAt.toISOString()).toBe("2026-05-09T10:00:00.000Z");
expect(result[1].startAt.toISOString()).toBe("2026-05-10T14:00:00.000Z");
});
});

describe("mergeOverlappingDateSlots — Date 기반 병합", () => {
test("겹치는 시간 범위를 하나로 합친다", () => {
const input: DateTimeSlot[] = [
{ startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T11:00:00Z") },
{ startAt: iso("2026-05-10T10:30:00Z"), endAt: iso("2026-05-10T12:00:00Z") },
];

const result = mergeOverlappingDateSlots(input);

expect(result).toHaveLength(1);
expect(result[0].startAt.toISOString()).toBe("2026-05-10T09:00:00.000Z");
expect(result[0].endAt.toISOString()).toBe("2026-05-10T12:00:00.000Z");
});

test("인접(end===start)한 슬롯도 병합한다", () => {
const input: DateTimeSlot[] = [
{ startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T10:00:00Z") },
{ startAt: iso("2026-05-10T10:00:00Z"), endAt: iso("2026-05-10T11:00:00Z") },
];

const result = mergeOverlappingDateSlots(input);

expect(result).toHaveLength(1);
expect(result[0].endAt.toISOString()).toBe("2026-05-10T11:00:00.000Z");
});

test("겹치지 않으면 그대로 둔다", () => {
const input: DateTimeSlot[] = [
{ startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T10:00:00Z") },
{ startAt: iso("2026-05-10T11:00:00Z"), endAt: iso("2026-05-10T12:00:00Z") },
];

expect(mergeOverlappingDateSlots(input)).toHaveLength(2);
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

sortDateSlots 불변성 테스트와 Date 변형 함수들의 빈 배열 엣지케이스 누락

두 가지 누락:

  1. sortSlots에는 불변성 테스트(Line 27-37)가 있는데, sortDateSlots에는 없다. 구현이 내부적으로 sort()를 직접 호출하면 입력 배열을 변형한다.
  2. mergeOverlappingDateSlots에 빈 배열([]) 케이스 없음. mergeOverlapping(Line 75-77)에는 있다.
🛡️ 누락 케이스 추가
 describe("sortDateSlots — Date 기반 정렬", () => {
+  test("입력 배열을 변형하지 않는다", () => {
+    const input: DateTimeSlot[] = [
+      { startAt: iso("2026-05-10T14:00:00Z"), endAt: iso("2026-05-10T15:00:00Z") },
+      { startAt: iso("2026-05-09T10:00:00Z"), endAt: iso("2026-05-09T11:00:00Z") },
+    ];
+    const original = input.map((s) => ({ ...s }));
+    sortDateSlots(input);
+    expect(input[0].startAt.toISOString()).toBe(original[0].startAt.toISOString());
+  });
+
+  test("빈 배열은 빈 배열을 반환한다", () => {
+    expect(sortDateSlots([])).toEqual([]);
+  });
 });

 describe("mergeOverlappingDateSlots — Date 기반 병합", () => {
+  test("빈 배열은 빈 배열을 반환한다", () => {
+    expect(mergeOverlappingDateSlots([])).toEqual([]);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/scheduling/__tests__/time-slot.test.ts` around lines 80 - 128, Add
two tests: (1) For sortDateSlots, add an immutability test similar to the
existing sortSlots test (create a copy of input, call sortDateSlots, assert the
original input array remains unchanged) to catch implementations that call
Array.prototype.sort in-place; reference sortDateSlots and the existing
sortSlots immutability pattern. (2) For mergeOverlappingDateSlots, add an
edge-case test that passes an empty array and asserts the result is an empty
array (length 0), mirroring the mergeOverlapping empty-array test pattern;
reference mergeOverlappingDateSlots and the existing mergeOverlapping test that
covers empty input.

Comment thread src/lib/scheduling/free-slots.ts
Comment thread src/lib/scheduling/time-slot.ts
@Siul49
Siul49 merged commit d03259a into dev May 27, 2026
2 of 4 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request May 30, 2026
11 tasks
@Siul49
Siul49 deleted the feature/15-availability-aggregation branch June 2, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant