feat(due-date): NoteCard badge + edit + IPC

Inline 📅 YYYY-MM-DD badge appears in NoteCard between summary and
tags. Click to edit (HTML date input). Past dates: gray + line-through.
AI label shown when not user-edited (mirrors title/summary AI badge
policy). Empty state shows '📅 마감일 추가' link in gray.

New IPC inbox:setDueDate routes to NoteRepository.setDueDate which
sets due_date_edited_by_user=1 (per slice §1.1 invariant 2 — user
edit blocks future AI overwrite). Preload bridge + InboxApi type
extended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-04-26 11:17:57 +09:00
parent adae90eb61
commit 9ab70868d1
4 changed files with 112 additions and 0 deletions

View File

@@ -15,6 +15,93 @@ const aiBadgeStyle: React.CSSProperties = {
background: '#eee', color: '#666', fontSize: 10, borderRadius: 3, verticalAlign: 'middle'
};
function isPastDue(iso: string, today: string): boolean {
return iso < today; // string comparison works for ISO YYYY-MM-DD
}
function todayKstIso(): string {
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const k = new Date(Date.now() + KST_OFFSET_MS);
return k.toISOString().slice(0, 10);
}
function DueDateBadge({
value,
isEdited,
today,
onSave
}: {
value: string | null;
isEdited: boolean;
today: string;
onSave: (next: string) => Promise<void>;
}): React.ReactElement {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
React.useEffect(() => { if (!editing) setDraft(value ?? ''); }, [value, editing]);
if (!editing) {
if (value === null) {
return (
<span
onClick={() => setEditing(true)}
style={{ fontSize: 11, color: '#bbb', cursor: 'pointer' }}
title="마감일 추가"
>
📅
</span>
);
}
const past = isPastDue(value, today);
return (
<span style={{ fontSize: 11, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span
onClick={() => setEditing(true)}
style={{
color: past ? '#999' : '#666',
textDecoration: past ? 'line-through' : 'none',
cursor: 'pointer'
}}
title={past ? '지난 마감일 — 클릭으로 편집' : '클릭으로 편집'}
>
📅 {value}
</span>
{!isEdited && (
<span
style={{
fontSize: 9, padding: '0 4px', background: '#eee',
color: '#888', borderRadius: 2
}}
title="AI 추출"
>
AI
</span>
)}
</span>
);
}
return (
<input
type="date"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={async () => {
try { await onSave(draft); }
catch { /* keep editing if invalid */ return; }
setEditing(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
if (e.key === 'Escape') { setDraft(value ?? ''); setEditing(false); }
}}
autoFocus
style={{ fontSize: 11, padding: 1 }}
/>
);
}
export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
const [local, setLocal] = useState(note);
@@ -41,6 +128,17 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
setLocal(updated); onUpdated(updated);
}
async function saveDueDate(next: string) {
const value = next.trim() === '' ? null : next.trim();
// Light validation: empty or YYYY-MM-DD
if (value !== null && !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error('Invalid date');
}
await inboxApi.setDueDate(note.id, value);
const updated = { ...local, dueDate: value, dueDateEditedByUser: true };
setLocal(updated); onUpdated(updated);
}
async function removeTag(tagName: string) {
const next = local.tags.filter((t) => t.name !== tagName).map((t) => t.name);
await inboxApi.updateAiFields(note.id, { tags: next });
@@ -102,6 +200,14 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
/>
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
</div>
<div style={{ marginTop: 6 }}>
<DueDateBadge
value={local.dueDate}
isEdited={local.dueDateEditedByUser}
today={todayKstIso()}
onSave={saveDueDate}
/>
</div>
{local.tags.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{local.tags.map((t) => (