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:
@@ -29,6 +29,10 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:setDueDate', (_e, arg: { noteId: string; date: string | null }) => {
|
||||
deps.repo.setDueDate(arg.noteId, arg.date);
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:delete', async (_e, noteId: string) => {
|
||||
await deps.capture.deleteNote(noteId);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const api: InklingApi = {
|
||||
listNotes: (opts) => ipcRenderer.invoke('inbox:list', opts),
|
||||
updateAiFields: (noteId, fields) =>
|
||||
ipcRenderer.invoke('inbox:updateAi', { noteId, fields }),
|
||||
setDueDate: (noteId, date) => ipcRenderer.invoke('inbox:setDueDate', { noteId, date }),
|
||||
deleteNote: (noteId) => ipcRenderer.invoke('inbox:delete', noteId),
|
||||
setIntent: (noteId, text) => ipcRenderer.invoke('inbox:setIntent', { noteId, text }),
|
||||
dismissIntent: (noteId) => ipcRenderer.invoke('inbox:dismissIntent', noteId),
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface InboxApi {
|
||||
noteId: string,
|
||||
fields: { title?: string; summary?: string; tags?: string[] }
|
||||
): Promise<void>;
|
||||
setDueDate(noteId: string, date: string | null): Promise<void>;
|
||||
deleteNote(noteId: string): Promise<void>;
|
||||
setIntent(noteId: string, text: string): Promise<void>;
|
||||
dismissIntent(noteId: string): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user