feat(calendar): 실연동 일정·반복(recurrence)·일정 에디터·드래그/뷰 개선

- Google/Outlook 캘린더 실연동 라우터, 회의 머티리얼라이즈 갱신
- 반복 일정·RSVP·알림, EvEditor(생성/편집) 추가, 월/주/일 뷰·미니캘린더 개선
- 라우팅 [[...slug]], 관련 테스트(api_calendar·phase16·views)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
I Luk Kim 2 months ago
parent 9db9ed7242
commit ebba607f49

@ -1,18 +1,35 @@
# backend/app/routers/calendar.py # backend/app/routers/calendar.py
import re import re
import uuid import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
from ..connectors import get_calendar_connector from ..connectors.calendar import get_calendar_connector, real_google, real_outlook
from ..connectors.calendar.normalize import account_calendar_id
from ..db import get_session from ..db import get_session
from ..models import Calendar, CalEvent, FocusBlock, Meeting, MeetingAction, Task from ..models import (
Calendar,
CalEvent,
ConnectorAccount,
ConnectorDomain,
ConnectorMode,
ConnState,
ExternalLink,
FocusBlock,
Meeting,
MeetingAction,
Task,
)
from ..schemas import ( from ..schemas import (
AttendeeOut,
CalendarOut, CalendarOut,
CalEventOut, CalEventOut,
DayBundleOut, DayBundleOut,
EventActionOut, EventActionOut,
EventRsvpRequest,
EventWriteRequest,
FocusBlockOut, FocusBlockOut,
FocusSuggestRequest, FocusSuggestRequest,
FocusSuggestResponse, FocusSuggestResponse,
@ -30,6 +47,29 @@ router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
WEEK = [7, 8, 9, 10, 11, 12, 13] WEEK = [7, 8, 9, 10, 11, 12, 13]
WEEKDAYS = ["", "", "", "", "", "", ""] WEEKDAYS = ["", "", "", "", "", "", ""]
TODAY = 8 TODAY = 8
DEMO_TODAY_DATE = "2026-06-08" # 데모(시드) 기준일 — 실연결 없을 때
def _display_date(day: int, real_date: str) -> str:
"""이벤트 표시용 ISO 날짜. 실데이터는 date, 데모(시드)는 6월 day 로 매핑."""
if real_date:
return real_date
try:
return f"2026-06-{int(day):02d}"
except (TypeError, ValueError):
return DEMO_TODAY_DATE
def _calendar_mode(s: Session) -> tuple[str, str]:
"""(mode, today_date). real 캘린더가 연결돼 있으면 실제 오늘, 아니면 데모 기준일."""
has_real = s.exec(
select(ConnectorAccount).where(
ConnectorAccount.domain == ConnectorDomain.calendar,
ConnectorAccount.mode == ConnectorMode.real,
ConnectorAccount.state == ConnState.connected,
)
).first()
return ("real", date.today().isoformat()) if has_real else ("demo", DEMO_TODAY_DATE)
def _end_from(start: str, dur: int) -> str: def _end_from(start: str, dur: int) -> str:
@ -108,6 +148,7 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut:
return CalEventOut( return CalEventOut(
id=e.id, id=e.id,
day=e.day, day=e.day,
date=_display_date(e.day, e.date),
start=e.start, start=e.start,
end=e.end, end=e.end,
title=e.title, title=e.title,
@ -116,6 +157,15 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut:
note=e.note, note=e.note,
soon=e.soon, soon=e.soon,
people=[p.strip() for p in e.people.split(",") if p.strip()], people=[p.strip() for p in e.people.split(",") if p.strip()],
rrule=e.rrule or "",
reminders=list(e.reminders or []),
attendees=[
AttendeeOut(
email=a.get("email", ""), name=a.get("name", ""), status=a.get("status", "")
)
for a in (e.attendees or [])
],
response_status=e.response_status or "",
actions=[EventActionOut(text=a.text, when=a.when_text) for a in eacts], actions=[EventActionOut(text=a.text, when=a.when_text) for a in eacts],
has_meeting=m is not None, has_meeting=m is not None,
meet_label=meet_label(m), meet_label=meet_label(m),
@ -124,15 +174,18 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut:
@router.get("/calendar/week", response_model=WeekBundleOut) @router.get("/calendar/week", response_model=WeekBundleOut)
def get_week(s: Session = Depends(get_session)): def get_week(s: Session = Depends(get_session)):
conn = get_calendar_connector() # mock-first conn = get_calendar_connector()
rows = [s.get(CalEvent, r["id"]) for r in conn.list_events(s)] rows = [s.get(CalEvent, r["id"]) for r in conn.list_events(s)]
cals = s.exec(select(Calendar).order_by(Calendar.sort_order)).all() cals = s.exec(select(Calendar).order_by(Calendar.sort_order)).all()
counts: dict[str, int] = {} counts: dict[str, int] = {}
for e in rows: for e in rows:
counts[e.cal] = counts.get(e.cal, 0) + 1 counts[e.cal] = counts.get(e.cal, 0) + 1
fbs = s.exec(select(FocusBlock).order_by(FocusBlock.sort_order)).all() fbs = s.exec(select(FocusBlock).order_by(FocusBlock.sort_order)).all()
mode, today_date = _calendar_mode(s)
return WeekBundleOut( return WeekBundleOut(
today=TODAY, today=TODAY,
today_date=today_date,
mode=mode,
week=WEEK, week=WEEK,
weekdays=WEEKDAYS, weekdays=WEEKDAYS,
calendars=[ calendars=[
@ -140,10 +193,16 @@ def get_week(s: Session = Depends(get_session)):
for c in cals for c in cals
], ],
events=[_event_out(s, e) for e in rows], events=[_event_out(s, e) for e in rows],
focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs], focus_blocks=[_focus_out(f) for f in fbs],
) )
def _focus_out(f: FocusBlock) -> FocusBlockOut:
d = f.model_dump()
d["date"] = _display_date(f.day, "") # 집중 블록은 데모 전용 → 6월로 매핑
return FocusBlockOut(**d)
@router.get("/calendar/day/{day}", response_model=DayBundleOut) @router.get("/calendar/day/{day}", response_model=DayBundleOut)
def get_day(day: int, s: Session = Depends(get_session)): def get_day(day: int, s: Session = Depends(get_session)):
evs = s.exec(select(CalEvent).where(CalEvent.day == day).order_by(CalEvent.start)).all() evs = s.exec(select(CalEvent).where(CalEvent.day == day).order_by(CalEvent.start)).all()
@ -151,7 +210,7 @@ def get_day(day: int, s: Session = Depends(get_session)):
return DayBundleOut( return DayBundleOut(
day=day, day=day,
events=[_event_out(s, e) for e in evs], events=[_event_out(s, e) for e in evs],
focus_blocks=[FocusBlockOut(**f.model_dump()) for f in fbs], focus_blocks=[_focus_out(f) for f in fbs],
) )
@ -227,3 +286,167 @@ def focus_suggest(body: FocusSuggestRequest, s: Session = Depends(get_session)):
s.add(b) s.add(b)
s.commit() s.commit()
return FocusSuggestResponse(day=body.day, created=body.create, suggestions=out) return FocusSuggestResponse(day=body.day, created=body.create, suggestions=out)
# ── 일정 쓰기(생성·수정·삭제). 연결된 Google 캘린더에 실제 반영 + 로컬 미러. ──
LOCAL_CAL_ID = "local"
def _ensure_local_calendar(s: Session) -> str:
"""외부 캘린더 미연결 시 사용할 로컬 캘린더 행 보장."""
if not s.get(Calendar, LOCAL_CAL_ID):
s.add(Calendar(id=LOCAL_CAL_ID, name="내 캘린더", tone="green", on=True, sort_order=10))
s.commit()
return LOCAL_CAL_ID
_CAL_WRITERS = {"google_calendar": real_google, "outlook_calendar": real_outlook}
def _cal_writer(account: ConnectorAccount):
"""계정 provider 에 맞는 쓰기 모듈(real_google / real_outlook)."""
return _CAL_WRITERS.get(account.provider)
def _writable_calendar_account(s: Session, cal_id: str = "") -> ConnectorAccount | None:
"""쓰기 가능한 연결 캘린더 계정(Google·Outlook 모두 쓰기 지원).
cal_id 특정 캘린더를 가리키면 계정, 아니면 연결 캘린더 계정."""
accts = s.exec(
select(ConnectorAccount).where(
ConnectorAccount.domain == ConnectorDomain.calendar,
ConnectorAccount.state == ConnState.connected,
)
).all()
accts = [a for a in accts if _cal_writer(a)] # 쓰기 지원 provider 만
if cal_id:
for a in accts:
if account_calendar_id(a) == cal_id:
return a
return accts[0] if accts else None
def _event_link(s: Session, eid: str) -> ExternalLink | None:
return s.exec(
select(ExternalLink).where(
ExternalLink.entity_id == eid, ExternalLink.entity_type == "event"
)
).first()
def _account_for_event(s: Session, eid: str) -> ConnectorAccount | None:
link = _event_link(s, eid)
return s.get(ConnectorAccount, link.account_id) if link else None
def _local_day(date_iso: str) -> int:
try:
return int(date_iso[8:10])
except (ValueError, IndexError):
return TODAY
@router.post("/calendar/events", response_model=CalEventOut)
def create_event(body: EventWriteRequest, s: Session = Depends(get_session)):
acct = _writable_calendar_account(s, body.cal)
if acct: # 연결된 캘린더(Google/Outlook)에 실제 생성 후 로컬 미러
writer = _cal_writer(acct)
ev_json = writer.create_remote_event(s, acct, body.model_dump())
eid = writer.mirror_event(s, acct, ev_json)
s.commit()
else: # 외부 캘린더 미연결 → 로컬 전용 이벤트
cal_id = _ensure_local_calendar(s)
eid = "ce-" + uuid.uuid4().hex[:8]
end = body.end or (_end_from(body.start, 60) if body.start else "")
s.add(
CalEvent(
id=eid,
day=_local_day(body.date),
date=body.date,
start=body.start, # 빈값 = 종일
end=end,
title=body.title,
cal=cal_id,
loc=body.loc,
note=body.note,
people=", ".join(body.people),
rrule=body.rrule,
reminders=list(body.reminders),
attendees=[{"email": e, "name": "", "status": "needsAction"} for e in body.people],
soon=False,
sort_order=0,
)
)
s.commit()
return _event_out(s, s.get(CalEvent, eid))
@router.patch("/calendar/events/{eid}", response_model=CalEventOut)
def update_event(eid: str, body: EventWriteRequest, s: Session = Depends(get_session)):
e = s.get(CalEvent, eid)
if not e:
raise HTTPException(404, "event not found")
acct = _account_for_event(s, eid)
writer = _cal_writer(acct) if acct else None
if acct and writer: # 연결된 캘린더(Google/Outlook)에 실제 수정
link = _event_link(s, eid)
ev_json = writer.update_remote_event(s, acct, link.external_id, body.model_dump())
writer.mirror_event(s, acct, ev_json)
s.commit()
else: # 로컬 전용 이벤트
e.title = body.title
e.date = body.date
e.day = _local_day(body.date)
e.start = body.start
e.end = body.end if body.start else ""
e.loc = body.loc
e.note = body.note
e.people = ", ".join(body.people)
e.rrule = body.rrule
e.reminders = list(body.reminders)
e.attendees = [{"email": p, "name": "", "status": "needsAction"} for p in body.people]
s.add(e)
s.commit()
return _event_out(s, s.get(CalEvent, eid))
@router.post("/calendar/events/{eid}/rsvp", response_model=CalEventOut)
def rsvp_event(eid: str, body: EventRsvpRequest, s: Session = Depends(get_session)):
"""초대 응답(RSVP). 연결된 캘린더면 Google/Outlook 에 실제 응답 전송 + 로컬 반영."""
if body.status not in ("accepted", "declined", "tentative"):
raise HTTPException(400, "status must be accepted|declined|tentative")
e = s.get(CalEvent, eid)
if not e:
raise HTTPException(404, "event not found")
acct = _account_for_event(s, eid)
writer = _cal_writer(acct) if acct else None
link = _event_link(s, eid)
if acct and writer and link and hasattr(writer, "respond_to_event"):
writer.respond_to_event(s, acct, link.external_id, body.status)
e.response_status = body.status
if acct: # 참석자 목록에서 내 상태도 갱신
myemail = (acct.external_account_id or "").lower()
atts = list(e.attendees or [])
for a in atts:
if a.get("email", "").lower() == myemail:
a["status"] = body.status
e.attendees = atts
s.add(e)
s.commit()
return _event_out(s, s.get(CalEvent, eid))
@router.delete("/calendar/events/{eid}")
def delete_event(eid: str, s: Session = Depends(get_session)):
e = s.get(CalEvent, eid)
if not e:
raise HTTPException(404, "event not found")
acct = _account_for_event(s, eid)
writer = _cal_writer(acct) if acct else None
link = _event_link(s, eid)
if acct and writer and link: # 연결된 캘린더(Google/Outlook)에서 실제 삭제
writer.delete_remote_event(s, acct, link.external_id)
if link:
s.delete(link)
s.delete(e)
s.commit()
return {"ok": True, "id": eid}

@ -1,45 +1,49 @@
# backend/tests/test_api_calendar.py # backend/tests/test_api_calendar.py
def test_week_bundle(client): # phase-16+: 일정 mock 시드 제거 → 기본은 빈 상태. 기능 테스트는 직접 생성.
from tests._factories import make_cal_event, make_meeting, make_meeting_action
def test_week_bundle_empty(client):
r = client.get("/api/calendar/week") r = client.get("/api/calendar/week")
assert r.status_code == 200 assert r.status_code == 200
b = r.json() b = r.json()
assert b["today"] == 8 and b["week"] == [7, 8, 9, 10, 11, 12, 13] assert b["today"] == 8 and b["week"] == [7, 8, 9, 10, 11, 12, 13]
assert b["weekdays"] == ["", "", "", "", "", "", ""] assert b["weekdays"] == ["", "", "", "", "", "", ""]
assert len(b["calendars"]) == 5 and len(b["events"]) == 22 and len(b["focus_blocks"]) == 4 # 일정 연결 전: 캘린더/이벤트/포커스 모두 비어 있음
team = next(c for c in b["calendars"] if c["id"] == "team") assert b["calendars"] == [] and b["events"] == [] and b["focus_blocks"] == []
assert team["count"] == sum(1 for e in b["events"] if e["cal"] == "team")
def test_week_bundle_with_event(client, session):
s, _ = session
make_cal_event(s, id="e1", day=8, start="14:00", end="15:00", title="분기 전략 미팅",
cal="work", soon=True)
b = client.get("/api/calendar/week").json()
assert any(e["id"] == "e1" for e in b["events"])
def test_day_bundle_has_meeting_and_actions(client): def test_day_bundle_with_meeting_and_actions(client, session):
s, _ = session
make_cal_event(s, id="e3", day=8, start="10:00", end="10:30", title="팀 스탠드업", cal="team")
make_meeting(s, event_id="e3", phase="done")
make_meeting_action(s, id="ea1", event_id="e3", idx=0, text="피드백 정리",
when_text="오늘", source="event")
b = client.get("/api/calendar/day/8").json() b = client.get("/api/calendar/day/8").json()
e3 = next(e for e in b["events"] if e["id"] == "e3") e3 = next(e for e in b["events"] if e["id"] == "e3")
assert e3["has_meeting"] is True assert e3["has_meeting"] is True
assert e3["meet_label"] == "회의 노트 · 액션 보기" assert [a["text"] for a in e3["actions"]] == ["피드백 정리"]
assert [a["text"] for a in e3["actions"]] == [
"온보딩 와이어프레임 피드백 정리",
"푸시 알림 QA 결과 공유",
]
e6 = next(e for e in b["events"] if e["id"] == "e6")
assert e6["soon"] is True and e6["meet_label"] == "사전 브리핑 보기"
assert e6["people"] == ["대표님", "재무팀장", "나 외 3명"]
def test_meeting_phases(client): def test_meeting_phase(client, session):
s, _ = session
make_cal_event(s, id="e3", title="팀 스탠드업", cal="team")
make_meeting(s, event_id="e3", phase="done")
assert client.get("/api/calendar/meetings/e3").json()["phase"] == "done" assert client.get("/api/calendar/meetings/e3").json()["phase"] == "done"
assert client.get("/api/calendar/meetings/e4").json()["phase"] == "live"
up = client.get("/api/calendar/meetings/e6").json()
assert up["phase"] == "upcoming" and len(up["agenda"]) == 3 and len(up["docs"]) == 2
oo = client.get("/api/calendar/meetings/e7").json()
assert oo["one_on_one"] is True and oo["person"]["name"] == "민서"
assert len(oo["talking_points"]) == 3
def test_meeting_404(client): def test_meeting_404(client):
assert client.get("/api/calendar/meetings/nope").status_code == 404 assert client.get("/api/calendar/meetings/nope").status_code == 404
def test_day_focus_blocks(client): def test_day_focus_blocks_empty(client):
b = client.get("/api/calendar/day/8").json() b = client.get("/api/calendar/day/8").json()
assert len(b["focus_blocks"]) == 4 assert b["focus_blocks"] == [] # 포커스 시드 제거
deep = [f for f in b["focus_blocks"] if f["type"] == "deep"]
assert len(deep) == 1 and deep[0]["title"] == "분기 리포트 초안 마무리"

@ -1,39 +1,53 @@
# backend/tests/test_meeting_materialize.py # backend/tests/test_meeting_materialize.py
# phase-16+: 일정 시드 제거 → 회의/액션을 테스트가 직접 생성.
from app.models import Task from app.models import Task
from tests._factories import make_cal_event, make_meeting, make_meeting_action
def _setup_e3(s):
make_cal_event(s, id="e3", cal="team", title="팀 스탠드업")
make_meeting(s, event_id="e3", phase="done")
make_meeting_action(s, id="ma1", meeting_id="e3", idx=2, text="매출 데이터 전달", who="현우")
def test_materialize_one_creates_task(client, session): def test_materialize_one_creates_task(client, session):
s, _ = session s, _ = session
# e3 idx=2 "매출 데이터 전달" (현우, added=False) _setup_e3(s)
r = client.post("/api/calendar/meetings/e3/actions/2/materialize") r = client.post("/api/calendar/meetings/e3/actions/2/materialize")
assert r.status_code == 200 assert r.status_code == 200
data = r.json() data = r.json()
assert data["action"]["added"] is True assert data["action"]["added"] is True
assert data["task"]["title"] == "매출 데이터 전달" assert data["task"]["title"] == "매출 데이터 전달"
assert data["task"]["assignee_id"] == "hyunwoo" # who 나매핑 assert data["task"]["assignee_id"] == "hyunwoo" # who '현우' → hyunwoo
assert data["task"]["project_id"] == "biz" # team/work/meeting → biz assert data["task"]["project_id"] == "biz" # team/work/meeting → biz
assert data["all_added"] is True # e3 의 마지막 미처리 액션이었음 assert data["all_added"] is True # 유일한 미처리 액션이었음
tid = data["task"]["id"] assert s.get(Task, data["task"]["id"]) is not None
assert s.get(Task, tid) is not None # federation: 작업 페이지에 등장
def test_materialize_idempotent(client): def test_materialize_idempotent(client, session):
s, _ = session
_setup_e3(s)
r1 = client.post("/api/calendar/meetings/e3/actions/2/materialize") r1 = client.post("/api/calendar/meetings/e3/actions/2/materialize")
t1 = r1.json()["task"]["id"] t1 = r1.json()["task"]["id"]
r2 = client.post("/api/calendar/meetings/e3/actions/2/materialize") r2 = client.post("/api/calendar/meetings/e3/actions/2/materialize")
assert r2.json()["task"]["id"] == t1 # 중복 생성 없음 assert r2.json()["task"]["id"] == t1 # 중복 생성 없음
def test_materialize_all(client): def test_materialize_all(client, session):
# e4(live) 액션 1건 모두 s, _ = session
r = client.post("/api/calendar/meetings/e4/actions/materialize-all") make_cal_event(s, id="e4", cal="work", title="디자인 리뷰")
arr = r.json() make_meeting(s, event_id="e4", phase="live")
make_meeting_action(s, id="ea1", meeting_id="e4", idx=0,
text="온보딩 3번 화면 CTA 수정안 반영", who="")
arr = client.post("/api/calendar/meetings/e4/actions/materialize-all").json()
assert len(arr) == 1 assert len(arr) == 1
assert arr[0]["task"]["title"] == "온보딩 3번 화면 CTA 수정안 반영" assert arr[0]["task"]["title"] == "온보딩 3번 화면 CTA 수정안 반영"
assert arr[0]["all_added"] is True assert arr[0]["all_added"] is True
def test_materialize_action_appears_in_tasks(client): def test_materialize_action_appears_in_tasks(client, session):
s, _ = session
_setup_e3(s)
client.post("/api/calendar/meetings/e3/actions/2/materialize") client.post("/api/calendar/meetings/e3/actions/2/materialize")
tasks = client.get("/api/tasks?area=work").json() tasks = client.get("/api/tasks?area=work").json()

@ -0,0 +1,137 @@
# backend/tests/test_phase16_calendar.py — phase-16 Outlook/M365 캘린더 OAuth + Graph sync
import time
import pytest
from sqlmodel import select
from app.config import get_settings
from app.connectors import oauth
from app.connectors.calendar import real_outlook
from app.connectors.calendar.real_outlook import OutlookCalendarConnector
from app.connectors.registry import ConnectorRegistry, _impl
from app.crypto import encrypt_token
from app.models import CalEvent, ConnectorAccount, ConnectorDomain, ConnectorMode, ConnState
@pytest.fixture()
def ms_creds(monkeypatch):
monkeypatch.setenv("MICROSOFT_CLIENT_ID", "m-cid")
monkeypatch.setenv("MICROSOFT_CLIENT_SECRET", "m-sec")
get_settings.cache_clear()
yield
get_settings.cache_clear()
class _Resp:
def __init__(self, payload, status=200):
self._p = payload
self.status_code = status
self.headers = {}
def json(self):
return self._p
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"http {self.status_code}")
class _Client:
"""httpx.Client 컨텍스트매니저 스텁 — 페이지를 순서대로 반환."""
def __init__(self, pages):
self._pages = list(pages)
self.calls = []
def __enter__(self):
return self
def __exit__(self, *a):
return False
def get(self, url, headers=None, params=None):
self.calls.append((url, params))
return _Resp(self._pages.pop(0))
# ── OAuth: Outlook 캘린더 authorize URL (microsoft 테넌트 + Calendars.Read) ──
def test_outlook_calendar_authorize_url(session, ms_creds):
s, _ = session
url = oauth.start_oauth(s, "calendar", "outlook_calendar", redirect_after="/settings")
assert url.startswith("https://login.microsoftonline.com/common/oauth2/v2.0/authorize")
assert "code_challenge=" in url and "state=" in url # PKCE
assert "Calendars.Read" in url and "offline_access" in url
assert "Mail.Read" not in url # 캘린더 스코프엔 메일 권한 없음
# ── registry: provider 로 Google/Outlook 캘린더 분기 ──
def test_calendar_dispatch_by_provider(session):
s, _ = session
assert _impl("calendar", "real", "outlook_calendar").__name__ == "OutlookCalendarConnector"
assert _impl("calendar", "real", "google_calendar").__name__ == "GoogleCalendarConnector"
out_acct = ConnectorAccount(
id="ca-cal-o",
domain=ConnectorDomain.calendar,
provider="outlook_calendar",
mode=ConnectorMode.real,
)
assert isinstance(ConnectorRegistry.get(s, out_acct), OutlookCalendarConnector)
# ── fetch: calendarView 페이지네이션(nextLink) + isCancelled 스킵 + CalEvent upsert ──
def test_outlook_calendar_fetch_paginates_and_upserts(session, monkeypatch):
s, _ = session
acct = ConnectorAccount(
id="ca-cal-outlook-1",
domain=ConnectorDomain.calendar,
provider="outlook_calendar",
mode=ConnectorMode.real,
state=ConnState.connected,
external_account_id="me@corp.com",
token_enc=encrypt_token({"access_token": "AT", "expires_at": int(time.time()) + 9999}),
)
s.add(acct)
s.commit()
ev = lambda i, **k: { # noqa: E731
"id": f"ev-{i}",
"subject": f"미팅 {i}",
"start": {"dateTime": "2026-06-12T09:00:00.0000000"},
"end": {"dateTime": "2026-06-12T09:30:00.0000000"},
"location": {"displayName": ""},
**k,
}
pages = [
{"value": [ev(1), ev(2, isCancelled=True)], "@odata.nextLink": "https://graph/next?p=2"},
{"value": [ev(3)]},
]
client = _Client(pages)
monkeypatch.setattr(real_outlook.httpx, "Client", lambda *a, **k: client)
conn = OutlookCalendarConnector(acct)
res = conn.sync(s)
# 취소 이벤트 제외하고 2건만 적재(ev-1, ev-3)
assert res.upserted == 2
titles = {e.title for e in s.exec(select(CalEvent)).all()}
assert titles == {"미팅 1", "미팅 3"}
# 2번째 호출은 nextLink URL + params=None (쿼리는 nextLink 에 포함)
assert client.calls[0][0].endswith("/me/calendarView")
assert client.calls[1] == ("https://graph/next?p=2", None)
# ── API: /connectors/providers?domain=calendar → google + outlook 캘린더 ──
def test_calendar_providers_endpoint(client, ms_creds, monkeypatch):
monkeypatch.setenv("GOOGLE_CLIENT_ID", "g-cid")
get_settings.cache_clear()
rows = client.get("/api/connectors/providers?domain=calendar").json()
by = {r["provider"]: r for r in rows}
assert set(by) == {"google_calendar", "outlook_calendar"}
assert by["outlook_calendar"]["configured"] is True # ms_creds 설정됨
assert by["outlook_calendar"]["label"] == "Outlook 캘린더"
# ── 시작 검증: 미구성 outlook_calendar 는 400 ──
def test_oauth_start_outlook_calendar_requires_microsoft_client_id(client):
r = client.get("/api/connectors/oauth/start?domain=calendar&provider=outlook_calendar")
assert r.status_code == 400

@ -0,0 +1,8 @@
// frontend/app/calendar/[[...slug]]/page.tsx — 일정 페이지 (옵셔널 캐치올: /calendar, /calendar/<id>)
// <id> 세그먼트는 CalendarClient 가 useParams() 로 읽어 해당 일정으로 이동·오픈한다.
import "@/styles/calendar.css";
import { CalendarClient } from "@/components/calendar/CalendarClient";
export default function CalendarPage() {
return <CalendarClient />;
}

@ -1,7 +0,0 @@
// frontend/app/calendar/page.tsx — 일정 페이지 (server component; Topbar 는 layout)
import "@/styles/calendar.css";
import { CalendarClient } from "@/components/calendar/CalendarClient";
export default function CalendarPage() {
return <CalendarClient />;
}

@ -1,16 +1,27 @@
// frontend/components/calendar/CalendarClient.tsx — 일정 페이지 오케스트레이터 (Topbar 제외) // frontend/components/calendar/CalendarClient.tsx — 일정 페이지 오케스트레이터 (실제 날짜 기반)
"use client"; "use client";
import { useEffect, useRef, useState, type MouseEvent } from "react"; import { useEffect, useRef, useState, type MouseEvent } from "react";
import { useParams } from "next/navigation";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import { useDialog } from "@/components/Dialog";
import { calendarApi } from "@/lib/calendar/api"; import { calendarApi } from "@/lib/calendar/api";
import { loadLS, saveLS } from "@/lib/calendar/store"; import { loadLS, saveLS } from "@/lib/calendar/store";
import type { CalEvent, WeekBundle } from "@/lib/calendar/types"; import {
addDays,
addMonths,
dayNum,
monthNum,
weekDatesOf,
yearNum,
} from "@/lib/calendar/time";
import type { Calendar, CalEvent, EventWrite, WeekBundle } from "@/lib/calendar/types";
import { MiniCal } from "./MiniCal"; import { MiniCal } from "./MiniCal";
import { CalToggleList } from "./CalToggleList"; import { CalToggleList } from "./CalToggleList";
import { WeekView } from "./WeekView"; import { WeekView } from "./WeekView";
import { MonthView } from "./MonthView"; import { MonthView } from "./MonthView";
import { DayView } from "./DayView"; import { DayView } from "./DayView";
import { EvPopover } from "./EvPopover"; import { EvPopover } from "./EvPopover";
import { EvEditor } from "./EvEditor";
import { MeetDrawer } from "./MeetDrawer"; import { MeetDrawer } from "./MeetDrawer";
const VIEWS = [ const VIEWS = [
@ -19,10 +30,10 @@ const VIEWS = [
{ id: "day", label: "일" }, { id: "day", label: "일" },
]; ];
function countByDay(events: CalEvent[]): Record<number, number> { function countByDate(events: CalEvent[]): Record<string, number> {
const m: Record<number, number> = {}; const m: Record<string, number> = {};
events.forEach((e) => { events.forEach((e) => {
m[e.day] = (m[e.day] || 0) + 1; m[e.date] = (m[e.date] || 0) + 1;
}); });
return m; return m;
} }
@ -51,21 +62,34 @@ function CalendarSkeleton() {
} }
export function CalendarClient() { export function CalendarClient() {
const { confirm, alert } = useDialog();
const [bundle, setBundle] = useState<WeekBundle | null>(null); const [bundle, setBundle] = useState<WeekBundle | null>(null);
const [err, setErr] = useState(false); const [err, setErr] = useState(false);
const [view, setView] = useState<string>("week"); const [view, setView] = useState<string>("week");
const [selDay, setSelDay] = useState<number>(8); const [refDate, setRefDate] = useState<string>(""); // 네비게이션 기준일(ISO)
const [selDate, setSelDate] = useState<string>(""); // 일 뷰 선택일(ISO)
const [calOn, setCalOn] = useState<Record<string, boolean>>({}); const [calOn, setCalOn] = useState<Record<string, boolean>>({});
const [focusOn, setFocusOn] = useState<boolean>(true); const [focusOn, setFocusOn] = useState<boolean>(true);
const [hydrated, setHydrated] = useState(false); const [hydrated, setHydrated] = useState(false);
const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | null>(null); const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | null>(null);
const [meetEv, setMeetEv] = useState<CalEvent | null>(null); const [meetEv, setMeetEv] = useState<CalEvent | null>(null);
const [editor, setEditor] = useState<{
ev: CalEvent | null;
date?: string;
time?: string;
} | null>(null);
const shellRef = useRef<HTMLDivElement>(null); const shellRef = useRef<HTMLDivElement>(null);
const didMount = useRef(false);
// 클라이언트에서만 localStorage 복원 (SSR 안정) // URL 로 열린 일정을 표현 — /calendar/<id> 의 <id> 세그먼트(옵셔널 캐치올).
// 주소 갱신은 router 대신 history.replaceState(얕은 갱신) — router.replace 는 이 라우트를
// 리마운트시켜 주간 네비게이션·상태가 날아가기 때문.
const params = useParams();
const routeId = Array.isArray(params.slug) ? params.slug[0] : undefined;
// 클라이언트에서만 localStorage 복원 (SSR 안정). 날짜는 항상 오늘에서 시작(stale 방지).
useEffect(() => { useEffect(() => {
setView(loadLS("view", "week")); setView(loadLS("view", "week"));
setSelDay(loadLS("selDay", 8));
setCalOn(loadLS<Record<string, boolean>>("calOn", {})); setCalOn(loadLS<Record<string, boolean>>("calOn", {}));
setFocusOn(loadLS("focusOn", true)); setFocusOn(loadLS("focusOn", true));
setHydrated(true); setHydrated(true);
@ -80,17 +104,32 @@ export function CalendarClient() {
setCalOn((cur) => setCalOn((cur) =>
Object.keys(cur).length ? cur : Object.fromEntries(b.calendars.map((c) => [c.id, c.on])), Object.keys(cur).length ? cur : Object.fromEntries(b.calendars.map((c) => [c.id, c.on])),
); );
setRefDate((cur) => cur || b.today_date);
setSelDate((cur) => cur || b.today_date);
}) })
.catch(() => setErr(true)); .catch(() => setErr(true));
}; };
useEffect(load, []); useEffect(load, []);
// 백엔드 자동 풀링이 적재한 새 일정을 주기적으로(10초)·포커스 시 반영(네비게이션 상태는 유지).
useEffect(() => {
const refetch = () => {
calendarApi
.week()
.then((b) => setBundle(b))
.catch(() => {});
};
const id = setInterval(refetch, 10_000);
window.addEventListener("focus", refetch);
return () => {
clearInterval(id);
window.removeEventListener("focus", refetch);
};
}, []);
useEffect(() => { useEffect(() => {
if (hydrated) saveLS("view", view); if (hydrated) saveLS("view", view);
}, [view, hydrated]); }, [view, hydrated]);
useEffect(() => {
if (hydrated) saveLS("selDay", selDay);
}, [selDay, hydrated]);
useEffect(() => { useEffect(() => {
if (hydrated) saveLS("calOn", calOn); if (hydrated) saveLS("calOn", calOn);
}, [calOn, hydrated]); }, [calOn, hydrated]);
@ -104,6 +143,33 @@ export function CalendarClient() {
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [bundle]); }, [bundle]);
// URL → 일정: routeId 가 가리키는 일정이 있으면 그 주로 이동 + 팝오버 오픈(직접 입력·뒤로가기·딥링크).
useEffect(() => {
if (!bundle) return; // 번들 로드 후에만 탐색 가능
if (!routeId) {
setPop((p) => (p ? null : p)); // 주소에서 빠지면 팝오버 닫기
return;
}
if (pop?.ev.id === routeId) return; // 이미 열려 있음
const ev = bundle.events.find((e) => e.id === routeId);
if (!ev) return; // 없는 id → 무시(잘못된 주소)
setRefDate(ev.date);
setSelDate(ev.date);
setPop({ ev, x: Math.max(window.innerWidth / 2 - 150, 16), y: 140 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeId, bundle]);
// 일정 → URL: 팝오버로 연 일정에 주소를 맞춘다(초기 마운트는 위 effect 가 처리하므로 skip).
useEffect(() => {
if (!didMount.current) {
didMount.current = true;
return;
}
const id = pop?.ev.id;
const target = id ? `/calendar/${id}` : "/calendar";
if (window.location.pathname !== target) window.history.replaceState(null, "", target);
}, [pop]);
if (err && !bundle) { if (err && !bundle) {
return ( return (
<div className="calpage"> <div className="calpage">
@ -117,7 +183,20 @@ export function CalendarClient() {
if (!bundle) return <CalendarSkeleton />; if (!bundle) return <CalendarSkeleton />;
const calMap = Object.fromEntries(bundle.calendars.map((c) => [c.id, c])); const today = bundle.today_date;
const ref = refDate || today;
const sel = selDate || today;
const weekDates = weekDatesOf(ref);
const calMap: Record<string, Calendar> = Object.fromEntries(
bundle.calendars.map((c) => [c.id, c]),
);
// 안전망: 이벤트가 참조하는 캘린더가 목록에 없어도 렌더가 깨지지 않도록 기본값 보강.
for (const e of bundle.events) {
if (!calMap[e.cal]) {
calMap[e.cal] = { id: e.cal, name: e.cal || "기타", tone: "ink", on: true, count: 0 };
}
}
const events = bundle.events.filter((e) => calOn[e.cal] !== false); const events = bundle.events.filter((e) => calOn[e.cal] !== false);
const focusBlocks = focusOn ? bundle.focus_blocks : []; const focusBlocks = focusOn ? bundle.focus_blocks : [];
const openEv = (ev: CalEvent, e: MouseEvent) => const openEv = (ev: CalEvent, e: MouseEvent) =>
@ -126,26 +205,130 @@ export function CalendarClient() {
setPop(null); setPop(null);
setMeetEv(ev); setMeetEv(ev);
}; };
const afterWrite = () => {
setEditor(null);
setPop(null);
load();
};
const deleteEvent = async (ev: CalEvent) => {
setPop(null);
const ok = await confirm({
title: "일정 삭제",
message: `${ev.title} 일정을 삭제할까요?`,
confirmText: "삭제",
tone: "danger",
});
if (!ok) return;
try {
await calendarApi.deleteEvent(ev.id);
load();
} catch (e) {
alert({ title: "삭제 실패", message: e instanceof Error ? e.message : "삭제하지 못했어요" });
}
};
// 빈 시간 슬롯 클릭 → 해당 날짜·시각으로 생성 모달 오픈.
const openSlot = (date: string, time: string) =>
setEditor({ ev: null, date, time });
// 팝오버 "내 작업에 추가" → 회의 액션 전체를 작업으로 변환.
const addToTasks = async (ev: CalEvent) => {
await calendarApi.materializeAll(ev.id);
};
// 드래그 이동/리사이즈 → 날짜·시각 변경을 서버에 반영(낙관적 갱신 후 재로드).
const evToWrite = (ev: CalEvent, patch: Partial<EventWrite>): EventWrite => ({
title: ev.title,
date: ev.date,
start: ev.start,
end: ev.end,
loc: ev.loc,
note: ev.note,
people: ev.people,
cal: ev.cal,
rrule: ev.rrule,
reminders: ev.reminders,
...patch,
});
const moveEvent = (ev: CalEvent, patch: { date: string; start: string; end: string }) => {
setBundle(
(b) =>
b && {
...b,
events: b.events.map((e) =>
e.id === ev.id
? { ...e, ...patch, day: Number(patch.date.slice(8, 10)) || e.day }
: e,
),
},
);
calendarApi
.updateEvent(ev.id, evToWrite(ev, patch))
.then(load)
.catch((e) => {
alert({
title: "이동 실패",
message: e instanceof Error ? e.message : "일정을 옮기지 못했어요",
});
load();
});
};
// 초대 응답(RSVP) → 서버 반영 + 팝오버 즉시 갱신.
const rsvp = async (ev: CalEvent, status: "accepted" | "declined" | "tentative") => {
try {
const updated = await calendarApi.rsvp(ev.id, status);
setPop((p) => (p && p.ev.id === ev.id ? { ...p, ev: updated } : p));
load();
} catch (e) {
alert({
title: "응답 실패",
message: e instanceof Error ? e.message : "응답을 보내지 못했어요",
});
}
};
const nav = (dir: number) => {
if (view === "month") setRefDate(addMonths(ref, dir));
else if (view === "day") {
const nd = addDays(sel, dir);
setSelDate(nd);
setRefDate(nd);
} else setRefDate(addDays(ref, dir * 7));
};
const goToday = () => {
setRefDate(today);
setSelDate(today);
};
const pickDay = (iso: string) => {
setSelDate(iso);
setRefDate(iso);
setView("day");
};
const ws = weekDates[0];
const we = weekDates[6];
const title = const title =
view === "month" ? "2026년 6월" : view === "day" ? `6월 ${selDay}` : "6월 7일 13일"; view === "month"
? `${yearNum(ref)}${monthNum(ref)}`
: view === "day"
? `${monthNum(sel)}${dayNum(sel)}`
: monthNum(ws) === monthNum(we)
? `${monthNum(ws)}${dayNum(ws)} ${dayNum(we)}`
: `${monthNum(ws)}${dayNum(ws)} ${monthNum(we)}${dayNum(we)}`;
return ( return (
<div className="calpage" ref={shellRef}> <div className="calpage" ref={shellRef}>
<div className="twork"> <div className="twork">
<aside className="subnav"> <aside className="subnav">
<div className="sn-title"></div> <div className="sn-title"></div>
<button className="sn-new"> <button className="sn-new" onClick={() => setEditor({ ev: null })}>
<Icon name="plus" /> <Icon name="plus" />
</button> </button>
<MiniCal <MiniCal
sel={selDay} monthRef={ref}
today={bundle.today} sel={sel}
today={today}
weekdays={bundle.weekdays} weekdays={bundle.weekdays}
eventsByDay={countByDay(events)} eventsByDate={countByDate(events)}
onSel={(n) => { onSel={pickDay}
setSelDay(n); onMonth={(dir) => setRefDate(addMonths(ref, dir))}
if (view !== "day") setView("day");
}}
/> />
<div className="sn-label"> </div> <div className="sn-label"> </div>
<CalToggleList <CalToggleList
@ -164,14 +347,14 @@ export function CalendarClient() {
<main className="cmain"> <main className="cmain">
<div className="chead"> <div className="chead">
<button className="ch-arrow" aria-label="이전"> <button className="ch-arrow" aria-label="이전" onClick={() => nav(-1)}>
<Icon name="chev" className="ic flip" /> <Icon name="chev" className="ic flip" />
</button> </button>
<button className="ch-arrow" aria-label="다음"> <button className="ch-arrow" aria-label="다음" onClick={() => nav(1)}>
<Icon name="chev" /> <Icon name="chev" />
</button> </button>
<h1 className="ch-title">{title}</h1> <h1 className="ch-title">{title}</h1>
<button className="ch-today" onClick={() => setSelDay(bundle.today)}> <button className="ch-today" onClick={goToday}>
</button> </button>
<span className="ch-spacer" /> <span className="ch-spacer" />
@ -198,39 +381,28 @@ export function CalendarClient() {
</div> </div>
</div> </div>
<div className="ai-strip">
<div className="ai-ic">
<Icon name="spark" />
</div>
<p>
<b>14:00 </b> .
, <b> 15:10 </b> . 16:00
1:1 15 .
</p>
</div>
{view === "week" && ( {view === "week" && (
<WeekView <WeekView
events={events} events={events}
focusBlocks={focusBlocks} focusBlocks={focusBlocks}
calMap={calMap} calMap={calMap}
week={bundle.week} weekDates={weekDates}
weekdays={bundle.weekdays} weekdays={bundle.weekdays}
today={bundle.today} todayDate={today}
onOpen={openEv} onOpen={openEv}
onSlotClick={openSlot}
onCommit={moveEvent}
/> />
)} )}
{view === "month" && ( {view === "month" && (
<MonthView <MonthView
events={events} events={events}
calMap={calMap} calMap={calMap}
monthRef={ref}
weekdays={bundle.weekdays} weekdays={bundle.weekdays}
today={bundle.today} todayDate={today}
onOpen={openEv} onOpen={openEv}
onSelDay={(n) => { onSelDay={pickDay}
setSelDay(n);
setView("day");
}}
/> />
)} )}
{view === "day" && ( {view === "day" && (
@ -238,12 +410,12 @@ export function CalendarClient() {
events={events} events={events}
focusBlocks={focusBlocks} focusBlocks={focusBlocks}
calMap={calMap} calMap={calMap}
day={selDay} dayDate={sel}
week={bundle.week} todayDate={today}
weekdays={bundle.weekdays}
today={bundle.today}
onOpen={openEv} onOpen={openEv}
onMeet={openMeet} onMeet={openMeet}
onSlotClick={openSlot}
onCommit={moveEvent}
/> />
)} )}
</main> </main>
@ -257,11 +429,29 @@ export function CalendarClient() {
cal={calMap[pop.ev.cal]} cal={calMap[pop.ev.cal]}
onClose={() => setPop(null)} onClose={() => setPop(null)}
onMeet={openMeet} onMeet={openMeet}
onEdit={(ev) => {
setPop(null);
setEditor({ ev });
}}
onDelete={deleteEvent}
onAddToTasks={addToTasks}
onRsvp={rsvp}
/> />
)} )}
{meetEv && ( {meetEv && (
<MeetDrawer ev={meetEv} cal={calMap[meetEv.cal]} onClose={() => setMeetEv(null)} /> <MeetDrawer ev={meetEv} cal={calMap[meetEv.cal]} onClose={() => setMeetEv(null)} />
)} )}
{editor && (
<EvEditor
calendars={bundle.calendars}
initial={editor.ev}
defaultDate={editor.date || sel}
defaultTime={editor.time}
onClose={() => setEditor(null)}
onSaved={afterWrite}
onDeleted={afterWrite}
/>
)}
</div> </div>
); );
} }

@ -1,44 +1,54 @@
// frontend/components/calendar/DayView.tsx — 일 뷰 (그리드 + 집중 카드 + 어젠다) // frontend/components/calendar/DayView.tsx — 일 뷰 (실제 날짜 기반: 그리드 + 집중 카드 + 어젠다)
"use client"; "use client";
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import { HOURS, toMin, topOf } from "@/lib/calendar/time"; import { HOURS, dayNum, monthNum, parseISO, timeOfPx, toMin, topOf } from "@/lib/calendar/time";
import type { CalEvent, Calendar, FocusBlock as FocusBlockT } from "@/lib/calendar/types"; import type { CalEvent, Calendar, FocusBlock as FocusBlockT } from "@/lib/calendar/types";
import { EvBlock } from "./EvBlock"; import { EvBlock, type EvCommit } from "./EvBlock";
import { FocusBlock } from "./FocusBlock"; import { FocusBlock } from "./FocusBlock";
const PEOPLE_COLORS = ["var(--coral)", "var(--blue)", "var(--violet)"]; const PEOPLE_COLORS = ["var(--coral)", "var(--blue)", "var(--violet)"];
const WEEKDAYS_KO = ["일", "월", "화", "수", "목", "금", "토"];
function evSort(a: CalEvent, b: CalEvent): number {
// 종일 먼저, 그다음 시작시각 순
if (!a.start && b.start) return -1;
if (a.start && !b.start) return 1;
if (!a.start && !b.start) return 0;
return toMin(a.start) - toMin(b.start);
}
export function DayView({ export function DayView({
events, events,
focusBlocks, focusBlocks,
calMap, calMap,
day, dayDate,
week, todayDate,
weekdays,
today,
onOpen, onOpen,
onMeet, onMeet,
onSlotClick,
onCommit,
}: { }: {
events: CalEvent[]; events: CalEvent[];
focusBlocks: FocusBlockT[]; focusBlocks: FocusBlockT[];
calMap: Record<string, Calendar>; calMap: Record<string, Calendar>;
day: number; dayDate: string;
week: number[]; todayDate: string;
weekdays: string[];
today: number;
onOpen: (ev: CalEvent, e: MouseEvent) => void; onOpen: (ev: CalEvent, e: MouseEvent) => void;
onMeet: (ev: CalEvent) => void; onMeet: (ev: CalEvent) => void;
onSlotClick: (dateIso: string, time: string) => void;
onCommit?: EvCommit;
}) { }) {
const dayEvents = events const dayEvents = events.filter((e) => e.date === dayDate).sort(evSort);
.filter((e) => e.day === day)
.sort((a, b) => toMin(a.start) - toMin(b.start));
const dayFocus = focusBlocks const dayFocus = focusBlocks
.filter((b) => b.day === day) .filter((b) => b.date === dayDate)
.sort((a, b) => toMin(a.start) - toMin(b.start)); .sort((a, b) => toMin(a.start) - toMin(b.start));
const idx = week.indexOf(day); const wd = WEEKDAYS_KO[parseISO(dayDate).getDay()];
const wd = idx >= 0 ? weekdays[idx] : weekdays[new Date(2026, 5, day).getDay()]; const isToday = dayDate === todayDate;
const nowTop = topOf("11:10"); const now = new Date();
const nowTop = topOf(
`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`,
);
return ( return (
<div className="day-wrap"> <div className="day-wrap">
@ -51,17 +61,37 @@ export function DayView({
</div> </div>
))} ))}
</div> </div>
<div className="day-col"> <div
className="day-col"
data-date={dayDate}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
onSlotClick(dayDate, timeOfPx(e.clientY - rect.top));
}}
>
{HOURS.map((h) => ( {HOURS.map((h) => (
<div key={h} className="wk-hr" /> <div key={h} className="wk-hr" />
))} ))}
{day === today && <div className="wk-now" style={{ top: nowTop + "px" }} />} {isToday && <div className="wk-now" style={{ top: nowTop + "px" }} />}
{dayFocus.map((b) => ( {dayFocus.map((b) => (
<FocusBlock key={b.id} b={b} /> <FocusBlock key={b.id} b={b} />
))} ))}
{dayEvents.map((ev) => ( {dayEvents
<EvBlock key={ev.id} ev={ev} cal={calMap[ev.cal]} onClick={onOpen} /> .filter((e) => !e.start)
))} .map((ev, ai) => (
<EvBlock key={ev.id} ev={ev} cal={calMap[ev.cal]} onClick={onOpen} idx={ai} />
))}
{dayEvents
.filter((e) => e.start)
.map((ev) => (
<EvBlock
key={ev.id}
ev={ev}
cal={calMap[ev.cal]}
onClick={onOpen}
onCommit={onCommit}
/>
))}
</div> </div>
</div> </div>
</div> </div>
@ -96,22 +126,28 @@ export function DayView({
<div className="cpanel agenda"> <div className="cpanel agenda">
<div className="ag-head"> <div className="ag-head">
<div className="ag-title"> <div className="ag-title">
6 {day} {wd} {monthNum(dayDate)} {dayNum(dayDate)} {wd}
</div> </div>
<div className="ag-sub"> <div className="ag-sub">
{dayEvents.length}{day === today ? " · 오늘" : ""} {dayEvents.length}{isToday ? " · 오늘" : ""}
</div> </div>
</div> </div>
{dayEvents.length === 0 && <div className="ag-empty"> .</div>} {dayEvents.length === 0 && <div className="ag-empty"> .</div>}
{dayEvents.map((ev) => ( {dayEvents.map((ev) => (
<div <div
key={ev.id} key={ev.id}
className={"ag-item tint-" + calMap[ev.cal].tone} className={"ag-item tint-" + (calMap[ev.cal]?.tone ?? "ink")}
onClick={(e) => onOpen(ev, e)} onClick={(e) => onOpen(ev, e)}
> >
<div className="ag-time"> <div className="ag-time">
{ev.start} {ev.start ? (
<small>{ev.end}</small> <>
{ev.start}
<small>{ev.end}</small>
</>
) : (
"종일"
)}
</div> </div>
<div className="ag-body"> <div className="ag-body">
<div className="ag-t"> <div className="ag-t">
@ -129,7 +165,7 @@ export function DayView({
display: "inline-block", display: "inline-block",
}} }}
/>{" "} />{" "}
{calMap[ev.cal].name} {calMap[ev.cal]?.name ?? "기타"}
</span> </span>
{ev.loc && ( {ev.loc && (
<> <>

@ -1,34 +1,132 @@
// frontend/components/calendar/EvBlock.tsx — 주/일 그리드의 이벤트 블록 // frontend/components/calendar/EvBlock.tsx — 주/일 그리드의 이벤트 블록(드래그 이동·리사이즈)
"use client"; "use client";
import type { MouseEvent } from "react"; import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { toMin, topOf, PXH } from "@/lib/calendar/time"; import { GRID_END_MIN, HSTART, PXH, fromMin, toMin, topOf } from "@/lib/calendar/time";
import type { CalEvent, Calendar } from "@/lib/calendar/types"; import type { CalEvent, Calendar } from "@/lib/calendar/types";
export type EvCommit = (
ev: CalEvent,
patch: { date: string; start: string; end: string },
) => void;
export function EvBlock({ export function EvBlock({
ev, ev,
cal, cal,
onClick, onClick,
idx = 0,
onCommit,
}: { }: {
ev: CalEvent; ev: CalEvent;
cal: Calendar; cal: Calendar;
onClick: (ev: CalEvent, e: MouseEvent) => void; onClick: (ev: CalEvent, e: ReactMouseEvent) => void;
idx?: number; // 종일 일정 세로 스택 인덱스
onCommit?: EvCommit; // 드래그 이동/리사이즈 확정(없으면 드래그 비활성)
}) { }) {
const top = topOf(ev.start); // 종일(시작 시각 없음) 일정은 그리드 상단에 22px 간격으로 쌓는다(NaN 위치 방지).
const h = ((toMin(ev.end) - toMin(ev.start)) / 60) * PXH; const allDay = !ev.start;
const short = h < 38; const tone = cal?.tone ?? "ink"; // 캘린더 누락 시에도 안전
const baseTop = allDay ? idx * 22 : topOf(ev.start);
const baseH = allDay ? 20 : ((toMin(ev.end) - toMin(ev.start)) / 60) * PXH;
const short = allDay || baseH < 38;
const canDrag = !allDay && !!onCommit;
const [active, setActive] = useState(false);
const [vis, setVis] = useState<{ mode: "move" | "resize"; dy: number }>({ mode: "move", dy: 0 });
const dragRef = useRef<{ mode: "move" | "resize"; startX: number; startY: number; moved: boolean } | null>(null);
const suppressClick = useRef(false);
useEffect(() => {
if (!active) return;
const onMove = (e: MouseEvent) => {
const d = dragRef.current;
if (!d) return;
const dy = e.clientY - d.startY;
if (Math.abs(dy) > 3 || Math.abs(e.clientX - d.startX) > 3) d.moved = true;
setVis({ mode: d.mode, dy });
};
const onUp = (e: MouseEvent) => {
const d = dragRef.current;
dragRef.current = null;
setActive(false);
if (!d || !d.moved || !onCommit) return;
suppressClick.current = true; // 드래그 직후의 click 으로 팝오버가 열리지 않도록
const dy = e.clientY - d.startY;
const deltaMin = Math.round((dy / PXH) * 60 / 15) * 15; // 15분 스냅
const startMin = toMin(ev.start);
const endMin = toMin(ev.end);
const dur = endMin - startMin;
if (d.mode === "move") {
let ns = startMin + deltaMin;
ns = Math.max(HSTART * 60, Math.min(ns, GRID_END_MIN - dur));
// 날짜 변경(주 뷰): 포인터 아래 컬럼의 data-date 로 이동
let date = ev.date;
const under = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
const col = under?.closest("[data-date]") as HTMLElement | null;
if (col?.dataset.date) date = col.dataset.date;
if (ns === startMin && date === ev.date) return;
onCommit(ev, { date, start: fromMin(ns), end: fromMin(ns + dur) });
} else {
let ne = endMin + deltaMin;
ne = Math.max(startMin + 15, Math.min(ne, GRID_END_MIN));
if (ne === endMin) return;
onCommit(ev, { date: ev.date, start: ev.start, end: fromMin(ne) });
}
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [active, ev, onCommit]);
const beginDrag = (mode: "move" | "resize", e: ReactMouseEvent) => {
if (!canDrag) return;
e.stopPropagation();
e.preventDefault();
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, moved: false };
setVis({ mode, dy: 0 });
setActive(true);
};
let top = baseTop;
let h = baseH;
if (active && vis.mode === "move") top = baseTop + vis.dy;
if (active && vis.mode === "resize") h = Math.max(baseH + vis.dy, 20);
const handleClick = (e: ReactMouseEvent) => {
e.stopPropagation();
if (suppressClick.current) {
suppressClick.current = false;
return;
}
onClick(ev, e);
};
return ( return (
<div <div
className={"ev tint-" + cal.tone + (ev.soon ? " soon" : "") + (short ? " short" : "")} className={
"ev tint-" +
tone +
(allDay ? " allday" : "") +
(ev.soon ? " soon" : "") +
(short ? " short" : "") +
(canDrag ? " draggable" : "") +
(active ? " dragging" : "")
}
style={{ top: top + "px", height: Math.max(h - 3, 20) + "px" }} style={{ top: top + "px", height: Math.max(h - 3, 20) + "px" }}
onClick={(e) => { onMouseDown={canDrag ? (e) => beginDrag("move", e) : undefined}
e.stopPropagation(); onClick={handleClick}
onClick(ev, e);
}}
> >
<div className="et">{ev.title}</div> <div className="et">{ev.title}</div>
<div className="etm"> {!allDay && (
{ev.start}{ev.end} <div className="etm">
</div> {ev.start}{ev.end}
</div>
)}
{canDrag && (
<div className="ev-resize" onMouseDown={(e) => beginDrag("resize", e)} aria-hidden />
)}
</div> </div>
); );
} }

@ -0,0 +1,220 @@
// frontend/components/calendar/EvEditor.tsx — 새 일정 생성/편집 모달
"use client";
import { useState } from "react";
import { Icon } from "@/components/Icon";
import { calendarApi } from "@/lib/calendar/api";
import { addHour } from "@/lib/calendar/time";
import type { Calendar, CalEvent, EventWrite } from "@/lib/calendar/types";
// 쓰기 가능한 캘린더(Google = gcal-*, Outlook = ocal-*, 로컬 = local).
function writableCalendars(cals: Calendar[]): Calendar[] {
const ok = cals.filter(
(c) => c.id.startsWith("gcal-") || c.id.startsWith("ocal-") || c.id === "local",
);
return ok.length ? ok : cals;
}
export function EvEditor({
calendars,
initial,
defaultDate,
defaultTime,
onClose,
onSaved,
onDeleted,
}: {
calendars: Calendar[];
initial?: CalEvent | null; // 있으면 편집, 없으면 생성
defaultDate: string; // 생성 시 기본 날짜(ISO)
defaultTime?: string; // 생성 시 기본 시작시각("HH:MM", 슬롯 클릭)
onClose: () => void;
onSaved: () => void;
onDeleted?: () => void;
}) {
const editing = !!initial;
const writable = writableCalendars(calendars);
const [title, setTitle] = useState(initial?.title ?? "");
const [date, setDate] = useState(initial?.date || defaultDate);
const [allDay, setAllDay] = useState(editing ? !initial?.start : false);
const [start, setStart] = useState(initial?.start || defaultTime || "09:00");
const [end, setEnd] = useState(
initial?.end || (defaultTime ? addHour(defaultTime, 1) : "10:00"),
);
const [loc, setLoc] = useState(initial?.loc ?? "");
const [note, setNote] = useState(initial?.note ?? "");
const [cal, setCal] = useState(initial?.cal || writable[0]?.id || "");
const [people, setPeople] = useState((initial?.people ?? []).join(", "));
const [rrule, setRrule] = useState(initial?.rrule ?? "");
const [reminder, setReminder] = useState<number>(initial?.reminders?.[0] ?? -1); // -1 = 없음
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const save = async () => {
if (!title.trim()) {
setErr("제목을 입력하세요");
return;
}
setBusy(true);
setErr("");
const body: EventWrite = {
title: title.trim(),
date,
start: allDay ? "" : start,
end: allDay ? "" : end,
loc: loc.trim(),
note: note.trim(),
people: people
.split(",")
.map((p) => p.trim())
.filter(Boolean),
cal,
rrule,
reminders: reminder >= 0 ? [reminder] : [],
};
try {
if (editing && initial) await calendarApi.updateEvent(initial.id, body);
else await calendarApi.createEvent(body);
onSaved();
} catch (e) {
setErr(e instanceof Error ? e.message : "저장하지 못했어요");
setBusy(false);
}
};
const del = async () => {
if (!initial) return;
setBusy(true);
setErr("");
try {
await calendarApi.deleteEvent(initial.id);
onDeleted?.();
} catch (e) {
setErr(e instanceof Error ? e.message : "삭제하지 못했어요");
setBusy(false);
}
};
return (
<>
<div className="dp-backdrop" onClick={busy ? undefined : onClose} />
<div className="eed" role="dialog" aria-modal="true" aria-label={editing ? "일정 편집" : "새 일정"}>
<div className="eed-head">
<h2>{editing ? "일정 편집" : "새 일정"}</h2>
<button className="eed-x" aria-label="닫기" onClick={onClose} disabled={busy}>
<Icon name="x" />
</button>
</div>
<label className="eed-field">
<span></span>
<input
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="일정 제목"
/>
</label>
<label className="eed-field">
<span></span>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</label>
<label className="eed-check">
<input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} />
<span></span>
</label>
{!allDay && (
<div className="eed-times">
<label className="eed-field">
<span></span>
<input type="time" value={start} onChange={(e) => setStart(e.target.value)} />
</label>
<label className="eed-field">
<span></span>
<input type="time" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
</div>
)}
<label className="eed-field">
<span></span>
<input value={loc} onChange={(e) => setLoc(e.target.value)} placeholder="(선택)" />
</label>
<label className="eed-field">
<span></span>
<select value={cal} onChange={(e) => setCal(e.target.value)}>
{writable.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<div className="eed-times">
<label className="eed-field">
<span></span>
<select value={rrule} onChange={(e) => setRrule(e.target.value)}>
<option value=""> </option>
<option value="FREQ=DAILY"></option>
<option value="FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"> ()</option>
<option value="FREQ=WEEKLY"></option>
<option value="FREQ=MONTHLY"></option>
<option value="FREQ=YEARLY"></option>
</select>
</label>
<label className="eed-field">
<span></span>
<select value={reminder} onChange={(e) => setReminder(Number(e.target.value))}>
<option value={-1}></option>
<option value={0}> </option>
<option value={5}>5 </option>
<option value={10}>10 </option>
<option value={15}>15 </option>
<option value={30}>30 </option>
<option value={60}>1 </option>
</select>
</label>
</div>
<label className="eed-field">
<span></span>
<input
value={people}
onChange={(e) => setPeople(e.target.value)}
placeholder="이메일 — 쉼표로 구분 (선택)"
/>
</label>
<label className="eed-field">
<span></span>
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={2} placeholder="(선택)" />
</label>
{err && (
<div className="eed-err" role="alert">
{err}
</div>
)}
<div className="eed-acts">
{editing && (
<button className="eed-del" onClick={del} disabled={busy}>
<Icon name="trash" />
</button>
)}
<span className="eed-spacer" />
<button className="eed-cancel" onClick={onClose} disabled={busy}>
</button>
<button className="eed-save" onClick={save} disabled={busy}>
{busy ? "저장 중…" : editing ? "저장" : "추가"}
</button>
</div>
</div>
</>
);
}

@ -1,8 +1,31 @@
// frontend/components/calendar/EvPopover.tsx — 이벤트 상세 팝오버 // frontend/components/calendar/EvPopover.tsx — 이벤트 상세 팝오버
"use client"; "use client";
import { useState } from "react";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import { fmt } from "@/lib/calendar/time"; import { fmt } from "@/lib/calendar/time";
import type { CalEvent, Calendar } from "@/lib/calendar/types"; import type { Attendee, CalEvent, Calendar } from "@/lib/calendar/types";
function rruleLabel(r: string): string {
if (!r) return "";
if (r.includes("DAILY")) return "매일 반복";
if (r.includes("BYDAY=MO,TU,WE,TH,FR")) return "평일 반복";
if (r.includes("WEEKLY")) return "매주 반복";
if (r.includes("MONTHLY")) return "매월 반복";
if (r.includes("YEARLY")) return "매년 반복";
return "반복 일정";
}
function reminderLabel(mins: number[]): string {
if (!mins.length) return "";
return mins.map((m) => (m === 0 ? "시작 시각" : `${m}분 전`)).join(", ") + " 알림";
}
const STATUS_KO: Record<string, string> = {
accepted: "수락",
declined: "거절",
tentative: "미정",
needsAction: "대기",
notResponded: "대기",
none: "",
};
export function EvPopover({ export function EvPopover({
ev, ev,
@ -11,6 +34,10 @@ export function EvPopover({
cal, cal,
onClose, onClose,
onMeet, onMeet,
onEdit,
onDelete,
onAddToTasks,
onRsvp,
}: { }: {
ev: CalEvent; ev: CalEvent;
x: number; x: number;
@ -18,7 +45,23 @@ export function EvPopover({
cal: Calendar; cal: Calendar;
onClose: () => void; onClose: () => void;
onMeet: (ev: CalEvent) => void; onMeet: (ev: CalEvent) => void;
onEdit: (ev: CalEvent) => void;
onDelete: (ev: CalEvent) => void;
onAddToTasks: (ev: CalEvent) => Promise<void>;
onRsvp: (ev: CalEvent, status: "accepted" | "declined" | "tentative") => Promise<void>;
}) { }) {
// 작업 추가 상태: idle → busy → done | error
const [addState, setAddState] = useState<"idle" | "busy" | "done" | "error">("idle");
const addToTasks = async () => {
if (addState === "busy" || addState === "done") return;
setAddState("busy");
try {
await onAddToTasks(ev);
setAddState("done");
} catch {
setAddState("error");
}
};
let left = x; let left = x;
let top = y; let top = y;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -45,9 +88,49 @@ export function EvPopover({
<Icon name="pin" /> {ev.loc} <Icon name="pin" /> {ev.loc}
</div> </div>
)} )}
{ev.people.length > 0 && ( {ev.rrule && (
<div className="epop-row">
<Icon name="repeat" /> {rruleLabel(ev.rrule)}
</div>
)}
{ev.reminders.length > 0 && (
<div className="epop-row"> <div className="epop-row">
<Icon name="users" /> {ev.people.join(", ")} <Icon name="bell" /> {reminderLabel(ev.reminders)}
</div>
)}
{ev.attendees.length > 0 ? (
<div className="epop-attendees">
<div className="epop-row">
<Icon name="users" /> {ev.attendees.length}
</div>
{ev.attendees.map((a: Attendee, i) => (
<div className="epop-att" key={i}>
<span className="epa-name">{a.name || a.email}</span>
{STATUS_KO[a.status] && (
<span className={"epa-status st-" + a.status}>{STATUS_KO[a.status]}</span>
)}
</div>
))}
</div>
) : (
ev.people.length > 0 && (
<div className="epop-row">
<Icon name="users" /> {ev.people.join(", ")}
</div>
)
)}
{ev.attendees.length > 0 && (
<div className="epop-rsvp">
<span className="rsvp-label"> </span>
{(["accepted", "tentative", "declined"] as const).map((st) => (
<button
key={st}
className={"rsvp-btn st-" + st + (ev.response_status === st ? " on" : "")}
onClick={() => onRsvp(ev, st)}
>
{STATUS_KO[st]}
</button>
))}
</div> </div>
)} )}
{ev.note && <div className="epop-note">{ev.note}</div>} {ev.note && <div className="epop-note">{ev.note}</div>}
@ -66,8 +149,19 @@ export function EvPopover({
<span className="epa-who">{a.when}</span> <span className="epa-who">{a.when}</span>
</div> </div>
))} ))}
<button className="epa-add"> <button
<Icon name="check" /> className={"epa-add" + (addState === "done" ? " done" : "")}
onClick={addToTasks}
disabled={addState === "busy" || addState === "done"}
>
<Icon name="check" />
{addState === "done"
? "작업에 추가됨"
: addState === "busy"
? "추가 중…"
: addState === "error"
? "다시 시도"
: "내 작업에 추가"}
</button> </button>
</div> </div>
)} )}
@ -78,10 +172,10 @@ export function EvPopover({
</button> </button>
)} )}
<div className="epop-acts"> <div className="epop-acts">
<button className="edit"> <button className="edit" onClick={() => onEdit(ev)}>
<Icon name="pen" /> <Icon name="pen" />
</button> </button>
<button className="del" aria-label="삭제"> <button className="del" aria-label="삭제" onClick={() => onDelete(ev)}>
<Icon name="trash" /> <Icon name="trash" />
</button> </button>
</div> </div>

@ -12,6 +12,7 @@ export function FocusBlock({ b }: { b: FocusBlockT }) {
<div <div
className={"fblock " + b.type + (short ? " short" : "")} className={"fblock " + b.type + (short ? " short" : "")}
style={{ top: top + "px", height: Math.max(h - 3, 22) + "px" }} style={{ top: top + "px", height: Math.max(h - 3, 22) + "px" }}
onClick={(e) => e.stopPropagation()}
> >
<div className="fb-k"> <div className="fb-k">
<Icon name="spark" /> <Icon name="spark" />

@ -1,36 +1,37 @@
// frontend/components/calendar/MiniCal.tsx — 미니 달력 (2026년 6월) // frontend/components/calendar/MiniCal.tsx — 미니 달력 (실제 날짜 기반)
"use client"; "use client";
import { Icon } from "@/components/Icon"; import { Icon } from "@/components/Icon";
import { dayNum, monthCells, monthNum, yearNum } from "@/lib/calendar/time";
export function MiniCal({ export function MiniCal({
monthRef,
sel, sel,
today, today,
weekdays, weekdays,
eventsByDay, eventsByDate,
onSel, onSel,
onMonth,
}: { }: {
sel: number; monthRef: string; // 보여줄 달(ISO)
today: number; sel: string; // 선택일(ISO)
today: string; // 오늘(ISO)
weekdays: string[]; weekdays: string[];
eventsByDay: Record<number, number>; eventsByDate: Record<string, number>;
onSel: (n: number) => void; onSel: (iso: string) => void;
onMonth: (dir: number) => void;
}) { }) {
const first = new Date(2026, 5, 1); const cells = monthCells(monthRef);
const off = first.getDay();
const cells: { inMonth: boolean; n: number }[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(2026, 5, 1 - off + i);
cells.push({ inMonth: d.getMonth() === 5, n: d.getDate() });
}
return ( return (
<div className="minical"> <div className="minical">
<div className="mc-head"> <div className="mc-head">
<span className="mc-title">2026 6</span> <span className="mc-title">
{yearNum(monthRef)} {monthNum(monthRef)}
</span>
<span className="mc-nav"> <span className="mc-nav">
<button aria-label="이전 달"> <button aria-label="이전 달" onClick={() => onMonth(-1)}>
<Icon name="chev" className="ic flip" /> <Icon name="chev" className="ic flip" />
</button> </button>
<button aria-label="다음 달"> <button aria-label="다음 달" onClick={() => onMonth(1)}>
<Icon name="chev" /> <Icon name="chev" />
</button> </button>
</span> </span>
@ -41,19 +42,19 @@ export function MiniCal({
{w} {w}
</div> </div>
))} ))}
{cells.map((c, i) => ( {cells.map((c) => (
<button <button
key={i} key={c.date}
className={ className={
"mc-day" + "mc-day" +
(c.inMonth ? "" : " out") + (c.inMonth ? "" : " out") +
(c.inMonth && c.n === today ? " today" : "") + (c.inMonth && c.date === today ? " today" : "") +
(c.inMonth && c.n === sel ? " sel" : "") (c.inMonth && c.date === sel ? " sel" : "")
} }
onClick={() => c.inMonth && onSel(c.n)} onClick={() => c.inMonth && onSel(c.date)}
> >
{c.n} {dayNum(c.date)}
{c.inMonth && eventsByDay[c.n] && <span className="evdot" />} {c.inMonth && eventsByDate[c.date] && <span className="evdot" />}
</button> </button>
))} ))}
</div> </div>

@ -1,36 +1,32 @@
// frontend/components/calendar/MonthView.tsx — 월 뷰 (셀당 이벤트 ≤3 + +N개 더) // frontend/components/calendar/MonthView.tsx — 월 뷰 (실제 날짜 기반, 셀당 이벤트 ≤3 + +N개 더)
"use client"; "use client";
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import { toMin } from "@/lib/calendar/time"; import { dayNum, monthCells, toMin } from "@/lib/calendar/time";
import type { CalEvent, Calendar } from "@/lib/calendar/types"; import type { CalEvent, Calendar } from "@/lib/calendar/types";
export function MonthView({ export function MonthView({
events, events,
calMap, calMap,
monthRef,
weekdays, weekdays,
today, todayDate,
onOpen, onOpen,
onSelDay, onSelDay,
}: { }: {
events: CalEvent[]; events: CalEvent[];
calMap: Record<string, Calendar>; calMap: Record<string, Calendar>;
monthRef: string; // 보여줄 달(ISO)
weekdays: string[]; weekdays: string[];
today: number; todayDate: string;
onOpen: (ev: CalEvent, e: MouseEvent) => void; onOpen: (ev: CalEvent, e: MouseEvent) => void;
onSelDay: (n: number) => void; onSelDay: (iso: string) => void;
}) { }) {
const first = new Date(2026, 5, 1); const byDate: Record<string, CalEvent[]> = {};
const off = first.getDay();
const byDay: Record<number, CalEvent[]> = {};
events.forEach((e) => { events.forEach((e) => {
(byDay[e.day] = byDay[e.day] || []).push(e); (byDate[e.date] = byDate[e.date] || []).push(e);
}); });
Object.values(byDay).forEach((a) => a.sort((x, y) => toMin(x.start) - toMin(y.start))); Object.values(byDate).forEach((a) => a.sort((x, y) => toMin(x.start) - toMin(y.start)));
const cells: { inMonth: boolean; n: number }[] = []; const cells = monthCells(monthRef);
for (let i = 0; i < 42; i++) {
const d = new Date(2026, 5, 1 - off + i);
cells.push({ inMonth: d.getMonth() === 5, n: d.getDate() });
}
return ( return (
<div className="cpanel"> <div className="cpanel">
<div className="mo-head"> <div className="mo-head">
@ -41,28 +37,30 @@ export function MonthView({
))} ))}
</div> </div>
<div className="mo-grid"> <div className="mo-grid">
{cells.map((c, i) => { {cells.map((c) => {
const evs = c.inMonth ? byDay[c.n] || [] : []; const evs = c.inMonth ? byDate[c.date] || [] : [];
return ( return (
<div <div
key={i} key={c.date}
className={ className={
"mo-cell" + (c.inMonth ? "" : " out") + (c.inMonth && c.n === today ? " today" : "") "mo-cell" +
(c.inMonth ? "" : " out") +
(c.inMonth && c.date === todayDate ? " today" : "")
} }
onClick={() => c.inMonth && onSelDay(c.n)} onClick={() => c.inMonth && onSelDay(c.date)}
> >
<div className="mo-dn">{c.n}</div> <div className="mo-dn">{dayNum(c.date)}</div>
{evs.slice(0, 3).map((ev) => ( {evs.slice(0, 3).map((ev) => (
<div <div
key={ev.id} key={ev.id}
className={"mo-ev tint-" + calMap[ev.cal].tone} className={"mo-ev tint-" + (calMap[ev.cal]?.tone ?? "ink")}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpen(ev, e); onOpen(ev, e);
}} }}
> >
<span className="pdot" /> <span className="pdot" />
<span className="mt">{ev.start}</span> <span className="mt">{ev.start || "종일"}</span>
<span className="mn">{ev.title}</span> <span className="mn">{ev.title}</span>
</div> </div>
))} ))}

@ -1,42 +1,56 @@
// frontend/components/calendar/WeekView.tsx — 주 뷰 (시간 그리드) // frontend/components/calendar/WeekView.tsx — 주 뷰 (실제 날짜 기반 시간 그리드)
"use client"; "use client";
import type { MouseEvent } from "react"; import type { MouseEvent } from "react";
import { HOURS, topOf } from "@/lib/calendar/time"; import { HOURS, dayNum, timeOfPx, topOf } from "@/lib/calendar/time";
import type { CalEvent, Calendar, FocusBlock as FocusBlockT } from "@/lib/calendar/types"; import type { CalEvent, Calendar, FocusBlock as FocusBlockT } from "@/lib/calendar/types";
import { EvBlock } from "./EvBlock"; import { EvBlock, type EvCommit } from "./EvBlock";
import { FocusBlock } from "./FocusBlock"; import { FocusBlock } from "./FocusBlock";
function nowTopPx(): number {
const d = new Date();
return topOf(`${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`);
}
export function WeekView({ export function WeekView({
events, events,
focusBlocks, focusBlocks,
calMap, calMap,
week, weekDates,
weekdays, weekdays,
today, todayDate,
onOpen, onOpen,
onSlotClick,
onCommit,
}: { }: {
events: CalEvent[]; events: CalEvent[];
focusBlocks: FocusBlockT[]; focusBlocks: FocusBlockT[];
calMap: Record<string, Calendar>; calMap: Record<string, Calendar>;
week: number[]; weekDates: string[]; // ISO 7개(일~토)
weekdays: string[]; weekdays: string[];
today: number; todayDate: string;
onOpen: (ev: CalEvent, e: MouseEvent) => void; onOpen: (ev: CalEvent, e: MouseEvent) => void;
onSlotClick: (dateIso: string, time: string) => void;
onCommit?: EvCommit;
}) { }) {
const nowTop = topOf("11:10"); // 오늘 6/8 가상 현재시각 const nowTop = nowTopPx();
// 빈 시간 슬롯 클릭 → 생성. 이벤트/집중 블록은 stopPropagation 으로 제외됨.
const slotClick = (iso: string) => (e: MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
onSlotClick(iso, timeOfPx(e.clientY - rect.top));
};
return ( return (
<div className="cpanel"> <div className="cpanel">
<div className="wk-head"> <div className="wk-head">
<div className="wk-corner" /> <div className="wk-corner" />
{week.map((day, i) => ( {weekDates.map((iso, i) => (
<div <div
key={day} key={iso}
className={ className={
"wk-dh" + (day === today ? " today" : "") + (i === 0 || i === 6 ? " wknd" : "") "wk-dh" + (iso === todayDate ? " today" : "") + (i === 0 || i === 6 ? " wknd" : "")
} }
> >
<div className="wd">{weekdays[i]}</div> <div className="wd">{weekdays[i]}</div>
<div className="dn">{day}</div> <div className="dn">{dayNum(iso)}</div>
</div> </div>
))} ))}
</div> </div>
@ -48,20 +62,36 @@ export function WeekView({
</div> </div>
))} ))}
</div> </div>
{week.map((day, i) => { {weekDates.map((iso, i) => {
const dayEvents = events.filter((e) => e.day === day); const dayEvents = events.filter((e) => e.date === iso);
const dayFocus = focusBlocks.filter((b) => b.day === day); const dayAllDay = dayEvents.filter((e) => !e.start);
const dayTimed = dayEvents.filter((e) => e.start);
const dayFocus = focusBlocks.filter((b) => b.date === iso);
return ( return (
<div key={day} className={"wk-col" + (i === 0 || i === 6 ? " wknd" : "")}> <div
key={iso}
data-date={iso}
className={"wk-col" + (i === 0 || i === 6 ? " wknd" : "")}
onClick={slotClick(iso)}
>
{HOURS.map((h) => ( {HOURS.map((h) => (
<div key={h} className="wk-hr" /> <div key={h} className="wk-hr" />
))} ))}
{day === today && <div className="wk-now" style={{ top: nowTop + "px" }} />} {iso === todayDate && <div className="wk-now" style={{ top: nowTop + "px" }} />}
{dayFocus.map((b) => ( {dayFocus.map((b) => (
<FocusBlock key={b.id} b={b} /> <FocusBlock key={b.id} b={b} />
))} ))}
{dayEvents.map((ev) => ( {dayAllDay.map((ev, ai) => (
<EvBlock key={ev.id} ev={ev} cal={calMap[ev.cal]} onClick={onOpen} /> <EvBlock key={ev.id} ev={ev} cal={calMap[ev.cal]} onClick={onOpen} idx={ai} />
))}
{dayTimed.map((ev) => (
<EvBlock
key={ev.id}
ev={ev}
cal={calMap[ev.cal]}
onClick={onOpen}
onCommit={onCommit}
/>
))} ))}
</div> </div>
); );

@ -1,15 +1,56 @@
// frontend/lib/calendar/api.ts — calendarApi (week/day/meeting/materialize/focus) // frontend/lib/calendar/api.ts — calendarApi (week/day/meeting/materialize/focus/events)
import type { WeekBundle, DayBundle, Meeting, MaterializeResult, FocusBlock } from "./types"; import type {
WeekBundle,
DayBundle,
Meeting,
MaterializeResult,
FocusBlock,
CalEvent,
EventWrite,
} from "./types";
const J = { "Content-Type": "application/json" }; const J = { "Content-Type": "application/json" };
async function ok<T>(r: Response): Promise<T> { async function ok<T>(r: Response): Promise<T> {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`); if (!r.ok) {
let msg = `${r.status} ${r.statusText}`;
try {
const j = await r.json();
if (j?.detail) msg = j.detail;
} catch {
/* 본문 없음 */
}
throw new Error(msg);
}
return r.json() as Promise<T>; return r.json() as Promise<T>;
} }
export const calendarApi = { export const calendarApi = {
week: () => fetch("/api/calendar/week", { cache: "no-store" }).then((r) => ok<WeekBundle>(r)), week: () => fetch("/api/calendar/week", { cache: "no-store" }).then((r) => ok<WeekBundle>(r)),
createEvent: (body: EventWrite) =>
fetch("/api/calendar/events", { method: "POST", headers: J, body: JSON.stringify(body) }).then(
(r) => ok<CalEvent>(r),
),
updateEvent: (id: string, body: EventWrite) =>
fetch(`/api/calendar/events/${id}`, {
method: "PATCH",
headers: J,
body: JSON.stringify(body),
}).then((r) => ok<CalEvent>(r)),
deleteEvent: (id: string) =>
fetch(`/api/calendar/events/${id}`, { method: "DELETE" }).then((r) =>
ok<{ ok: boolean; id: string }>(r),
),
rsvp: (id: string, status: "accepted" | "declined" | "tentative") =>
fetch(`/api/calendar/events/${id}/rsvp`, {
method: "POST",
headers: J,
body: JSON.stringify({ status }),
}).then((r) => ok<CalEvent>(r)),
day: (d: number) => day: (d: number) =>
fetch(`/api/calendar/day/${d}`, { cache: "no-store" }).then((r) => ok<DayBundle>(r)), fetch(`/api/calendar/day/${d}`, { cache: "no-store" }).then((r) => ok<DayBundle>(r)),

@ -8,6 +8,16 @@ export const toMin = (t: string): number => {
return h * 60 + m; return h * 60 + m;
}; };
// 분 → "HH:MM"(자정~23:59 캡). 드래그 이동/리사이즈 결과 계산용.
export const fromMin = (total: number): string => {
const t = Math.max(0, Math.min(total, 23 * 60 + 59));
const h = Math.floor(t / 60);
const m = t % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
export const GRID_END_MIN = (HSTART + HOURS.length) * 60; // 23:00 (그리드 하단)
export const fmt = (t: string): string => { export const fmt = (t: string): string => {
const [h, m] = t.split(":").map(Number); const [h, m] = t.split(":").map(Number);
const ap = h < 12 ? "오전" : "오후"; const ap = h < 12 ? "오전" : "오후";
@ -16,3 +26,63 @@ export const fmt = (t: string): string => {
}; };
export const topOf = (t: string): number => ((toMin(t) - HSTART * 60) / 60) * PXH; export const topOf = (t: string): number => ((toMin(t) - HSTART * 60) / 60) * PXH;
// 그리드 내부 Y 픽셀 → "HH:MM"(30분 단위 반올림). 슬롯 클릭 생성용.
export const timeOfPx = (y: number): string => {
const totalMin = HSTART * 60 + (y / PXH) * 60;
const lastMin = (HSTART + HOURS.length) * 60; // 22:00
const snapped = Math.round(totalMin / 30) * 30;
const clamped = Math.max(HSTART * 60, Math.min(snapped, lastMin - 60));
const h = Math.floor(clamped / 60);
const m = clamped % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
// "HH:MM" + n시간 → "HH:MM"(24시 캡). 생성 시 종료시각 계산용.
export const addHour = (t: string, n: number): string => {
const total = Math.min(toMin(t) + n * 60, 23 * 60 + 59);
const h = Math.floor(total / 60);
const m = total % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
// ── 실제 날짜(ISO "YYYY-MM-DD") 유틸 ──────────────────────────────
export const parseISO = (iso: string): Date => {
const [y, m, d] = iso.split("-").map(Number);
return new Date(y, (m || 1) - 1, d || 1);
};
export const isoOf = (d: Date): string =>
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
export const addDays = (iso: string, n: number): string => {
const d = parseISO(iso);
d.setDate(d.getDate() + n);
return isoOf(d);
};
export const addMonths = (iso: string, n: number): string => {
const d = parseISO(iso);
d.setDate(1);
d.setMonth(d.getMonth() + n);
return isoOf(d);
};
export const startOfWeek = (iso: string): string => {
const d = parseISO(iso);
d.setDate(d.getDate() - d.getDay()); // 일요일 시작
return isoOf(d);
};
export const weekDatesOf = (iso: string): string[] => {
const s = startOfWeek(iso);
return Array.from({ length: 7 }, (_, i) => addDays(s, i));
};
export const dayNum = (iso: string): number => parseISO(iso).getDate();
export const monthNum = (iso: string): number => parseISO(iso).getMonth() + 1;
export const yearNum = (iso: string): number => parseISO(iso).getFullYear();
// 월 그리드(42칸): 해당 월 1일이 포함된 주(일요일)부터 6주.
export const monthCells = (iso: string): { date: string; inMonth: boolean }[] => {
const ref = parseISO(iso);
const first = new Date(ref.getFullYear(), ref.getMonth(), 1);
const startIso = startOfWeek(isoOf(first));
return Array.from({ length: 42 }, (_, i) => {
const date = addDays(startIso, i);
return { date, inMonth: monthNum(date) === ref.getMonth() + 1 };
});
};

@ -14,9 +14,16 @@ export interface EventAction {
when: string; when: string;
} }
export interface Attendee {
email: string;
name: string;
status: string; // accepted|declined|tentative|needsAction|none
}
export interface CalEvent { export interface CalEvent {
id: string; id: string;
day: number; day: number;
date: string; // 실제 날짜 ISO "YYYY-MM-DD"
start: string; start: string;
end: string; end: string;
title: string; title: string;
@ -25,14 +32,32 @@ export interface CalEvent {
note: string; note: string;
soon: boolean; soon: boolean;
people: string[]; people: string[];
rrule: string; // 반복 규칙(빈값=단일)
reminders: number[]; // 시작 N분 전 알림
attendees: Attendee[];
response_status: string; // 내 RSVP
actions: EventAction[]; actions: EventAction[];
has_meeting: boolean; has_meeting: boolean;
meet_label: string; meet_label: string;
} }
export interface EventWrite {
title: string;
date: string; // ISO "YYYY-MM-DD"
start: string; // "HH:MM" (빈값 = 종일)
end: string;
loc: string;
note: string;
people: string[];
cal: string; // 대상 캘린더 id(빈값=기본)
rrule: string; // 반복 규칙
reminders: number[]; // 시작 N분 전 알림
}
export interface FocusBlock { export interface FocusBlock {
id: string; id: string;
day: number; day: number;
date: string;
start: string; start: string;
end: string; end: string;
title: string; title: string;
@ -75,6 +100,8 @@ export interface Meeting {
export interface WeekBundle { export interface WeekBundle {
today: number; today: number;
today_date: string; // 실제 오늘 ISO
mode: "real" | "demo";
week: number[]; week: number[];
weekdays: string[]; weekdays: string[];
calendars: Calendar[]; calendars: Calendar[];

@ -483,6 +483,10 @@
.calpage .wk-col { .calpage .wk-col {
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
position: relative; position: relative;
cursor: pointer;
}
.calpage .wk-col:hover {
background: color-mix(in oklab, var(--ink) 1.5%, transparent);
} }
.calpage .wk-col:last-child { .calpage .wk-col:last-child {
border-right: none; border-right: none;
@ -679,6 +683,10 @@
} }
.calpage .day-col { .calpage .day-col {
position: relative; position: relative;
cursor: pointer;
}
.calpage .day-col:hover {
background: color-mix(in oklab, var(--ink) 1.5%, transparent);
} }
.calpage .agenda { .calpage .agenda {
padding: 20px; padding: 20px;
@ -961,6 +969,144 @@
padding: 9px 13px; padding: 9px 13px;
} }
/* ====================== 일정 생성/편집 모달 (EvEditor) ====================== */
.calpage .eed {
position: fixed;
z-index: 61;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(440px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
overflow-y: auto;
background: var(--card);
border: 1px solid var(--line);
border-radius: 18px;
box-shadow: 0 30px 70px -22px rgba(26, 22, 18, 0.5);
padding: 22px;
animation: cal-pop 0.16s ease;
}
.calpage .eed-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.calpage .eed-head h2 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
}
.calpage .eed-x {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
color: var(--muted);
}
.calpage .eed-x:hover {
background: var(--card-2);
color: var(--ink);
}
.calpage .eed-field {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
}
.calpage .eed-field > span {
font-size: 12px;
font-weight: 600;
color: var(--ink-2);
}
.calpage .eed-field input,
.calpage .eed-field select,
.calpage .eed-field textarea {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--card-2);
color: var(--ink);
font-size: 13.5px;
font-family: inherit;
}
.calpage .eed-field input:focus,
.calpage .eed-field select:focus,
.calpage .eed-field textarea:focus {
outline: 2px solid color-mix(in oklab, var(--blue) 50%, transparent);
outline-offset: 1px;
}
.calpage .eed-field textarea {
resize: vertical;
}
.calpage .eed-times {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.calpage .eed-check {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--ink-2);
margin-bottom: 12px;
cursor: pointer;
}
.calpage .eed-check input {
width: 15px;
height: 15px;
}
.calpage .eed-err {
font-size: 12.5px;
color: var(--coral);
margin: 4px 0 10px;
}
.calpage .eed-acts {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.calpage .eed-spacer {
flex: 1;
}
.calpage .eed-acts button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 15px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
transition: background 0.13s;
}
.calpage .eed-acts button:disabled {
opacity: 0.55;
}
.calpage .eed-acts .ic {
width: 15px;
height: 15px;
}
.calpage .eed-del {
background: color-mix(in oklab, var(--coral) 12%, transparent);
color: var(--coral);
}
.calpage .eed-cancel {
background: var(--card-2);
color: var(--ink-2);
}
.calpage .eed-save {
background: var(--ink);
color: var(--card);
}
.calpage .eed-save:hover:not(:disabled) {
background: var(--ink-2);
}
/* ====================== 집중 블록 (Focus of the Day) ====================== */ /* ====================== 집중 블록 (Focus of the Day) ====================== */
.calpage .ch-focus { .calpage .ch-focus {
display: inline-flex; display: inline-flex;
@ -1268,6 +1414,16 @@
.calpage .epa-add:hover { .calpage .epa-add:hover {
filter: brightness(1.08); filter: brightness(1.08);
} }
.calpage .epa-add:disabled {
cursor: default;
}
.calpage .epa-add.done {
background: color-mix(in oklab, var(--violet) 16%, transparent);
color: var(--violet);
}
.calpage .epa-add.done:hover {
filter: none;
}
/* 진입 애니메이션 */ /* 진입 애니메이션 */
.calpage.entered .twork > * { .calpage.entered .twork > * {
@ -1852,3 +2008,113 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
/* ── phase-18: 반복/알림/참석자/RSVP · 드래그 이동·리사이즈 ── */
/* 참석자 목록 + 상태 배지 */
.calpage .epop-attendees {
margin-top: 4px;
}
.calpage .epop-att {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 0 2px 22px;
font-size: 12px;
}
.calpage .epop-att .epa-name {
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calpage .epa-status {
font-size: 10.5px;
padding: 1px 7px;
border-radius: 999px;
margin-left: auto;
font-weight: 600;
}
.calpage .epa-status.st-accepted {
background: color-mix(in srgb, var(--green) 18%, transparent);
color: var(--green);
}
.calpage .epa-status.st-declined {
background: color-mix(in srgb, var(--coral) 18%, transparent);
color: var(--coral);
}
.calpage .epa-status.st-tentative {
background: color-mix(in srgb, var(--amber) 20%, transparent);
color: var(--amber);
}
.calpage .epa-status.st-needsAction,
.calpage .epa-status.st-notResponded {
background: var(--card-3);
color: var(--muted);
}
/* RSVP 버튼 */
.calpage .epop-rsvp {
display: flex;
align-items: center;
gap: 6px;
margin: 10px 0 2px;
}
.calpage .rsvp-label {
font-size: 11px;
color: var(--muted);
margin-right: 2px;
}
.calpage .rsvp-btn {
flex: 1;
font-size: 12px;
padding: 6px 0;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--card);
cursor: pointer;
transition:
background 0.12s,
border-color 0.12s;
}
.calpage .rsvp-btn:hover {
background: var(--card-3);
}
.calpage .rsvp-btn.st-accepted.on {
background: color-mix(in srgb, var(--green) 16%, transparent);
border-color: var(--green);
color: var(--green);
}
.calpage .rsvp-btn.st-tentative.on {
background: color-mix(in srgb, var(--amber) 18%, transparent);
border-color: var(--amber);
color: var(--amber);
}
.calpage .rsvp-btn.st-declined.on {
background: color-mix(in srgb, var(--coral) 16%, transparent);
border-color: var(--coral);
color: var(--coral);
}
/* 드래그 이동/리사이즈 */
.calpage .ev.draggable {
cursor: grab;
}
.calpage .ev.dragging {
pointer-events: none; /* elementFromPoint 가 아래 컬럼을 찾도록 */
opacity: 0.85;
box-shadow: 0 10px 24px -6px rgba(30, 26, 22, 0.5);
z-index: 20;
transition: none;
cursor: grabbing;
}
.calpage .ev .ev-resize {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 7px;
cursor: ns-resize;
}
.calpage .ev.short .ev-resize {
height: 5px;
}

@ -2,6 +2,7 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, expect, test, vi } from "vitest"; import { beforeEach, expect, test, vi } from "vitest";
import { CalendarClient } from "@/components/calendar/CalendarClient"; import { CalendarClient } from "@/components/calendar/CalendarClient";
import { DialogProvider } from "@/components/Dialog";
import { calendarApi } from "@/lib/calendar/api"; import { calendarApi } from "@/lib/calendar/api";
import type { WeekBundle } from "@/lib/calendar/types"; import type { WeekBundle } from "@/lib/calendar/types";
@ -9,6 +10,8 @@ vi.mock("@/lib/calendar/api");
const BUNDLE: WeekBundle = { const BUNDLE: WeekBundle = {
today: 8, today: 8,
today_date: "2026-06-08",
mode: "demo",
week: [7, 8, 9, 10, 11, 12, 13], week: [7, 8, 9, 10, 11, 12, 13],
weekdays: ["일", "월", "화", "수", "목", "금", "토"], weekdays: ["일", "월", "화", "수", "목", "금", "토"],
calendars: [ calendars: [
@ -19,6 +22,7 @@ const BUNDLE: WeekBundle = {
{ {
id: "w1", id: "w1",
day: 8, day: 8,
date: "2026-06-08",
start: "11:00", start: "11:00",
end: "11:45", end: "11:45",
title: "디자인 리뷰", title: "디자인 리뷰",
@ -27,6 +31,10 @@ const BUNDLE: WeekBundle = {
note: "", note: "",
soon: false, soon: false,
people: [], people: [],
rrule: "",
reminders: [],
attendees: [],
response_status: "",
actions: [], actions: [],
has_meeting: false, has_meeting: false,
meet_label: "", meet_label: "",
@ -34,6 +42,7 @@ const BUNDLE: WeekBundle = {
{ {
id: "h1", id: "h1",
day: 8, day: 8,
date: "2026-06-08",
start: "20:00", start: "20:00",
end: "20:30", end: "20:30",
title: "스트레칭 & 명상", title: "스트레칭 & 명상",
@ -42,6 +51,10 @@ const BUNDLE: WeekBundle = {
note: "", note: "",
soon: false, soon: false,
people: [], people: [],
rrule: "",
reminders: [],
attendees: [],
response_status: "",
actions: [], actions: [],
has_meeting: false, has_meeting: false,
meet_label: "", meet_label: "",
@ -51,6 +64,7 @@ const BUNDLE: WeekBundle = {
{ {
id: "f3", id: "f3",
day: 8, day: 8,
date: "2026-06-08",
start: "15:10", start: "15:10",
end: "16:25", end: "16:25",
title: "분기 리포트 초안 마무리", title: "분기 리포트 초안 마무리",
@ -68,7 +82,7 @@ beforeEach(() => {
}); });
async function renderReady() { async function renderReady() {
const utils = render(<CalendarClient />); const utils = render(<CalendarClient />, { wrapper: DialogProvider });
// bundle 로딩 완료까지 대기 (주 헤더 표시) // bundle 로딩 완료까지 대기 (주 헤더 표시)
await screen.findByText("6월 7일 13일"); await screen.findByText("6월 7일 13일");
return utils; return utils;
@ -110,3 +124,21 @@ test("카테고리 토글 off 시 해당 cal 이벤트 숨김", async () => {
// 업무 이벤트는 유지 // 업무 이벤트는 유지
expect(screen.getByText("디자인 리뷰")).toBeInTheDocument(); expect(screen.getByText("디자인 리뷰")).toBeInTheDocument();
}); });
test("주 뷰 빈 슬롯 클릭 시 생성 모달 오픈", async () => {
const { container } = await renderReady();
// 빈 시간 컬럼(.wk-col) 클릭 → 생성 에디터 오픈
const col = container.querySelector(".wk-col") as HTMLElement;
expect(col).toBeTruthy();
fireEvent.click(col);
const dialog = await screen.findByRole("dialog", { name: "새 일정" });
expect(dialog).toBeInTheDocument();
});
test("이벤트 클릭은 슬롯 생성으로 새지 않고 팝오버를 연다", async () => {
await renderReady();
// 이벤트 블록 클릭 → 팝오버(편집/삭제 액션) 표시, 생성 모달 아님
fireEvent.click(screen.getByText("디자인 리뷰"));
expect(await screen.findByRole("button", { name: /편집/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "새 일정" })).toBeNull();
});

Loading…
Cancel
Save