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} + + {#each items as item (item.id)} + + + + + {KIND_LABEL[item.kind]} + + {STATUS_LABEL[item.status]} + + {#if item.project_tag} + #{item.project_tag} + {/if} + + + {item.title} + + {#if timeLabel(item)} + {timeLabel(item)} + {/if} + + + {#if item.status !== 'done' && item.status !== 'cancelled' && item.kind !== 'activity_log'} + doComplete(item)} title="완료" /> + doDefer(item)} title="연기" /> + doCancel(item)} title="취소" /> + {:else if (item.status === 'done' || item.status === 'cancelled') && item.kind !== 'activity_log'} + doReactivate(item)} title="재활성" /> + {/if} + + + + {/each} + + {/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 + + + + + + events 상세 + + + {#if loading} + + {:else if item} + + + {KIND_LABEL[item.kind]} + + {STATUS_LABEL[item.status]} + + id #{item.id} + source: {item.source} + + + + + 제목 + + + + + 설명 (markdown) + + + + {#if item.kind === 'task'} + + 마감 (due_at) + + + {/if} + + {#if item.kind === 'calendar_event' || item.kind === 'task'} + + + 시작 (start_at) + + + + 종료 (end_at) + + + + + + all-day + + {/if} + + + + 우선순위 (1–4) + + + + 프로젝트 태그 + + + + + + + + {#if item.status !== 'done' && item.status !== 'cancelled' && item.kind !== 'activity_log'} + lifecycle('complete')}>완료 + lifecycle('defer')}>연기 + lifecycle('cancel')}>취소 + {:else if (item.status === 'done' || item.status === 'cancelled') && item.kind !== 'activity_log'} + lifecycle('reactivate')}>재활성 + {/if} + + 저장 + + + + + 변경 이력 + {#if history.length === 0} + 이력 없음 + {:else} + + {#each history as h (h.id)} + + + + {formatDateTimeKst(h.changed_at)} · {actorLabel(h.changed_by)} + + + {CHANGE_KIND_LABEL[h.change_kind] ?? h.change_kind} + + + {/each} + + {/if} + + {/if} + diff --git a/frontend/src/routes/events/new/+page.svelte b/frontend/src/routes/events/new/+page.svelte new file mode 100644 index 0000000..b86093e --- /dev/null +++ b/frontend/src/routes/events/new/+page.svelte @@ -0,0 +1,171 @@ + + + + 새 events · hyungi PKM + + + + + + 새 events + + + + + 유형 + + {#each ['task', 'calendar_event', 'activity_log'] as k} + (kind = k as EventKind)} + > + {KIND_LABEL[k as EventKind]} + + {/each} + + + + + 제목 * + + + + + 설명 (markdown, 선택) + + + + {#if kind === 'task'} + + 마감 (due_at, 선택) + + + + 시작 (start_at, 선택 — "14:00 전화" 같은 경우) + + + {:else if kind === 'calendar_event'} + + + 시작 (start_at) * + + + + 종료 (end_at, 선택) + + + + + + all-day + + {:else if kind === 'activity_log'} + + 비워두면 시작·종료 모두 now() 로 기록됩니다 (방금 한 일). + + + + 시작 시각 (선택) + + + + 종료 시각 (선택) + + + + {/if} + + + + 우선순위 (1–4) + + + + 프로젝트 태그 + + + + + + 생성 + + +
개인 운영 로그 · 일정 · 할 일 · 회고
이력 없음
+ 비워두면 시작·종료 모두 now() 로 기록됩니다 (방금 한 일). +
now()