Merge pull request 'feat/events-ui-mvp' (#6) from feat/events-ui-mvp into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-05-11 08:11:32 +09:00
7 changed files with 1172 additions and 1 deletions
+34
View File
@@ -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 ───
+95
View File
@@ -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":"<USERNAME>","password":"<PASSWORD>","totp_code":"<TOTP_IF_ENABLED>"}' \
| 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 추가 시 본 문서 갱신.
+11 -1
View File
@@ -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 @@
메모
</span>
</a>
<a
href="/events"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
{$page.url.pathname.startsWith('/events') ? 'bg-accent/15 text-accent' : 'text-text hover:bg-surface'}"
>
<span class="flex items-center gap-2">
<CalendarCheck size={16} />
events
</span>
</a>
<a
href="/study"
class="flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors
+242
View File
@@ -0,0 +1,242 @@
// events 도메인 helper (PR-2 UI MVP).
// type 정의 + 표시용 라벨 + lifecycle 호출 wrapper.
import { api } from '$lib/api';
export type EventKind = 'task' | 'calendar_event' | 'activity_log';
export type EventStatus =
| 'inbox'
| 'next'
| 'scheduled'
| 'in_progress'
| 'done'
| 'cancelled'
| 'deferred';
export type EventSource =
| 'manual'
| 'memo'
| 'email'
| 'chat'
| 'webhook'
| 'git_commit'
| 'claude_code';
export type EventActor = 'manual' | 'eid' | 'email_ingest' | 'system';
export interface EventItem {
id: number;
title: string;
description: string | null;
kind: EventKind;
status: EventStatus;
due_at: string | null;
start_at: string | null;
end_at: string | null;
started_at: string | null;
ended_at: string | null;
all_day: boolean;
timezone: string | null;
defer_until: string | null;
completed_at: string | null;
cancelled_at: string | null;
priority: number | null;
project_tag: string | null;
tags: unknown[];
source: EventSource;
source_ref: string | null;
raw_metadata: Record<string, unknown>;
memo_document_id: number | null;
user_id: number;
created_by: EventActor;
created_at: string;
updated_at: string;
}
export interface EventListResponse {
items: EventItem[];
total: number;
}
export const KIND_LABEL: Record<EventKind, string> = {
task: '할 일',
calendar_event: '일정',
activity_log: '기록',
};
export const STATUS_LABEL: Record<EventStatus, string> = {
inbox: 'Inbox',
next: '다음',
scheduled: '예정',
in_progress: '진행 중',
done: '완료',
cancelled: '취소',
deferred: '연기',
};
export const STATUS_COLOR: Record<EventStatus, string> = {
inbox: 'bg-slate-100 text-slate-700',
next: 'bg-indigo-100 text-indigo-700',
scheduled: 'bg-blue-100 text-blue-700',
in_progress: 'bg-amber-100 text-amber-700',
done: 'bg-emerald-100 text-emerald-700',
cancelled: 'bg-rose-100 text-rose-700',
deferred: 'bg-slate-100 text-slate-500',
};
export const KIND_COLOR: Record<EventKind, string> = {
task: 'border-l-4 border-indigo-400',
calendar_event: 'border-l-4 border-blue-400',
activity_log: 'border-l-4 border-emerald-400',
};
// ─── API wrappers ───
export interface EventCreatePayload {
title: string;
description?: string | null;
kind: EventKind;
status?: EventStatus | null;
due_at?: string | null;
start_at?: string | null;
end_at?: string | null;
started_at?: string | null;
ended_at?: string | null;
all_day?: boolean;
timezone?: string | null;
priority?: number | null;
project_tag?: string | null;
tags?: unknown[];
memo_document_id?: number | null;
source?: EventSource;
source_ref?: string | null;
raw_metadata?: Record<string, unknown>;
}
export async function createEvent(payload: EventCreatePayload): Promise<EventItem> {
return api<EventItem>('/events/', {
method: 'POST',
body: JSON.stringify(payload),
});
}
// 빠른 행동 기록 (5초 UX 핵심) — title 1개만 받고 서버 default 활용.
export async function logActivity(title: string, projectTag?: string | null): Promise<EventItem> {
const payload: EventCreatePayload = {
title,
kind: 'activity_log',
};
if (projectTag) payload.project_tag = projectTag;
return createEvent(payload);
}
export interface EventPatchPayload {
title?: string;
description?: string | null;
due_at?: string | null;
start_at?: string | null;
end_at?: string | null;
started_at?: string | null;
ended_at?: string | null;
all_day?: boolean;
timezone?: string | null;
priority?: number | null;
project_tag?: string | null;
tags?: unknown[];
memo_document_id?: number | null;
}
export async function patchEvent(id: number, payload: EventPatchPayload): Promise<EventItem> {
return api<EventItem>(`/events/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export async function getEvent(id: number): Promise<EventItem> {
return api<EventItem>(`/events/${id}`);
}
export async function listToday(timezone = 'Asia/Seoul'): Promise<EventListResponse> {
return api<EventListResponse>(`/events/today?timezone=${encodeURIComponent(timezone)}`);
}
export async function listInbox(): Promise<EventListResponse> {
return api<EventListResponse>('/events/inbox');
}
export async function listActivity(fromIso?: string, toIso?: string): Promise<EventListResponse> {
const params = new URLSearchParams();
if (fromIso) params.set('from', fromIso);
if (toIso) params.set('to', toIso);
const qs = params.toString();
return api<EventListResponse>(`/events/activity${qs ? '?' + qs : ''}`);
}
export async function listUpcoming(days = 7): Promise<EventListResponse> {
const now = new Date();
const to = new Date(now.getTime() + days * 86400_000);
const params = new URLSearchParams({
from: now.toISOString(),
to: to.toISOString(),
status: 'scheduled,next,deferred',
});
return api<EventListResponse>(`/events/?${params.toString()}`);
}
// Lifecycle endpoints
export async function completeEvent(id: number): Promise<EventItem> {
return api<EventItem>(`/events/${id}/complete`, { method: 'POST' });
}
export async function cancelEvent(id: number): Promise<EventItem> {
return api<EventItem>(`/events/${id}/cancel`, { method: 'POST' });
}
export async function deferEvent(id: number, deferUntilIso: string): Promise<EventItem> {
return api<EventItem>(`/events/${id}/defer`, {
method: 'POST',
body: JSON.stringify({ defer_until: deferUntilIso }),
});
}
export async function reactivateEvent(id: number): Promise<EventItem> {
return api<EventItem>(`/events/${id}/reactivate`, { method: 'POST' });
}
// 표시 유틸
export function formatDateTimeKst(iso: string | null | undefined, opts?: { dateOnly?: boolean }): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
if (opts?.dateOnly) {
return d.toLocaleDateString('ko-KR', { timeZone: 'Asia/Seoul', month: 'numeric', day: 'numeric', weekday: 'short' });
}
return d.toLocaleString('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function relativeTimeKo(iso: string | null | undefined, nowMs: number = Date.now()): string {
if (!iso) return '';
const t = new Date(iso).getTime();
if (isNaN(t)) return '';
const diff = nowMs - t;
const abs = Math.abs(diff);
const future = diff < 0;
if (abs < 60_000) return future ? '곧' : '방금';
const min = Math.round(abs / 60_000);
if (min < 60) return future ? `${min}분 후` : `${min}분 전`;
const hr = Math.round(min / 60);
if (hr < 24) return future ? `${hr}시간 후` : `${hr}시간 전`;
const day = Math.round(hr / 24);
if (day < 30) return future ? `${day}일 후` : `${day}일 전`;
const mo = Math.round(day / 30);
if (mo < 12) return future ? `${mo}달 후` : `${mo}달 전`;
const yr = Math.round(mo / 12);
return future ? `${yr}년 후` : `${yr}년 전`;
}
+311
View File
@@ -0,0 +1,311 @@
<script lang="ts">
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<EventItem[]>([]);
let inboxItems = $state<EventItem[]>([]);
let upcomingItems = $state<EventItem[]>([]);
let activityItems = $state<EventItem[]>([]);
let loading = $state<Record<string, boolean>>({ today: true, inbox: true, upcoming: true, activity: true });
// 빠른 행동 기록 (5초 UX 핵심)
let quickInput = $state('');
let quickSubmitting = $state(false);
let quickInputRef = $state<HTMLInputElement | null>(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: '활동' },
];
</script>
<svelte:head>
<title>events · hyungi PKM</title>
</svelte:head>
<div class="mx-auto max-w-3xl space-y-6 px-4 py-6">
<header class="flex items-end justify-between gap-3">
<div class="space-y-1">
<h1 class="text-2xl font-semibold">events</h1>
<p class="text-sm text-slate-500">개인 운영 로그 · 일정 · 할 일 · 회고</p>
</div>
<Button variant="secondary" size="sm" icon={Plus} href="/events/new">새 항목</Button>
</header>
<!-- 빠른 행동 기록 (5초 UX 핵심) -->
<form onsubmit={submitQuick} class="flex items-stretch gap-2 rounded-lg border border-slate-200 bg-white p-2 shadow-sm">
<input
bind:this={quickInputRef}
bind:value={quickInput}
type="text"
placeholder="방금 한 일 기록… (Enter 저장)"
class="flex-1 rounded-md border-0 bg-transparent px-2 py-2 text-sm outline-none focus:ring-0"
disabled={quickSubmitting}
autocomplete="off"
/>
<Button type="submit" variant="primary" size="sm" loading={quickSubmitting} disabled={!quickInput.trim()} icon={Plus}>
기록
</Button>
</form>
<Tabs {tabs} bind:value={activeTab}>
{#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}
</Tabs>
</div>
{#snippet eventList(items: EventItem[], isLoading: boolean, tabId: string, EmptyIcon: typeof Sun, emptyMsg: string)}
{#if isLoading}
<div class="space-y-2">
{#each Array(3) as _}
<Skeleton class="h-16 rounded-lg" />
{/each}
</div>
{:else if items.length === 0}
<EmptyState icon={EmptyIcon} message={emptyMsg} />
{:else}
<ul class="space-y-2">
{#each items as item (item.id)}
<li>
<Card class="flex items-start gap-3 p-3 {KIND_COLOR[item.kind]}">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-xs text-slate-500">
<span>{KIND_LABEL[item.kind]}</span>
<span class="rounded px-1.5 py-0.5 text-[10px] {STATUS_COLOR[item.status]}">
{STATUS_LABEL[item.status]}
</span>
{#if item.project_tag}
<span class="text-slate-400">#{item.project_tag}</span>
{/if}
</div>
<a href="/events/{item.id}" class="mt-1 block break-words text-sm font-medium hover:underline">
{item.title}
</a>
{#if timeLabel(item)}
<div class="mt-0.5 text-xs text-slate-500">{timeLabel(item)}</div>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
{#if item.status !== 'done' && item.status !== 'cancelled' && item.kind !== 'activity_log'}
<Button variant="ghost" size="sm" icon={Check} onclick={() => doComplete(item)} title="완료" />
<Button variant="ghost" size="sm" icon={Pause} onclick={() => doDefer(item)} title="연기" />
<Button variant="ghost" size="sm" icon={XIcon} onclick={() => doCancel(item)} title="취소" />
{:else if (item.status === 'done' || item.status === 'cancelled') && item.kind !== 'activity_log'}
<Button variant="ghost" size="sm" icon={RotateCcw} onclick={() => doReactivate(item)} title="재활성" />
{/if}
</div>
</Card>
</li>
{/each}
</ul>
{/if}
{/snippet}
@@ -0,0 +1,308 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import TextInput from '$lib/components/ui/TextInput.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
import {
ArrowLeft,
Check,
X as XIcon,
Pause,
RotateCcw,
Save,
} from 'lucide-svelte';
import {
type EventItem,
type EventKind,
KIND_LABEL,
STATUS_LABEL,
STATUS_COLOR,
KIND_COLOR,
getEvent,
patchEvent,
completeEvent,
cancelEvent,
deferEvent,
reactivateEvent,
formatDateTimeKst,
relativeTimeKo,
} from '$lib/utils/events';
const eventId = $derived(parseInt(page.params.id ?? '0', 10));
let item = $state<EventItem | null>(null);
let history = $state<HistoryEntry[]>([]);
let loading = $state(true);
let saving = $state(false);
// edit fields (PATCH 허용만)
let editTitle = $state('');
let editDescription = $state('');
let editDueAt = $state('');
let editStartAt = $state('');
let editEndAt = $state('');
let editAllDay = $state(false);
let editPriority = $state<number | ''>('');
let editProjectTag = $state('');
interface HistoryEntry {
id: number;
event_id: number;
changed_at: string;
changed_by: string;
change_kind: string;
before: Record<string, unknown> | null;
after: Record<string, unknown>;
}
const CHANGE_KIND_LABEL: Record<string, string> = {
create: '생성',
reschedule: '시간 변경',
defer: '연기',
reactivate: '재활성',
complete: '완료',
cancel: '취소',
};
onMount(() => {
if (!eventId) {
addToast('error', '유효하지 않은 ID');
goto('/events');
return;
}
void load();
});
async function load() {
loading = true;
try {
const [ev, hist] = await Promise.all([
getEvent(eventId),
api<{ items: HistoryEntry[] }>(`/events/${eventId}/history`),
]);
item = ev;
history = hist.items;
// edit fields seed
editTitle = ev.title;
editDescription = ev.description ?? '';
editDueAt = isoToLocalInput(ev.due_at);
editStartAt = isoToLocalInput(ev.start_at);
editEndAt = isoToLocalInput(ev.end_at);
editAllDay = ev.all_day;
editPriority = ev.priority ?? '';
editProjectTag = ev.project_tag ?? '';
} catch (err) {
const detail = (err as { detail?: string })?.detail ?? '로드 실패';
addToast('error', detail);
if ((err as { status?: number })?.status === 404) goto('/events');
} finally {
loading = false;
}
}
function isoToLocalInput(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
// YYYY-MM-DDTHH:MM (KST 변환은 브라우저 default)
const off = d.getTimezoneOffset();
const local = new Date(d.getTime() - off * 60_000);
return local.toISOString().slice(0, 16);
}
function localInputToIso(s: string): string | null {
if (!s) return null;
const d = new Date(s);
if (isNaN(d.getTime())) return null;
return d.toISOString();
}
async function save() {
if (!item || saving) return;
saving = true;
try {
const patch: Record<string, unknown> = {};
if (editTitle !== item.title) patch.title = editTitle;
if ((editDescription || null) !== (item.description ?? null))
patch.description = editDescription || null;
const newDue = localInputToIso(editDueAt);
if (newDue !== item.due_at) patch.due_at = newDue;
const newStart = localInputToIso(editStartAt);
if (newStart !== item.start_at) patch.start_at = newStart;
const newEnd = localInputToIso(editEndAt);
if (newEnd !== item.end_at) patch.end_at = newEnd;
if (editAllDay !== item.all_day) patch.all_day = editAllDay;
const newPriority = editPriority === '' ? null : Number(editPriority);
if (newPriority !== item.priority) patch.priority = newPriority;
if ((editProjectTag || null) !== (item.project_tag ?? null))
patch.project_tag = editProjectTag || null;
if (Object.keys(patch).length === 0) {
addToast('info', '변경 사항 없음');
return;
}
const updated = await patchEvent(item.id, patch);
item = updated;
addToast('success', '저장됨');
// history 가 새 reschedule row 가질 수 있어 reload
await reloadHistory();
} catch (err) {
const detail = (err as { detail?: string })?.detail ?? '저장 실패';
addToast('error', detail);
} finally {
saving = false;
}
}
async function reloadHistory() {
if (!item) return;
try {
const hist = await api<{ items: HistoryEntry[] }>(`/events/${item.id}/history`);
history = hist.items;
} catch (err) {
// silent
}
}
async function lifecycle(action: 'complete' | 'cancel' | 'defer' | 'reactivate') {
if (!item) return;
try {
let updated: EventItem;
if (action === 'complete') updated = await completeEvent(item.id);
else if (action === 'cancel') {
if (!confirm(`"${item.title}" 취소할까요?`)) return;
updated = await cancelEvent(item.id);
} else if (action === 'defer') {
const dt = new Date(Date.now() + 86400_000);
const input = prompt('연기할 시각 (YYYY-MM-DDTHH:MM, KST)', isoToLocalInput(dt.toISOString()));
if (!input) return;
updated = await deferEvent(item.id, new Date(input).toISOString());
} else {
updated = await reactivateEvent(item.id);
}
item = updated;
addToast('success', '처리됨');
await reloadHistory();
} catch (err) {
const detail = (err as { detail?: string })?.detail ?? '실패';
addToast('error', detail);
}
}
function actorLabel(actor: string): string {
return { manual: '나', eid: '이드', email_ingest: '메일', system: '시스템' }[actor] ?? actor;
}
</script>
<svelte:head>
<title>{item?.title ?? 'events'} · hyungi PKM</title>
</svelte:head>
<div class="mx-auto max-w-3xl space-y-6 px-4 py-6">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" icon={ArrowLeft} href="/events" />
<h1 class="text-xl font-semibold">events 상세</h1>
</div>
{#if loading}
<Skeleton class="h-64 rounded-lg" />
{:else if item}
<Card class="space-y-4 p-4 {KIND_COLOR[item.kind]}">
<div class="flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span>{KIND_LABEL[item.kind]}</span>
<span class="rounded px-1.5 py-0.5 text-[10px] {STATUS_COLOR[item.status]}">
{STATUS_LABEL[item.status]}
</span>
<span class="text-slate-400">id #{item.id}</span>
<span class="text-slate-400">source: {item.source}</span>
</div>
<div class="space-y-3">
<label class="block">
<span class="block text-xs text-slate-500">제목</span>
<TextInput bind:value={editTitle} />
</label>
<label class="block">
<span class="block text-xs text-slate-500">설명 (markdown)</span>
<Textarea bind:value={editDescription} rows={4} />
</label>
{#if item.kind === 'task'}
<label class="block">
<span class="block text-xs text-slate-500">마감 (due_at)</span>
<input type="datetime-local" bind:value={editDueAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
{/if}
{#if item.kind === 'calendar_event' || item.kind === 'task'}
<div class="flex flex-wrap gap-3">
<label class="block">
<span class="block text-xs text-slate-500">시작 (start_at)</span>
<input type="datetime-local" bind:value={editStartAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
<label class="block">
<span class="block text-xs text-slate-500">종료 (end_at)</span>
<input type="datetime-local" bind:value={editEndAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
</div>
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={editAllDay} />
<span>all-day</span>
</label>
{/if}
<div class="flex flex-wrap gap-3">
<label class="block">
<span class="block text-xs text-slate-500">우선순위 (14)</span>
<input type="number" min="1" max="4" bind:value={editPriority} class="w-20 rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
<label class="block">
<span class="block text-xs text-slate-500">프로젝트 태그</span>
<TextInput bind:value={editProjectTag} placeholder="(없음)" />
</label>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 pt-2">
<div class="flex flex-wrap items-center gap-2">
{#if item.status !== 'done' && item.status !== 'cancelled' && item.kind !== 'activity_log'}
<Button variant="primary" size="sm" icon={Check} onclick={() => lifecycle('complete')}>완료</Button>
<Button variant="secondary" size="sm" icon={Pause} onclick={() => lifecycle('defer')}>연기</Button>
<Button variant="ghost" size="sm" icon={XIcon} onclick={() => lifecycle('cancel')}>취소</Button>
{:else if (item.status === 'done' || item.status === 'cancelled') && item.kind !== 'activity_log'}
<Button variant="secondary" size="sm" icon={RotateCcw} onclick={() => lifecycle('reactivate')}>재활성</Button>
{/if}
</div>
<Button variant="primary" size="sm" icon={Save} loading={saving} onclick={save}>저장</Button>
</div>
</Card>
<section class="space-y-2">
<h2 class="text-sm font-semibold text-slate-700">변경 이력</h2>
{#if history.length === 0}
<p class="text-sm text-slate-500">이력 없음</p>
{:else}
<ol class="space-y-1.5 border-l border-slate-200 pl-4">
{#each history as h (h.id)}
<li class="relative">
<span class="absolute -left-[21px] top-1.5 h-2 w-2 rounded-full bg-slate-300"></span>
<div class="text-xs text-slate-500">
{formatDateTimeKst(h.changed_at)} · {actorLabel(h.changed_by)}
</div>
<div class="text-sm">
{CHANGE_KIND_LABEL[h.change_kind] ?? h.change_kind}
</div>
</li>
{/each}
</ol>
{/if}
</section>
{/if}
</div>
+171
View File
@@ -0,0 +1,171 @@
<script lang="ts">
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 TextInput from '$lib/components/ui/TextInput.svelte';
import Textarea from '$lib/components/ui/Textarea.svelte';
import { ArrowLeft } from 'lucide-svelte';
import {
type EventKind,
type EventCreatePayload,
KIND_LABEL,
createEvent,
} from '$lib/utils/events';
let kind = $state<EventKind>('task');
let title = $state('');
let description = $state('');
let dueAt = $state('');
let startAt = $state('');
let endAt = $state('');
let allDay = $state(false);
let startedAt = $state('');
let endedAt = $state('');
let priority = $state<number | ''>('');
let projectTag = $state('');
let submitting = $state(false);
function localToIso(s: string): string | null {
if (!s) return null;
const d = new Date(s);
return isNaN(d.getTime()) ? null : d.toISOString();
}
async function submit() {
if (submitting) return;
if (!title.trim()) {
addToast('error', '제목을 입력하세요');
return;
}
submitting = true;
try {
const payload: EventCreatePayload = {
title: title.trim(),
kind,
};
if (description.trim()) payload.description = description.trim();
if (kind === 'task') {
if (dueAt) payload.due_at = localToIso(dueAt);
if (startAt) payload.start_at = localToIso(startAt);
} else if (kind === 'calendar_event') {
if (!startAt) {
addToast('error', 'calendar_event 는 시작 시간 필수');
submitting = false;
return;
}
payload.start_at = localToIso(startAt);
if (endAt) payload.end_at = localToIso(endAt);
payload.all_day = allDay;
} else if (kind === 'activity_log') {
if (startedAt) payload.started_at = localToIso(startedAt);
if (endedAt) payload.ended_at = localToIso(endedAt);
// 모두 비우면 서버 default (now())
}
if (priority !== '') payload.priority = Number(priority);
if (projectTag.trim()) payload.project_tag = projectTag.trim();
const item = await createEvent(payload);
addToast('success', '생성됨');
goto(`/events/${item.id}`);
} catch (err) {
const detail = (err as { detail?: string })?.detail ?? '생성 실패';
addToast('error', detail);
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>새 events · hyungi PKM</title>
</svelte:head>
<div class="mx-auto max-w-xl space-y-4 px-4 py-6">
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" icon={ArrowLeft} href="/events" />
<h1 class="text-xl font-semibold">새 events</h1>
</div>
<Card class="space-y-4 p-4">
<div>
<span class="block text-xs text-slate-500">유형</span>
<div class="mt-1 flex gap-2">
{#each ['task', 'calendar_event', 'activity_log'] as k}
<button
type="button"
class="rounded-md border px-3 py-1.5 text-sm transition {kind === k ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 hover:bg-slate-50'}"
onclick={() => (kind = k as EventKind)}
>
{KIND_LABEL[k as EventKind]}
</button>
{/each}
</div>
</div>
<label class="block">
<span class="block text-xs text-slate-500">제목 *</span>
<TextInput bind:value={title} placeholder="할 / 일정 / 기록한 행동" />
</label>
<label class="block">
<span class="block text-xs text-slate-500">설명 (markdown, 선택)</span>
<Textarea bind:value={description} rows={3} />
</label>
{#if kind === 'task'}
<label class="block">
<span class="block text-xs text-slate-500">마감 (due_at, 선택)</span>
<input type="datetime-local" bind:value={dueAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
<label class="block">
<span class="block text-xs text-slate-500">시작 (start_at, 선택 — "14:00 전화" 같은 경우)</span>
<input type="datetime-local" bind:value={startAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
{:else if kind === 'calendar_event'}
<div class="flex flex-wrap gap-3">
<label class="block">
<span class="block text-xs text-slate-500">시작 (start_at) *</span>
<input type="datetime-local" bind:value={startAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" required />
</label>
<label class="block">
<span class="block text-xs text-slate-500">종료 (end_at, 선택)</span>
<input type="datetime-local" bind:value={endAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
</div>
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox" bind:checked={allDay} />
<span>all-day</span>
</label>
{:else if kind === 'activity_log'}
<p class="text-xs text-slate-500">
비워두면 시작·종료 모두 <code>now()</code> 로 기록됩니다 (방금 한 일).
</p>
<div class="flex flex-wrap gap-3">
<label class="block">
<span class="block text-xs text-slate-500">시작 시각 (선택)</span>
<input type="datetime-local" bind:value={startedAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
<label class="block">
<span class="block text-xs text-slate-500">종료 시각 (선택)</span>
<input type="datetime-local" bind:value={endedAt} class="rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
</div>
{/if}
<div class="flex flex-wrap gap-3">
<label class="block">
<span class="block text-xs text-slate-500">우선순위 (14)</span>
<input type="number" min="1" max="4" bind:value={priority} class="w-20 rounded-md border border-slate-200 px-2 py-1 text-sm" />
</label>
<label class="block">
<span class="block text-xs text-slate-500">프로젝트 태그</span>
<TextInput bind:value={projectTag} placeholder="예: doc-server" />
</label>
</div>
<div class="flex justify-end">
<Button variant="primary" size="md" loading={submitting} onclick={submit}>생성</Button>
</div>
</Card>
</div>