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
import re
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException
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 ..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 (
AttendeeOut,
CalendarOut,
CalEventOut,
DayBundleOut,
EventActionOut,
EventRsvpRequest,
EventWriteRequest,
FocusBlockOut,
FocusSuggestRequest,
FocusSuggestResponse,
@ -30,6 +47,29 @@ router = APIRouter() # prefix 없음. main.py 에서 prefix="/api" 등록.
WEEK = [7, 8, 9, 10, 11, 12, 13]
WEEKDAYS = ["", "", "", "", "", "", ""]
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:
@ -108,6 +148,7 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut:
return CalEventOut(
id=e.id,
day=e.day,
date=_display_date(e.day, e.date),
start=e.start,
end=e.end,
title=e.title,
@ -116,6 +157,15 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut:
note=e.note,
soon=e.soon,
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],
has_meeting=m is not None,
meet_label=meet_label(m),
@ -124,15 +174,18 @@ def _event_out(s: Session, e: CalEvent) -> CalEventOut:
@router.get("/calendar/week", response_model=WeekBundleOut)
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)]
cals = s.exec(select(Calendar).order_by(Calendar.sort_order)).all()
counts: dict[str, int] = {}
for e in rows:
counts[e.cal] = counts.get(e.cal, 0) + 1
fbs = s.exec(select(FocusBlock).order_by(FocusBlock.sort_order)).all()
mode, today_date = _calendar_mode(s)
return WeekBundleOut(
today=TODAY,
today_date=today_date,
mode=mode,
week=WEEK,
weekdays=WEEKDAYS,
calendars=[
@ -140,10 +193,16 @@ def get_week(s: Session = Depends(get_session)):
for c in cals
],
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)
def get_day(day: int, s: Session = Depends(get_session)):
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(
day=day,
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.commit()
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
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")
assert r.status_code == 200
b = r.json()
assert b["today"] == 8 and b["week"] == [7, 8, 9, 10, 11, 12, 13]
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 team["count"] == sum(1 for e in b["events"] if e["cal"] == "team")
# 일정 연결 전: 캘린더/이벤트/포커스 모두 비어 있음
assert b["calendars"] == [] and b["events"] == [] and b["focus_blocks"] == []
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()
e3 = next(e for e in b["events"] if e["id"] == "e3")
assert e3["has_meeting"] is True
assert e3["meet_label"] == "회의 노트 · 액션 보기"
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명"]
assert [a["text"] for a in e3["actions"]] == ["피드백 정리"]
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/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):
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()
assert len(b["focus_blocks"]) == 4
deep = [f for f in b["focus_blocks"] if f["type"] == "deep"]
assert len(deep) == 1 and deep[0]["title"] == "분기 리포트 초안 마무리"
assert b["focus_blocks"] == [] # 포커스 시드 제거

