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:
@@ -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 토글. 일반 줄은 카운트하지 않음.
|
||||
*/
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user