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
+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); }