feat(memo): auto-hide completed tasks after 10s with toggle

체크박스 체크 후 10초 경과 항목을 대시보드 핀 메모 / /memos 에서
자동 숨김, 메모 푸터 "완료 N개 보기" 버튼으로 토글.

- migration 161: documents.memo_task_state JSONB — {"<idx>":{"checked_at":"ISO"}}
- PATCH /memos/{id}/tasks/{task_index} 전용 엔드포인트:
  · SELECT FOR UPDATE 로 동시 토글 race 차단
  · task_index drift 시 stale state 자동 정리 (400 대신 200)
  · AI 재처리/큐 enqueue 의도적 스킵 + memo_task_toggle_skip_ai 로그
- renderMemoHtml(taskStates, now) → 경과 항목에 memo-task-hidden 클래스
- Svelte 5 $effect cleanup 으로 setInterval 누수 방지
This commit is contained in:
Hyungi Ahn
2026-04-24 10:20:02 +09:00
parent ebc37961e0
commit 9d344c87ea
6 changed files with 276 additions and 35 deletions
+96
View File
@@ -1,6 +1,7 @@
"""메모 CRUD API — 파일 없는 문서(file_type='note')"""
import hashlib
import logging
import re
from datetime import datetime, timezone
from typing import Annotated
@@ -16,8 +17,13 @@ from models.document import Document
from models.queue import ProcessingQueue, enqueue_stage
from models.user import User
logger = logging.getLogger(__name__)
router = APIRouter()
# markdown task line: "- [ ] ..." 또는 "- [x] ..."
TASK_LINE_RE = re.compile(r"^(\s*- \[)([ xX])(\].*)$")
# #태그 파싱 패턴: 한글/영문/숫자/밑줄, 2자 이상
TAG_PATTERN = re.compile(r"(?:^|(?<=\s))#([가-힣a-zA-Z0-9_]{2,})")
@@ -46,6 +52,28 @@ def _auto_title(content: str) -> str:
return title
def _toggle_task_line(content: str, target_index: int, checked: bool) -> tuple[str, bool]:
"""N번째 markdown task line을 찾아 checked/unchecked 상태로 설정.
(new_content, found) 반환. found=False면 target_index에 해당하는 task line이 없음
(본문 편집으로 drift된 경우).
"""
lines = content.split("\n")
ti = 0
found = False
for i, line in enumerate(lines):
m = TASK_LINE_RE.match(line)
if not m:
continue
if ti == target_index:
mark = "x" if checked else " "
lines[i] = m.group(1) + mark + m.group(3)
found = True
break
ti += 1
return "\n".join(lines), found
async def _enqueue_ai_stages(session: AsyncSession, document_id: int):
"""classify + embed + chunk 큐 등록. 기존 pending 건 정리 (중복 방지)."""
stages = ["classify", "embed", "chunk"]
@@ -78,6 +106,10 @@ class ArchiveSet(BaseModel):
archived: bool
class TaskToggle(BaseModel):
checked: bool
class MemoResponse(BaseModel):
id: int
title: str | None
@@ -91,6 +123,7 @@ class MemoResponse(BaseModel):
pinned: bool
archived: bool
ask_includable: bool
memo_task_state: dict # {"<task_index>": {"checked_at": "<ISO8601>"}}
created_at: datetime
updated_at: datetime
@@ -119,6 +152,7 @@ def _to_memo_response(doc: Document) -> MemoResponse:
pinned=doc.pinned,
archived=doc.archived,
ask_includable=doc.ask_includable,
memo_task_state=dict(doc.memo_task_state or {}),
created_at=doc.created_at,
updated_at=doc.updated_at,
)
@@ -279,6 +313,68 @@ async def update_memo(
return _to_memo_response(doc)
@router.patch("/{memo_id}/tasks/{task_index}", response_model=MemoResponse)
async def toggle_memo_task(
memo_id: int,
task_index: int,
body: TaskToggle,
user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""메모 체크박스 토글 전용 엔드포인트.
N번째 markdown task line의 체크 상태를 설정하고 memo_task_state에 시각 기록.
AI 재처리(classify/embed/chunk)는 **의도적으로 스킵** — 체크박스 한 번에 재분석을 트리거하는 건 과하다.
같은 row를 동시에 토글하는 race 방지를 위해 SELECT ... FOR UPDATE 사용.
"""
# ❶ FOR UPDATE: 같은 row 동시 토글 race 차단 (JSONB 전체 replace라 필수)
doc = await session.get(Document, memo_id, with_for_update=True)
if not doc or doc.file_type != "note" or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="메모를 찾을 수 없습니다")
state = dict(doc.memo_task_state or {})
key = str(task_index)
# ❷ content의 N번째 task line 토글
new_content, found = _toggle_task_line(doc.extracted_text or "", task_index, body.checked)
if not found:
# drift: 사용자가 본문 편집으로 task_index 매칭이 깨짐 → stale state만 정리하고 200 OK
stale_removed = key in state
if stale_removed:
state.pop(key, None)
doc.memo_task_state = state
await session.commit()
await session.refresh(doc)
logger.info(
"memo_task_toggle_drift memo_id=%s task_index=%s stale_removed=%s",
memo_id, task_index, stale_removed,
)
return _to_memo_response(doc)
doc.extracted_text = new_content
doc.file_hash = _content_hash(new_content)
doc.file_size = len(new_content.encode("utf-8"))
# ❸ task_state 갱신 (JSONB 전체 replace — FOR UPDATE lock 아래라 race safe)
if body.checked:
state[key] = {"checked_at": datetime.now(timezone.utc).isoformat()}
else:
state.pop(key, None)
doc.memo_task_state = state
doc.updated_at = datetime.now(timezone.utc)
# AI 재처리 / user_tags 재파싱 / chunks 삭제 / queue enqueue — 모두 의도적 스킵.
# 왜 스킵하는지 나중에 디버깅하지 않아도 되도록 명시 로그.
logger.info(
"memo_task_toggle_skip_ai memo_id=%s task_index=%s checked=%s",
memo_id, task_index, body.checked,
)
await session.commit()
await session.refresh(doc)
return _to_memo_response(doc)
@router.delete("/{memo_id}", status_code=204)
async def delete_memo(
memo_id: int,
+4
View File
@@ -67,6 +67,10 @@ class Document(Base):
# 아카이브 (현재 메모 UX 전용, 문서 쪽에는 노출하지 않음)
archived: Mapped[bool] = mapped_column(Boolean, default=False)
# 메모 체크박스별 메타 — {"<task_index>": {"checked_at": "<ISO8601 UTC>"}}
# UI에서 체크 후 10초 경과 항목 숨김 판정에 사용. file_type='note'에서만 의미 있음.
memo_task_state: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# ODF 변환
derived_path: Mapped[str | None] = mapped_column(Text) # 변환본 경로 (.derived/)
original_format: Mapped[str | None] = mapped_column(String(20))
+86 -11
View File
@@ -30,8 +30,16 @@ const CHECKBOX_INPUT_RE = /<input\b(?=[^>]*type="checkbox")(?=[^>]*disabled)[^>]
export interface RenderOptions {
compact?: boolean;
interactive?: boolean;
/** 서버에서 내려온 memo_task_state — {"<task_index>": {"checked_at": "<ISO>"}} */
taskStates?: Record<string, { checked_at?: string }>;
/** 현재 시각 tick. 지정 시 checked_at 경과 항목에 task-hidden 클래스 부여. */
now?: Date;
/** 체크 후 자동 숨김까지 대기 시간 (ms). 기본 10초. */
hideAfterMs?: number;
}
export const DEFAULT_HIDE_AFTER_MS = 10_000;
/**
* 메모 마크다운 → HTML 변환.
* - GFM task list checkbox를 인터랙티브하게 교체
@@ -39,7 +47,12 @@ export interface RenderOptions {
* - #태그를 클릭 가능 링크로 변환
*/
export function renderMemoHtml(markdown: string, options: RenderOptions = {}): string {
const { interactive = true } = options;
const {
interactive = true,
taskStates = {},
now,
hideAfterMs = DEFAULT_HIDE_AFTER_MS,
} = options;
// 1. #태그 → 링크 (marked 전에 처리, 마크다운 파싱 방해 않는 위치)
let text = markdown.replace(
@@ -50,21 +63,33 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s
// 2. marked → HTML
let html = marked(text) as string;
// 3. checkbox input 교체 (인터랙티브 + data-task-index)
// 3. checkbox input 교체 (인터랙티브 + data-task-index + data-checked-at)
let taskIdx = 0;
html = html.replace(CHECKBOX_INPUT_RE, (match) => {
const idx = taskIdx++;
const isChecked = /checked/.test(match);
const checkedAt = taskStates[String(idx)]?.checked_at ?? '';
const dataCheckedAt = checkedAt ? ` data-checked-at="${checkedAt}"` : '';
if (interactive) {
return `<input type="checkbox" data-task-index="${idx}" ${isChecked ? 'checked' : ''} class="memo-checkbox" />`;
return `<input type="checkbox" data-task-index="${idx}"${dataCheckedAt} ${isChecked ? 'checked' : ''} class="memo-checkbox" />`;
}
return `<input type="checkbox" ${isChecked ? 'checked' : ''} disabled class="memo-checkbox" />`;
return `<input type="checkbox"${dataCheckedAt} ${isChecked ? 'checked' : ''} disabled class="memo-checkbox" />`;
});
// 4. checkbox line의 due date badge 변환
// <li> 태그 내부에서만 @YYYY-MM-DD를 badge로 교체
// 4. checkbox line의 due date badge 변환 + 자동 숨김 클래스
const nowMs = now ? now.getTime() : Date.now();
html = html.replace(/<li>(<input[^>]*class="memo-checkbox"[^>]*>)(.*?)<\/li>/gi, (fullMatch, inputTag, rest) => {
const isChecked = /checked/.test(inputTag);
// checked_at 추출 → 10초 경과 판정
const checkedAtMatch = /data-checked-at="([^"]+)"/.exec(inputTag);
let hidden = false;
if (isChecked && checkedAtMatch) {
const checkedAtMs = Date.parse(checkedAtMatch[1]);
if (!Number.isNaN(checkedAtMs) && nowMs - checkedAtMs >= hideAfterMs) {
hidden = true;
}
}
// 마지막 @YYYY-MM-DD만 badge로 변환
let lastDue: ParsedDueDate | null = null;
let lastMatch: { index: number; length: number } | null = null;
@@ -78,18 +103,22 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s
}
}
const classes = [
isChecked ? 'memo-task-done' : '',
hidden ? 'memo-task-hidden' : '',
].filter(Boolean).join(' ');
const classAttr = classes ? ` class="${classes}"` : '';
if (lastDue && lastMatch) {
const status = isChecked ? 'normal' : getDueStatus(lastDue.date);
const badgeClass = getDueBadgeClass(status, isChecked);
const label = formatDueLabel(lastDue.date, status);
const badge = `<span class="${badgeClass}">${label}</span>`;
const newRest = rest.slice(0, lastMatch.index) + badge + rest.slice(lastMatch.index + lastMatch.length);
const liClass = isChecked ? 'memo-task-done' : '';
return `<li class="${liClass}">${inputTag}${newRest}</li>`;
return `<li${classAttr}>${inputTag}${newRest}</li>`;
}
const liClass = isChecked ? 'memo-task-done' : '';
return `<li class="${liClass}">${inputTag}${rest}</li>`;
return `<li${classAttr}>${inputTag}${rest}</li>`;
});
// 5. DOMPurify sanitize
@@ -99,10 +128,56 @@ export function renderMemoHtml(markdown: string, options: RenderOptions = {}): s
FORBID_ATTR: ['onerror', 'onclick'],
ALLOW_UNKNOWN_PROTOCOLS: false,
ADD_TAGS: ['input'],
ADD_ATTR: ['type', 'checked', 'data-task-index', 'class'],
ADD_ATTR: ['type', 'checked', 'data-task-index', 'data-checked-at', 'class'],
});
}
/**
* 체크 후 hideAfterMs 이내에 다시 가려져야 할 항목의 남은 시간(ms) 중 최소값을 반환.
* 다음 tick 타이머를 정확히 예측하는 데 사용. 가려질 항목이 없으면 null.
*/
export function nextHideInMs(
taskStates: Record<string, { checked_at?: string }> | undefined | null,
now: Date,
hideAfterMs = DEFAULT_HIDE_AFTER_MS,
): number | null {
if (!taskStates) return null;
const nowMs = now.getTime();
let soonest: number | null = null;
for (const key of Object.keys(taskStates)) {
const at = taskStates[key]?.checked_at;
if (!at) continue;
const atMs = Date.parse(at);
if (Number.isNaN(atMs)) continue;
const remain = atMs + hideAfterMs - nowMs;
if (remain > 0 && (soonest === null || remain < soonest)) {
soonest = remain;
}
}
return soonest;
}
/**
* 메모에 표시할 "숨긴 체크 항목 개수". 토글 버튼 라벨용.
*/
export function countHiddenTasks(
taskStates: Record<string, { checked_at?: string }> | undefined | null,
now: Date,
hideAfterMs = DEFAULT_HIDE_AFTER_MS,
): number {
if (!taskStates) return 0;
const nowMs = now.getTime();
let n = 0;
for (const key of Object.keys(taskStates)) {
const at = taskStates[key]?.checked_at;
if (!at) continue;
const atMs = Date.parse(at);
if (Number.isNaN(atMs)) continue;
if (nowMs - atMs >= hideAfterMs) n++;
}
return n;
}
/**
* n번째 task item의 checkbox 토글. 일반 줄은 카운트하지 않음.
*/
+48 -13
View File
@@ -18,7 +18,7 @@
Inbox, Scale, FileText, Activity, StickyNote, Newspaper, Pin, ChevronRight, Pencil,
Library, Mic, Video, Sparkles,
} from 'lucide-svelte';
import { renderMemoHtml, toggleTaskLine } from '$lib/utils/memoRenderer';
import { renderMemoHtml, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
import { addToast } from '$lib/stores/toast';
let summary = $derived<DashboardSummary | null>($dashboardSummary);
@@ -26,6 +26,16 @@
// ─── 핀 고정 메모 ───
let pinnedMemos = $state<any[]>([]);
// 메모별 "완료 항목 펼침" 토글 — key: memo.id, value: true 면 숨겨진 체크 항목 노출
let showHiddenByMemo = $state<Record<number, boolean>>({});
// 자동 숨김 tick. 1초 해상도로 충분 (hideAfter 10초라 오차 수용).
let nowTick = $state(new Date());
$effect(() => {
const id = setInterval(() => { nowTick = new Date(); }, 1000);
return () => clearInterval(id);
});
onMount(async () => {
try {
const res = await api<any>('/memos/?pinned=true&page_size=3&archived=false');
@@ -39,26 +49,27 @@
if (target.tagName !== 'INPUT' || (target as HTMLInputElement).type !== 'checkbox') return;
e.preventDefault();
e.stopPropagation(); // details 토글 충돌 방지
const taskIndex = parseInt((target as HTMLElement).dataset.taskIndex || '', 10);
const input = target as HTMLInputElement;
const taskIndex = parseInt(input.dataset.taskIndex || '', 10);
if (isNaN(taskIndex)) return;
const oldContent = memo.content;
const newContent = toggleTaskLine(oldContent, taskIndex);
memo.content = newContent;
pinnedMemos = [...pinnedMemos];
const checked = input.checked;
try {
await api(`/memos/${memo.id}`, {
const updated = await api<any>(`/memos/${memo.id}/tasks/${taskIndex}`, {
method: 'PATCH',
body: JSON.stringify({ content: newContent }),
body: JSON.stringify({ checked }),
});
pinnedMemos = pinnedMemos.map((m) => (m.id === memo.id ? updated : m));
} catch {
memo.content = oldContent;
pinnedMemos = [...pinnedMemos];
input.checked = !checked; // 롤백
addToast('error', '체크박스 변경 실패');
}
}
function toggleShowHidden(memoId: number) {
showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] };
}
// ─── 파이프라인 ───
const STAGE_ORDER = ['extract', 'stt', 'classify', 'embed', 'preview', 'thumbnail'] as const;
const STAGE_LABEL: Record<string, string> = {
@@ -204,10 +215,31 @@
class="mt-1 px-3 py-2.5 bg-surface/50 border border-default/30 rounded-lg text-sm text-text"
onclick={(e) => handlePinCheckbox(e, memo)}
>
<div class="prose prose-sm max-w-none memo-content-pin">
{@html renderMemoHtml(memo.content || '', { compact: true, interactive: true })}
<div
class="prose prose-sm max-w-none memo-content-pin"
class:show-hidden={showHiddenByMemo[memo.id]}
>
{@html renderMemoHtml(memo.content || '', {
compact: true,
interactive: true,
taskStates: memo.memo_task_state ?? {},
now: nowTick,
})}
</div>
<div class="flex items-center gap-3 mt-2">
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]}
<button
type="button"
class="text-[11px] text-dim hover:text-text underline-offset-2 hover:underline"
onclick={(e) => { e.stopPropagation(); toggleShowHidden(memo.id); }}
>
{#if showHiddenByMemo[memo.id]}
완료 항목 숨기기
{:else}
완료 {countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS)}개 보기
{/if}
</button>
{/if}
<a href="/memos" class="text-[11px] text-accent hover:underline">메모함에서 보기 →</a>
</div>
</div>
@@ -508,6 +540,9 @@
.memo-content-pin :global(.memo-checkbox) { cursor: pointer; width: 14px; height: 14px; accent-color: var(--accent); vertical-align: middle; margin-right: 3px; }
.memo-content-pin :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; }
.memo-content-pin :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; }
/* 체크 후 10초 경과 항목 자동 숨김 (`show-hidden` 클래스로 토글 해제) */
.memo-content-pin :global(.memo-task-hidden) { display: none; }
.memo-content-pin.show-hidden :global(.memo-task-hidden) { display: list-item; }
.memo-content-pin :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 5px; border-radius: 8px; margin-left: 3px; }
.memo-content-pin :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); }
.memo-content-pin :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
+39 -11
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { addToast } from '$lib/stores/toast';
import { renderMemoHtml, toggleTaskLine, todayIso } from '$lib/utils/memoRenderer';
import { renderMemoHtml, todayIso, countHiddenTasks, DEFAULT_HIDE_AFTER_MS } from '$lib/utils/memoRenderer';
import { Pin, PinOff, Pencil, Trash2, Eye, EyeOff, X, Check, Archive, ArchiveRestore, ListChecks, Bold, Heading, CalendarDays } from 'lucide-svelte';
import Button from '$lib/components/ui/Button.svelte';
import Card from '$lib/components/ui/Card.svelte';
@@ -31,6 +31,19 @@
let activeTag = $state(null);
let showArchived = $state(false);
// 자동 숨김 tick (1초 해상도) + 메모별 "숨겨진 항목 펼침" 상태
let nowTick = $state(new Date());
let showHiddenByMemo = $state({});
$effect(() => {
const id = setInterval(() => { nowTick = new Date(); }, 1000);
return () => clearInterval(id);
});
function toggleShowHidden(memoId) {
showHiddenByMemo = { ...showHiddenByMemo, [memoId]: !showHiddenByMemo[memoId] };
}
onMount(() => {
const params = new URLSearchParams(window.location.search);
activeTag = params.get('tag');
@@ -243,20 +256,15 @@
const taskIndex = parseInt(target.dataset.taskIndex, 10);
if (isNaN(taskIndex)) return;
const oldContent = memo.content;
const newCont = toggleTaskLine(oldContent, taskIndex);
memo.content = newCont;
memos = [...memos];
const checked = target.checked;
try {
const updated = await api(`/memos/${memo.id}`, {
const updated = await api(`/memos/${memo.id}/tasks/${taskIndex}`, {
method: 'PATCH',
body: JSON.stringify({ content: newCont }),
body: JSON.stringify({ checked }),
});
memos = memos.map((m) => (m.id === memo.id ? updated : m));
} catch (err) {
memo.content = oldContent;
memos = [...memos];
target.checked = !checked; // 롤백
addToast('error', '체크박스 변경 실패');
}
}
@@ -472,10 +480,27 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="prose prose-sm text-text max-w-none memo-content"
class:show-hidden={showHiddenByMemo[memo.id]}
onclick={(e) => handleCheckboxClick(e, memo)}
>
{@html renderMemoHtml(memo.content || '')}
{@html renderMemoHtml(memo.content || '', {
taskStates: memo.memo_task_state ?? {},
now: nowTick,
})}
</div>
{#if countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS) > 0 || showHiddenByMemo[memo.id]}
<button
type="button"
class="text-[11px] text-dim hover:text-text underline-offset-2 hover:underline mt-1"
onclick={() => toggleShowHidden(memo.id)}
>
{#if showHiddenByMemo[memo.id]}
완료 항목 숨기기
{:else}
완료 {countHiddenTasks(memo.memo_task_state, nowTick, DEFAULT_HIDE_AFTER_MS)}개 보기
{/if}
</button>
{/if}
{/if}
<!-- 태그 + 하단 -->
@@ -550,6 +575,9 @@
}
.memo-content :global(li:has(.memo-checkbox)) { list-style: none; margin-left: -1.5em; }
.memo-content :global(.memo-task-done) { opacity: 0.5; text-decoration: line-through; }
/* 체크 후 10초 경과 항목 자동 숨김 (.show-hidden 클래스로 해제) */
.memo-content :global(.memo-task-hidden) { display: none; }
.memo-content.show-hidden :global(.memo-task-hidden) { display: list-item; }
.memo-content :global(.due-badge) { display: inline-block; font-size: 10px; padding: 1px 6px; border-radius: 8px; margin-left: 4px; vertical-align: middle; }
.memo-content :global(.due-overdue) { background: rgba(245, 86, 78, 0.15); color: var(--error); }
.memo-content :global(.due-soon) { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
+3
View File
@@ -0,0 +1,3 @@
-- 156: 메모 체크박스 개별 항목의 체크 시각 저장 (자동 숨김용)
-- 구조: {"<task_index>": {"checked_at": "<ISO8601 UTC>"}}
ALTER TABLE documents ADD COLUMN IF NOT EXISTS memo_task_state JSONB NOT NULL DEFAULT '{}'::jsonb