@ -1,39 +1,53 @@
# backend/tests/test_meeting_materialize.py
# phase-16+: 일정 시드 제거 → 회의/액션을 테스트가 직접 생성.
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):
s, _ = session
# e3 idx=2 "매출 데이터 전달" (현우, added=False)
_setup_e3(s)
r = client.post("/api/calendar/meetings/e3/actions/2/materialize")
assert r.status_code == 200
data = r.json()
assert data["action"]["added"] is True
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["all_added"] is True # e3 의 마지막 미처리 액션이었음
tid = data["task"]["id"]
assert s.get(Task, tid) is not None # federation: 작업 페이지에 등장
assert data["all_added"] is True # 유일한 미처리 액션이었음
assert s.get(Task, data["task"]["id"]) is not None
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")
t1 = r1.json()["task"]["id"]
r2 = client.post("/api/calendar/meetings/e3/actions/2/materialize")
assert r2.json()["task"]["id"] == t1 # 중복 생성 없음
def test_materialize_all(client):
# e4(live) 액션 1건 모두
r = client.post("/api/calendar/meetings/e4/actions/materialize-all")
arr = r.json()
def test_materialize_all(client, session):
s, _ = session
make_cal_event(s, id="e4", cal="work", title="디자인 리뷰")
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 arr[0]["task"]["title"] == "온보딩 3번 화면 CTA 수정안 반영"
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")
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";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { useParams } from "next/navigation";
import { Icon } from "@/components/Icon";
import { useDialog } from "@/components/Dialog";
import { calendarApi } from "@/lib/calendar/api";
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 { CalToggleList } from "./CalToggleList";
import { WeekView } from "./WeekView";
import { MonthView } from "./MonthView";
import { DayView } from "./DayView";
import { EvPopover } from "./EvPopover";
import { EvEditor } from "./EvEditor";
import { MeetDrawer } from "./MeetDrawer";
const VIEWS = [
@ -19,10 +30,10 @@ const VIEWS = [
{ id: "day", label: "일" },
];
function countByDay(events: CalEvent[]): Record<number, number> {
const m: Record<number, number> = {};
function countByDate(events: CalEvent[]): Record<string, number> {
const m: Record<string, number> = {};
events.forEach((e) => {
m[e.day] = (m[e.day] || 0) + 1;
m[e.date] = (m[e.date] || 0) + 1;
});
return m;
}
@ -51,21 +62,34 @@ function CalendarSkeleton() {
}
export function CalendarClient() {
const { confirm, alert } = useDialog();
const [bundle, setBundle] = useState<WeekBundle | null>(null);
const [err, setErr] = useState(false);
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 [focusOn, setFocusOn] = useState<boolean>(true);
const [hydrated, setHydrated] = useState(false);
const [pop, setPop] = useState<{ ev: CalEvent; x: number; y: number } | 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 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(() => {
setView(loadLS("view", "week"));
setSelDay(loadLS("selDay", 8));
setCalOn(loadLS<Record<string, boolean>>("calOn", {}));
setFocusOn(loadLS("focusOn", true));
setHydrated(true);
@ -80,17 +104,32 @@ export function CalendarClient() {
setCalOn((cur) =>
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));
};
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(() => {
if (hydrated) saveLS("view", view);
}, [view, hydrated]);
useEffect(() => {
if (hydrated) saveLS("selDay", selDay);
}, [selDay, hydrated]);
useEffect(() => {
if (hydrated) saveLS("calOn", calOn);
}, [calOn, hydrated]);
@ -104,6 +143,33 @@ export function CalendarClient() {
return () => cancelAnimationFrame(id);
}, [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) {
return (
<div className="calpage">
@ -117,7 +183,20 @@ export function CalendarClient() {
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 focusBlocks = focusOn ? bundle.focus_blocks : [];
const openEv = (ev: CalEvent, e: MouseEvent) =>
@ -126,26 +205,130 @@ export function CalendarClient() {
setPop(null);
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 =
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 (
<div className="calpage" ref={shellRef}>
<div className="twork">
<aside className="subnav">
<div className="sn-title"></div>
<button className="sn-new">
<button className="sn-new" onClick={() => setEditor({ ev: null })}>
<Icon name="plus" />
</button>
<MiniCal
sel={selDay}
today={bundle.today}
monthRef={ref}
sel={sel}
today={today}
weekdays={bundle.weekdays}
eventsByDay={countByDay(events)}
onSel={(n) => {
setSelDay(n);
if (view !== "day") setView("day");
}}
eventsByDate={countByDate(events)}
onSel={pickDay}
onMonth={(dir) => setRefDate(addMonths(ref, dir))}
/>
<div className="sn-label"> </div>
<CalToggleList
@ -164,14 +347,14 @@ export function CalendarClient() {
<main className="cmain">
<div className="chead">
<button className="ch-arrow" aria-label="이전">
<button className="ch-arrow" aria-label="이전" onClick={() => nav(-1)}>
<Icon name="chev" className="ic flip" />
</button>
<button className="ch-arrow" aria-label="다음">
<button className="ch-arrow" aria-label="다음" onClick={() => nav(1)}>
<Icon name="chev" />
</button>
<h1 className="ch-title">{title}</h1>
<button className="ch-today" onClick={() => setSelDay(bundle.today)}>
<button className="ch-today" onClick={goToday}>
</button>
<span className="ch-spacer" />
@ -198,39 +381,28 @@ export function CalendarClient() {
</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" && (
<WeekView
events={events}
focusBlocks={focusBlocks}
calMap={calMap}
week={bundle.week}
weekDates={weekDates}
weekdays={bundle.weekdays}
today={bundle.today}
todayDate={today}
onOpen={openEv}
onSlotClick={openSlot}
onCommit={moveEvent}
/>
)}
{view === "month" && (
<MonthView
events={events}
calMap={calMap}
monthRef={ref}
weekdays={bundle.weekdays}
today={bundle.today}
todayDate={today}
onOpen={openEv}
onSelDay={(n) => {
setSelDay(n);
setView("day");
}}
onSelDay={pickDay}
/>
)}
{view === "day" && (
@ -238,12 +410,12 @@ export function CalendarClient() {
events={events}
focusBlocks={focusBlocks}
calMap={calMap}
day={selDay}
week={bundle.week}
weekdays={bundle.weekdays}
today={bundle.today}
dayDate={sel}
todayDate={today}
onOpen={openEv}
onMeet={openMeet}
onSlotClick={openSlot}
onCommit={moveEvent}
/>
)}
</main>
@ -257,11 +429,29 @@ export function CalendarClient() {
cal={calMap[pop.ev.cal]}
onClose={() => setPop(null)}
onMeet={openMeet}
onEdit={(ev) => {
setPop(null);
setEditor({ ev });
}}
onDelete={deleteEvent}
onAddToTasks={addToTasks}
onRsvp={rsvp}
/>
)}
{meetEv && (
<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>
);
}

@ -1,44 +1,54 @@
// frontend/components/calendar/DayView.tsx — 일 뷰 (그리드 + 집중 카드 + 어젠다)
// frontend/components/calendar/DayView.tsx — 일 뷰 (실제 날짜 기반: 그리드 + 집중 카드 + 어젠다)
"use client";
import type { MouseEvent } from "react";
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 { EvBlock } from "./EvBlock";
import { EvBlock, type EvCommit } from "./EvBlock";
import { FocusBlock } from "./FocusBlock";
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({
events,
focusBlocks,
calMap,
day,
week,
weekdays,
today,
dayDate,
todayDate,
onOpen,
onMeet,
onSlotClick,
onCommit,
}: {
events: CalEvent[];
focusBlocks: FocusBlockT[];
calMap: Record<string, Calendar>;
day: number;
week: number[];
weekdays: string[];
today: number;
dayDate: string;
todayDate: string;
onOpen: (ev: CalEvent, e: MouseEvent) => void;
onMeet: (ev: CalEvent) => void;
onSlotClick: (dateIso: string, time: string) => void;
onCommit?: EvCommit;
}) {
const dayEvents = events
.filter((e) => e.day === day)
.sort((a, b) => toMin(a.start) - toMin(b.start));
const dayEvents = events.filter((e) => e.date === dayDate).sort(evSort);
const dayFocus = focusBlocks
.filter((b) => b.day === day)
.filter((b) => b.date === dayDate)
.sort((a, b) => toMin(a.start) - toMin(b.start));
const idx = week.indexOf(day);
const wd = idx >= 0 ? weekdays[idx] : weekdays[new Date(2026, 5, day).getDay()];
const nowTop = topOf("11:10");
const wd = WEEKDAYS_KO[parseISO(dayDate).getDay()];
const isToday = dayDate === todayDate;
const now = new Date();
const nowTop = topOf(
`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`,
);
return (
<div className="day-wrap">
@ -51,17 +61,37 @@ export function DayView({
</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) => (
<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) => (
<FocusBlock key={b.id} b={b} />
))}
{dayEvents.map((ev) => (
<EvBlock key={ev.id} ev={ev} cal={calMap[ev.cal]} onClick={onOpen} />
))}
{dayEvents
.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>
@ -96,22 +126,28 @@ export function DayView({
<div className="cpanel agenda">
<div className="ag-head">
<div className="ag-title">
6 {day} {wd}
{monthNum(dayDate)} {dayNum(dayDate)} {wd}
</div>
<div className="ag-sub">
{dayEvents.length}{day === today ? " · 오늘" : ""}
{dayEvents.length}{isToday ? " · 오늘" : ""}
</div>
</div>
{dayEvents.length === 0 && <div className="ag-empty"> .</div>}
{dayEvents.map((ev) => (
<div
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)}
>
<div className="ag-time">
{ev.start}
<small>{ev.end}</small>
{ev.start ? (
<>
{ev.start}
<small>{ev.end}</small>
</>
) : (
"종일"
)}
</div>
<div className="ag-body">
<div className="ag-t">
@ -129,7 +165,7 @@ export function DayView({
display: "inline-block",
}}
/>{" "}
{calMap[ev.cal].name}
{calMap[ev.cal]?.name ?? "기타"}
</span>
{ev.loc && (
<>

@ -1,34 +1,132 @@
// frontend/components/calendar/EvBlock.tsx — 주/일 그리드의 이벤트 블록
// frontend/components/calendar/EvBlock.tsx — 주/일 그리드의 이벤트 블록(드래그 이동·리사이즈)
"use client";
import type { MouseEvent } from "react";
import { toMin, topOf, PXH } from "@/lib/calendar/time";
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { GRID_END_MIN, HSTART, PXH, fromMin, toMin, topOf } from "@/lib/calendar/time";
import type { CalEvent, Calendar } from "@/lib/calendar/types";
export type EvCommit = (
ev: CalEvent,
patch: { date: string; start: string; end: string },
) => void;
export function EvBlock({
ev,
cal,
onClick,
idx = 0,
onCommit,
}: {
ev: CalEvent;
cal: Calendar;
onClick: (ev: CalEvent, e: MouseEvent) => void;
onClick: (ev: CalEvent, e: ReactMouseEvent) => void;
idx?: number; // 종일 일정 세로 스택 인덱스
onCommit?: EvCommit; // 드래그 이동/리사이즈 확정(없으면 드래그 비활성)
}) {
const top = topOf(ev.start);
const h = ((toMin(ev.end) - toMin(ev.start)) / 60) * PXH;
const short = h < 38;
// 종일(시작 시각 없음) 일정은 그리드 상단에 22px 간격으로 쌓는다(NaN 위치 방지).
const allDay = !ev.start;
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 (
<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" }}
onClick={(e) => {
e.stopPropagation();
onClick(ev, e);
}}
onMouseDown={canDrag ? (e) => beginDrag("move", e) : undefined}
onClick={handleClick}
>
<div className="et">{ev.title}</div>
<div className="etm">
{ev.start}{ev.end}
</div>
{!allDay && (
<div className="etm">
{ev.start}{ev.end}
</div>
)}
{canDrag && (
<div className="ev-resize" onMouseDown={(e) => beginDrag("resize", e)} aria-hidden />
)}
</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 — 이벤트 상세 팝오버
"use client";
import { useState } from "react";
import { Icon } from "@/components/Icon";
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({
ev,
@ -11,6 +34,10 @@ export function EvPopover({
cal,
onClose,
onMeet,
onEdit,
onDelete,
onAddToTasks,
onRsvp,
}: {
ev: CalEvent;
x: number;
@ -18,7 +45,23 @@ export function EvPopover({
cal: Calendar;
onClose: () => 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 top = y;
if (typeof window !== "undefined") {
@ -45,9 +88,49 @@ export function EvPopover({
<Icon name="pin" /> {ev.loc}
</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">
<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>
)}
{ev.note && <div className="epop-note">{ev.note}</div>}
@ -66,8 +149,19 @@ export function EvPopover({
<span className="epa-who">{a.when}</span>
</div>
))}
<button className="epa-add">
<Icon name="check" />
<button
className={"epa-add" + (addState === "done" ? " done" : "")}
onClick={addToTasks}
disabled={addState === "busy" || addState === "done"}
>
<Icon name="check" />
{addState === "done"
? "작업에 추가됨"
: addState === "busy"
? "추가 중…"
: addState === "error"
? "다시 시도"
: "내 작업에 추가"}
</button>
</div>
)}
@ -78,10 +172,10 @@ export function EvPopover({
</button>
)}
<div className="epop-acts">
<button className="edit">
<button className="edit" onClick={() => onEdit(ev)}>
<Icon name="pen" />
</button>
<button className="del" aria-label="삭제">
<button className="del" aria-label="삭제" onClick={() => onDelete(ev)}>
<Icon name="trash" />
</button>
</div>

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

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

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

@ -1,42 +1,56 @@
// frontend/components/calendar/WeekView.tsx — 주 뷰 (시간 그리드)
// frontend/components/calendar/WeekView.tsx — 주 뷰 (실제 날짜 기반 시간 그리드)
"use client";
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 { EvBlock } from "./EvBlock";
import { EvBlock, type EvCommit } from "./EvBlock";
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({
events,
focusBlocks,
calMap,
week,
weekDates,
weekdays,
today,
todayDate,
onOpen,
onSlotClick,
onCommit,
}: {
events: CalEvent[];
focusBlocks: FocusBlockT[];
calMap: Record<string, Calendar>;
week: number[];
weekDates: string[]; // ISO 7개(일~토)
weekdays: string[];
today: number;
todayDate: string;
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 (
<div className="cpanel">
<div className="wk-head">
<div className="wk-corner" />
{week.map((day, i) => (
{weekDates.map((iso, i) => (
<div
key={day}
key={iso}
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="dn">{day}</div>
<div className="dn">{dayNum(iso)}</div>
</div>
))}
</div>
@ -48,20 +62,36 @@ export function WeekView({
</div>
))}
</div>
{week.map((day, i) => {
const dayEvents = events.filter((e) => e.day === day);
const dayFocus = focusBlocks.filter((b) => b.day === day);
{weekDates.map((iso, i) => {
const dayEvents = events.filter((e) => e.date === iso);
const dayAllDay = dayEvents.filter((e) => !e.start);
const dayTimed = dayEvents.filter((e) => e.start);
const dayFocus = focusBlocks.filter((b) => b.date === iso);
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) => (
<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) => (
<FocusBlock key={b.id} b={b} />
))}
{dayEvents.map((ev) => (
<EvBlock key={ev.id} ev={ev} cal={calMap[ev.cal]} onClick={onOpen} />
{dayAllDay.map((ev, ai) => (
<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>
);

@ -1,15 +1,56 @@
// frontend/lib/calendar/api.ts — calendarApi (week/day/meeting/materialize/focus)
import type { WeekBundle, DayBundle, Meeting, MaterializeResult, FocusBlock } from "./types";
// frontend/lib/calendar/api.ts — calendarApi (week/day/meeting/materialize/focus/events)
import type {
WeekBundle,
DayBundle,
Meeting,
MaterializeResult,
FocusBlock,
CalEvent,
EventWrite,
} from "./types";
const J = { "Content-Type": "application/json" };
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>;
}
export const calendarApi = {
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) =>
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;
};
// 분 → "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 => {
const [h, m] = t.split(":").map(Number);
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;
// 그리드 내부 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;
}
export interface Attendee {
email: string;
name: string;
status: string; // accepted|declined|tentative|needsAction|none
}
export interface CalEvent {
id: string;
day: number;
date: string; // 실제 날짜 ISO "YYYY-MM-DD"
start: string;
end: string;
title: string;
@ -25,14 +32,32 @@ export interface CalEvent {
note: string;
soon: boolean;
people: string[];
rrule: string; // 반복 규칙(빈값=단일)
reminders: number[]; // 시작 N분 전 알림
attendees: Attendee[];
response_status: string; // 내 RSVP
actions: EventAction[];
has_meeting: boolean;
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 {
id: string;
day: number;
date: string;
start: string;
end: string;
title: string;
@ -75,6 +100,8 @@ export interface Meeting {
export interface WeekBundle {
today: number;
today_date: string; // 실제 오늘 ISO
mode: "real" | "demo";
week: number[];
weekdays: string[];
calendars: Calendar[];

@ -483,6 +483,10 @@
.calpage .wk-col {
border-right: 1px solid var(--line);
position: relative;
cursor: pointer;
}
.calpage .wk-col:hover {
background: color-mix(in oklab, var(--ink) 1.5%, transparent);
}
.calpage .wk-col:last-child {
border-right: none;
@ -679,6 +683,10 @@
}
.calpage .day-col {
position: relative;
cursor: pointer;
}
.calpage .day-col:hover {
background: color-mix(in oklab, var(--ink) 1.5%, transparent);
}
.calpage .agenda {
padding: 20px;
@ -961,6 +969,144 @@
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) ====================== */
.calpage .ch-focus {
display: inline-flex;
@ -1268,6 +1414,16 @@
.calpage .epa-add:hover {
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 > * {
@ -1852,3 +2008,113 @@
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 { beforeEach, expect, test, vi } from "vitest";
import { CalendarClient } from "@/components/calendar/CalendarClient";
import { DialogProvider } from "@/components/Dialog";
import { calendarApi } from "@/lib/calendar/api";
import type { WeekBundle } from "@/lib/calendar/types";
@ -9,6 +10,8 @@ vi.mock("@/lib/calendar/api");
const BUNDLE: WeekBundle = {
today: 8,
today_date: "2026-06-08",
mode: "demo",
week: [7, 8, 9, 10, 11, 12, 13],
weekdays: ["일", "월", "화", "수", "목", "금", "토"],
calendars: [
@ -19,6 +22,7 @@ const BUNDLE: WeekBundle = {
{
id: "w1",
day: 8,
date: "2026-06-08",
start: "11:00",
end: "11:45",
title: "디자인 리뷰",
@ -27,6 +31,10 @@ const BUNDLE: WeekBundle = {
note: "",
soon: false,
people: [],
rrule: "",
reminders: [],
attendees: [],
response_status: "",
actions: [],
has_meeting: false,
meet_label: "",
@ -34,6 +42,7 @@ const BUNDLE: WeekBundle = {
{
id: "h1",
day: 8,
date: "2026-06-08",
start: "20:00",
end: "20:30",
title: "스트레칭 & 명상",
@ -42,6 +51,10 @@ const BUNDLE: WeekBundle = {
note: "",
soon: false,
people: [],
rrule: "",
reminders: [],
attendees: [],
response_status: "",
actions: [],
has_meeting: false,
meet_label: "",
@ -51,6 +64,7 @@ const BUNDLE: WeekBundle = {
{
id: "f3",
day: 8,
date: "2026-06-08",
start: "15:10",
end: "16:25",
title: "분기 리포트 초안 마무리",
@ -68,7 +82,7 @@ beforeEach(() => {
});
async function renderReady() {
const utils = render(<CalendarClient />);
const utils = render(<CalendarClient />, { wrapper: DialogProvider });
// bundle 로딩 완료까지 대기 (주 헤더 표시)
await screen.findByText("6월 7일 13일");
return utils;
@ -110,3 +124,21 @@ test("카테고리 토글 off 시 해당 cal 이벤트 숨김", async () => {
// 업무 이벤트는 유지
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