From 477be3892a29e74155b81ffff08e144ab8d809aa Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 11 May 2026 07:50:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?docs(events):=20PR-1=20=E2=86=92=20PR-2=20q?= =?UTF-8?q?uickref=20=E2=80=94=20API=20contract=20+=205=EC=B4=88=20?= =?UTF-8?q?=ED=96=89=EB=8F=99=20=EA=B8=B0=EB=A1=9D=20UX=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-2 (frontend UI MVP) 진입 전 reference doc. plan: beszel-tingly-sloth.md v6. 내용: - JWT 인증 flow (curl 예시) - 9 endpoint 표 (Create/List/Detail + 4 Lifecycle + 3 View) - kind / status enum 의미 + UI 분기 hint - 빠른 행동 기록 5초 UX (PR-2 핵심 가설) - PR-2 smoke 로 자연 검증할 5건 (PR-1 closure 의 deferred 항목) - events_history 조회 endpoint 미존재 (필요 시 PR-2 에서 추가) authoritative API contract = /openapi.json. 본 doc 은 frontend cheat sheet. --- docs/events_api_quickref.md | 95 +++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/events_api_quickref.md diff --git a/docs/events_api_quickref.md b/docs/events_api_quickref.md new file mode 100644 index 0000000..0d6f844 --- /dev/null +++ b/docs/events_api_quickref.md @@ -0,0 +1,95 @@ +# events API quickref (PR-1 → PR-2 frontend reference) + +**Plan**: `~/.claude/plans/beszel-tingly-sloth.md` v6 +**PR-1 closure**: 2026-05-11, schema + endpoint registration 검증 완료. JWT 의존 HTTP behavior 는 PR-2 UI smoke 로 자연 검증. +**Authoritative contract**: `GET /openapi.json` (자동 생성). 본 문서는 frontend 개발자용 cheat sheet. + +## 인증 + +모든 events endpoint = JWT Bearer (기존 `get_current_user` 의존성). + +```bash +# 로그인 → access_token +TOKEN=$(curl -s -X POST https://document.hyungi.net/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"","password":"","totp_code":""}' \ + | jq -r .access_token) + +# events 호출 +curl -H "Authorization: Bearer $TOKEN" https://document.hyungi.net/api/events/today +``` + +Frontend (SvelteKit 5) 는 기존 `lib/api.ts` 의 fetch wrapper (JWT 자동 첨부) 그대로 사용. + +## 9 endpoint + +### Create / List / Detail + +| Method | Path | 용도 | +|---|---|---| +| `POST` | `/api/events/` | 생성. kind=task/calendar_event/activity_log. activity_log 면 status=done + ended_at=now() default. | +| `GET` | `/api/events/` | 목록. `?kind&status&from&to&project_tag&source&page&page_size`. Upcoming = `?from=now&to=now+7d&status=scheduled,next,deferred`. | +| `GET` | `/api/events/{id}` | 상세. | +| `PATCH` | `/api/events/{id}` | edit. **허용**: title/description/시간 7필드/priority/project_tag/tags/memo_document_id. **금지** (422): status/completed_at/cancelled_at/defer_until/source/source_ref/raw_metadata/user_id/created_by. 시간 필드 변경 시 `reschedule` history 자동. | + +### Lifecycle (각 호출당 events_history 1 row 자동) + +| Method | Path | 효과 | +|---|---|---| +| `POST` | `/api/events/{id}/complete` | status=done + completed_at=now() | +| `POST` | `/api/events/{id}/cancel` | status=cancelled + cancelled_at=now() | +| `POST` | `/api/events/{id}/defer` | body `{defer_until: ISO}` → status=deferred + defer_until 설정 | +| `POST` | `/api/events/{id}/reactivate` | task → inbox, calendar_event → scheduled, activity_log → 400 거부 | + +### View + +| Method | Path | 정책 | +|---|---|---| +| `GET` | `/api/events/today` | `?timezone=Asia/Seoul` (default). due_at/start_at/started_at 이 오늘이고 status ∈ {inbox, next, scheduled, in_progress} 또는 (deferred AND defer_until <= now). | +| `GET` | `/api/events/inbox` | status=inbox 만. | +| `GET` | `/api/events/activity` | `?from&to`. kind=activity_log + status=done 만. Today 와 분리. | + +## kind / status 의미 (UI 분기 가이드) + +| kind | 주요 시간 필드 | default status | UI hint | +|---|---|---|---| +| `task` | due_at (start_at/end_at optional, "14:00 전화" 같은 시각 task 허용) | inbox | 체크박스 + 마감일 표시 | +| `calendar_event` | start_at (필수) + end_at (optional) | scheduled | 캘린더 일정 카드 | +| `activity_log` | started_at OR ended_at (둘 다 NULL 금지) | done | "방금 한 일" 입력 / Activity 타임라인 | + +| status | 의미 | +|---|---| +| `inbox` | 아직 정리 안 됨 | +| `next` | 다음 행동으로 선정 (시간 미정) | +| `scheduled` | 시간/날짜 잡힘 | +| `in_progress` | 진행 중 | +| `done` | 완료 | +| `cancelled` | 취소 | +| `deferred` | defer_until 까지 숨김 | + +## 빠른 행동 기록 5초 UX (PR-2 핵심 가설) + +```js +// 1입력 필드 → Enter → POST /api/events +api.post('/api/events/', { + kind: 'activity_log', + title: '<사용자 입력>' + // status/started_at/ended_at/completed_at 모두 server-side default +}) +``` + +→ status=done + started_at=ended_at=completed_at=now() 자동 → Activity 탭 즉시 반영 + 새로고침 유지. + +## 검증되지 않은 HTTP behavior 항목 (PR-2 smoke 시 닫기) + +PR-1 closure 시 schema/endpoint registration 만 자동 검증. 아래 5건은 frontend 호출하며 자연 검증: + +1. POST `/api/events` kind=activity_log + title only → status=done + 시간 default 채워짐 +2. POST `/api/events/{id}/complete` 호출 → events_history row 1건 (change_kind=complete) 자동 생성 +3. PATCH `/api/events/{id}` 시간 필드 변경 → reschedule history 자동 +4. PATCH `/api/events/{id}` 금지 필드 (status 등) 시도 → 422 응답 (Pydantic extra=forbid) +5. GET `/api/events/today?timezone=Asia/Seoul` → 오늘 (KST 기준) 항목만 반환, deferred 는 defer_until ≤ now 조건 만족 시만 + +## events_history 조회 (PR-2 history timeline) + +PR-1 에는 history 조회 endpoint 없음. PR-2 시 상세 페이지 timeline 필요하면 `GET /api/events/{id}/history` 신규 추가 (간단 — events_history 테이블 ORDER BY changed_at). 별 endpoint 추가 시 본 문서 갱신. -- 2.52.0 From 6d71116553513eec8cce2d4efed141536bd9dd9e Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Mon, 11 May 2026 07:56:31 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(events):=20PR-2=20UI=20MVP=20=E2=80=94?= =?UTF-8?q?=204-tab=20+=20=EB=B9=A0=EB=A5=B8=20=ED=96=89=EB=8F=99=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=20+=20=EC=83=81=EC=84=B8/=EC=83=9D=EC=84=B1/?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan v6 PR-2 scope. 5초 행동 기록 UX 가 핵심 가설. Backend: - GET /api/events/{id}/history — events_history timeline 조회 (lifecycle op 자동 기록) Frontend (SvelteKit 5 runes mode): - /events 메인 — 4-tab (오늘/Inbox/예정/활동) + 빠른 행동 기록 widget · 단일 입력 + Enter → POST /api/events kind=activity_log · status=done + 시간 default 채워짐 (서버 측) → Activity 탭 즉시 반영 · 새 항목을 list 최상단 prepend (refetch 불필요) · 연속 입력 위해 입력 ref focus 유지 · lifecycle 버튼 (complete/defer/cancel/reactivate) — activity_log 는 lifecycle 대상 X - /events/[id] 상세 — PATCH 허용 필드 edit (title/desc/시간/priority/project_tag) + history timeline · PATCH 금지 필드는 UI 노출 X (status/completed_at/cancelled_at/defer_until 은 별 버튼) - /events/new — kind 선택 (task/calendar_event/activity_log) 후 필드 분기 form · task: due_at + start_at (선택, "14:00 전화" 같은 시각 task 허용 — 라운드 10) · calendar_event: start_at 필수 + end_at + all_day · activity_log: started_at/ended_at 비우면 서버 default now() - Sidebar 메모 옆에 events 진입점 (CalendarCheck icon) API helpers: frontend/src/lib/utils/events.ts (createEvent / logActivity / list* / lifecycle ops / kind&status enum label/color). quickref doc: docs/events_api_quickref.md (이전 commit, PR-2 frontend reference). PR-2 핵심 가설 검증 = 빠른 입력 → 저장 → Activity 즉시 반영 → 새로고침 유지. PR-1 deferred HTTP behavior 5건도 본 UI 의 자연 사용으로 닫힘. --- app/api/events.py | 34 ++ frontend/src/lib/components/Sidebar.svelte | 12 +- frontend/src/lib/utils/events.ts | 242 +++++++++++++++ frontend/src/routes/events/+page.svelte | 311 +++++++++++++++++++ frontend/src/routes/events/[id]/+page.svelte | 308 ++++++++++++++++++ frontend/src/routes/events/new/+page.svelte | 171 ++++++++++ 6 files changed, 1077 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/utils/events.ts create mode 100644 frontend/src/routes/events/+page.svelte create mode 100644 frontend/src/routes/events/[id]/+page.svelte create mode 100644 frontend/src/routes/events/new/+page.svelte diff --git a/app/api/events.py b/app/api/events.py index c90ec57..d6be9ff 100644 --- a/app/api/events.py +++ b/app/api/events.py @@ -147,6 +147,20 @@ class EventListResponse(BaseModel): total: int +class EventHistoryResponse(BaseModel): + id: int + event_id: int + changed_at: datetime + changed_by: str + change_kind: str + before: dict[str, Any] | None + after: dict[str, Any] + + +class EventHistoryListResponse(BaseModel): + items: list[EventHistoryResponse] + + # ─── 헬퍼 ─── @@ -476,6 +490,26 @@ async def get_event( return _to_response(ev) +@router.get("/{event_id}/history", response_model=EventHistoryListResponse) +async def get_event_history( + event_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """events_history 조회 — 상세 페이지 timeline. lifecycle op 자동 기록만 (v1).""" + await _load_owned(session, event_id, user) # owner 검증 + rows = await session.execute( + select(EventHistory) + .where(EventHistory.event_id == event_id) + .order_by(EventHistory.changed_at.desc()) + ) + items = [ + EventHistoryResponse.model_validate(h, from_attributes=True) + for h in rows.scalars().all() + ] + return EventHistoryListResponse(items=items) + + # ─── PATCH ─── diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index d36750f..38b48b8 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -2,7 +2,7 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { api } from '$lib/api'; - import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap } from 'lucide-svelte'; + import { ChevronRight, ChevronDown, FolderOpen, FolderTree, Inbox, Clock, Mail, Scale, StickyNote, GraduationCap, CalendarCheck } from 'lucide-svelte'; let tree = $state([]); let loading = $state(true); @@ -209,6 +209,16 @@ 메모 + + + + events + + + import { onMount } from 'svelte'; + import { goto } from '$app/navigation'; + import { addToast } from '$lib/stores/toast'; + import Button from '$lib/components/ui/Button.svelte'; + import Card from '$lib/components/ui/Card.svelte'; + import EmptyState from '$lib/components/ui/EmptyState.svelte'; + import Skeleton from '$lib/components/ui/Skeleton.svelte'; + import Tabs from '$lib/components/ui/Tabs.svelte'; + import { + Inbox, + Sun, + CalendarClock, + History, + Check, + X as XIcon, + Pause, + RotateCcw, + Plus, + } from 'lucide-svelte'; + import { + type EventItem, + type EventKind, + KIND_LABEL, + STATUS_LABEL, + STATUS_COLOR, + KIND_COLOR, + logActivity, + listToday, + listInbox, + listActivity, + listUpcoming, + completeEvent, + cancelEvent, + deferEvent, + reactivateEvent, + formatDateTimeKst, + relativeTimeKo, + } from '$lib/utils/events'; + + // 활성 탭 + let activeTab = $state('today'); + + // 데이터 + let todayItems = $state([]); + let inboxItems = $state([]); + let upcomingItems = $state([]); + let activityItems = $state([]); + let loading = $state>({ today: true, inbox: true, upcoming: true, activity: true }); + + // 빠른 행동 기록 (5초 UX 핵심) + let quickInput = $state(''); + let quickSubmitting = $state(false); + let quickInputRef = $state(null); + + // 상대 시각 tick (1분) + let nowTick = $state(Date.now()); + $effect(() => { + const id = setInterval(() => { nowTick = Date.now(); }, 60_000); + return () => clearInterval(id); + }); + + onMount(() => { + loadAll(); + }); + + async function loadAll() { + await Promise.all([loadToday(), loadInbox(), loadActivity(), loadUpcoming()]); + } + + async function loadToday() { + loading.today = true; + try { + const res = await listToday(); + todayItems = res.items; + } catch (err) { + addToast('error', '오늘 일정 로드 실패'); + } finally { + loading.today = false; + } + } + + async function loadInbox() { + loading.inbox = true; + try { + const res = await listInbox(); + inboxItems = res.items; + } catch (err) { + addToast('error', 'Inbox 로드 실패'); + } finally { + loading.inbox = false; + } + } + + async function loadActivity() { + loading.activity = true; + try { + // 최근 7일 + const to = new Date(); + const from = new Date(to.getTime() - 7 * 86400_000); + const res = await listActivity(from.toISOString(), to.toISOString()); + activityItems = res.items; + } catch (err) { + addToast('error', '활동 로드 실패'); + } finally { + loading.activity = false; + } + } + + async function loadUpcoming() { + loading.upcoming = true; + try { + const res = await listUpcoming(7); + upcomingItems = res.items; + } catch (err) { + addToast('error', 'Upcoming 로드 실패'); + } finally { + loading.upcoming = false; + } + } + + // 빠른 행동 기록 — Enter 한 번에 저장 + async function submitQuick(e?: Event) { + e?.preventDefault(); + const title = quickInput.trim(); + if (!title || quickSubmitting) return; + quickSubmitting = true; + try { + const item = await logActivity(title); + // 새 항목을 Activity 탭 최상단에 즉시 반영 + activityItems = [item, ...activityItems]; + quickInput = ''; + addToast('success', '기록됨'); + // 입력 포커스 유지 (연속 입력) + quickInputRef?.focus(); + } catch (err) { + const detail = (err as { detail?: string })?.detail ?? '저장 실패'; + addToast('error', detail); + } finally { + quickSubmitting = false; + } + } + + // Lifecycle ops + async function doComplete(item: EventItem) { + try { + const updated = await completeEvent(item.id); + replaceItem(updated); + addToast('success', '완료 처리'); + } catch (err) { + addToast('error', '완료 실패'); + } + } + + async function doCancel(item: EventItem) { + if (!confirm(`"${item.title}" 취소할까요?`)) return; + try { + const updated = await cancelEvent(item.id); + replaceItem(updated); + addToast('success', '취소됨'); + } catch (err) { + addToast('error', '취소 실패'); + } + } + + async function doDefer(item: EventItem) { + // 기본 = 내일 같은 시각 + const dt = new Date(Date.now() + 86400_000); + const isoLocal = dt.toISOString().slice(0, 16); + const input = prompt('연기할 시각 (YYYY-MM-DDTHH:MM, KST)', isoLocal); + if (!input) return; + try { + const iso = new Date(input).toISOString(); + const updated = await deferEvent(item.id, iso); + replaceItem(updated); + addToast('success', '연기됨'); + } catch (err) { + addToast('error', '연기 실패'); + } + } + + async function doReactivate(item: EventItem) { + try { + const updated = await reactivateEvent(item.id); + replaceItem(updated); + addToast('success', '재활성'); + } catch (err) { + addToast('error', '재활성 실패'); + } + } + + function replaceItem(item: EventItem) { + const replacer = (arr: EventItem[]) => + arr.map((x) => (x.id === item.id ? item : x)); + todayItems = replacer(todayItems); + inboxItems = replacer(inboxItems); + upcomingItems = replacer(upcomingItems); + activityItems = replacer(activityItems); + } + + function timeLabel(item: EventItem): string { + if (item.kind === 'task') { + return item.due_at ? `마감 ${formatDateTimeKst(item.due_at)}` : ''; + } + if (item.kind === 'calendar_event') { + const s = item.start_at ? formatDateTimeKst(item.start_at) : ''; + const e = item.end_at ? formatDateTimeKst(item.end_at) : ''; + return s && e ? `${s} – ${e}` : s; + } + // activity_log + return item.started_at ? relativeTimeKo(item.started_at, nowTick) : ''; + } + + const tabs = [ + { id: 'today', label: '오늘' }, + { id: 'inbox', label: 'Inbox' }, + { id: 'upcoming', label: '예정' }, + { id: 'activity', label: '활동' }, + ]; + + + + events · hyungi PKM + + +
+
+
+

