feat(scheduling): 캘린더 통합 가용시간 산출 프레임워크 (Date 기반 어댑터) - #16
Conversation
- 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
|
Caution Review failedFailed to post review comments Summary by CodeRabbit
Walkthrough캘린더 제공자별 원본 이벤트를 표준 Changes캘린더 어댑터 및 Date 기반 스케줄링 프레임워크
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Suggested labels
지적사항 (핵심, 간결 — TDD·방어적 코딩 강제)
// 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) });
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 인자를 항상 전달하도록 권장)
externalId: `manual:${slot.dayCode}:${slot.startHour}-${slot.endHour}:${index}`,
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') }]);
});
class NotFoundError extends Error {}
// 사용처:
if (err instanceof NotFoundError) return NextResponse.json({ error: err.message }, { status: 404 });
끝. 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (16)
src/lib/calendar/adapter.tssrc/lib/calendar/adapters/__tests__/google.test.tssrc/lib/calendar/adapters/__tests__/icloud.test.tssrc/lib/calendar/adapters/__tests__/manual.test.tssrc/lib/calendar/adapters/google.tssrc/lib/calendar/adapters/icloud.tssrc/lib/calendar/adapters/manual.tssrc/lib/calendar/adapters/photo.tssrc/lib/scheduling/__tests__/availability-date.test.tssrc/lib/scheduling/__tests__/free-slots.test.tssrc/lib/scheduling/__tests__/time-slot.test.tssrc/lib/scheduling/availability.tssrc/lib/scheduling/free-slots.tssrc/lib/scheduling/time-slot.tssrc/types/calendar-event.tssrc/types/schedule.ts
| * 새로운 provider를 추가할 때: | ||
| * 1. `adapters/<provider>.ts`에 `createXxxAdapter()` 팩토리를 만들고 | ||
| * 2. `toCalendarEvents`에서 provider raw → CalendarEvent 매핑을 작성한 뒤 | ||
| * 3. 본 모듈의 `ADAPTER_REGISTRY`에 등록한다. | ||
| */ |
There was a problem hiding this comment.
존재하지 않는 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" 같은 문구로 대체해 주세요.
| 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("휴가"); | ||
| }); |
There was a problem hiding this comment.
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.
| 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( | ||
| "(제목 없음)", | ||
| ); | ||
| }); |
There was a problem hiding this comment.
🧩 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 -100Repository: 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 -60Repository: 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:
- 1: https://developers.google.com/workspace/calendar/api/v3/reference/events
- 2: https://googleapis.dev/nodejs/googleapis/latest/calendar/interfaces/Params$Resource$Events$List.html
타입 정의가 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.
| 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); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# GoogleEvent 타입 정의 확인
rg -n "GoogleEvent" --type=ts -A 20Repository: Siul49/moim
Length of output: 16621
🏁 Script executed:
cat -n src/lib/calendar/adapters/google.tsRepository: Siul49/moim
Length of output: 1928
🏁 Script executed:
cat -n src/lib/calendar/adapters/__tests__/google.test.tsRepository: Siul49/moim
Length of output: 3017
as string 캐스트가 런타임 Invalid Date를 묵살한다.
GoogleEvent의 start.dateTime, end.dateTime은 모두 옵셔널 필드다. L28의 isAllDay 판별이 date 존재만 확인하기 때문에, isAllDay === false인데 dateTime이 null 또는 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).
| export const photoAdapter: CalendarAdapter<PhotoExtractionResult> = { | ||
| source: "photo", | ||
| toCalendarEvents(_raw) { | ||
| throw new Error( | ||
| "photoAdapter.toCalendarEvents: 후속 이슈(AI 사진 추출)에서 구현 예정", | ||
| ); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
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.
| 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); | ||
| }); |
There was a problem hiding this comment.
하나의 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.
| 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.
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
sortDateSlots 불변성 테스트와 Date 변형 함수들의 빈 배열 엣지케이스 누락
두 가지 누락:
sortSlots에는 불변성 테스트(Line 27-37)가 있는데,sortDateSlots에는 없다. 구현이 내부적으로sort()를 직접 호출하면 입력 배열을 변형한다.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.
🚀 작업 내용 (What)
CalendarEvent표준 타입과TimeRange/SearchWindow정의 (src/types/calendar-event.ts)DateTimeSlot,ParticipantDateAvailability를schedule.ts에 추가time-slot.ts에sortSlots/mergeOverlapping구현 +sortDateSlots/mergeOverlappingDateSlots추가free-slots.ts신규: busy 이벤트를 검색 윈도우 내 free 슬롯으로 반전 (busyEventsToFree,eventsToBusyRanges)findCommonDateSlots(participants, { durationMinutes })신규: Date 기반 교집합 + 회의 길이 필터CalendarAdapter<TRaw>인터페이스와 iCloud(ParsedEvent)·Google(GoogleEvent)·Manual(TimeSlot) 어댑터 구현adapters/photo.ts)는 후속 이슈 자리만 잡아둔 스텁📣 핵심 변경 이유 (Why)
findCommonSlots는 요일+정수 시간 단위라 30분 단위 슬롯·실제 Date 이벤트(Google/iCloud)·회의 길이 필터를 처리할 수 없었다.findCommonSlots는 그대로 두어 호환성을 유지하고, 신규 코드만 Date 기반으로 마이그레이션할 수 있도록 두 파이프라인을 공존시켰다.🔗 관련 이슈 (Issue)
Close #15