events

+

개인 운영 로그 · 일정 · 할 일 · 회고

+
+ +
+ + +
+ + +
+ + + {#snippet children(activeId)} + {#if activeId === 'today'} + {@render eventList(todayItems, loading.today, 'today', Sun, '오늘 할 일이 없습니다')} + {:else if activeId === 'inbox'} + {@render eventList(inboxItems, loading.inbox, 'inbox', Inbox, 'Inbox 가 비어 있습니다')} + {:else if activeId === 'upcoming'} + {@render eventList(upcomingItems, loading.upcoming, 'upcoming', CalendarClock, '예정된 일정 없음')} + {:else if activeId === 'activity'} + {@render eventList(activityItems, loading.activity, 'activity', History, '최근 활동 없음')} + {/if} + {/snippet} + +
+ +{#snippet eventList(items: EventItem[], isLoading: boolean, tabId: string, EmptyIcon: typeof Sun, emptyMsg: string)} + {#if isLoading} +
+ {#each Array(3) as _} + + {/each} +
+ {:else if items.length === 0} + + {:else} +
+ {/if} +{/snippet} diff --git a/frontend/src/routes/events/[id]/+page.svelte b/frontend/src/routes/events/[id]/+page.svelte new file mode 100644 index 0000000..c04ecdd --- /dev/null +++ b/frontend/src/routes/events/[id]/+page.svelte @@ -0,0 +1,308 @@ + + + + {item?.title ?? 'events'} · hyungi PKM + + +
+
+
+ + {#if loading} + + {:else if item} + +
+ {KIND_LABEL[item.kind]} + + {STATUS_LABEL[item.status]} + + id #{item.id} + source: {item.source} +
+ +
+ + +