diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 181b35b..e50bc2d 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1121,9 +1121,12 @@ export class NoteRepository { } /** - * Notes whose due_date is strictly before today (KST calendar) and that are - * still active (not trashed) and AI-processed. Includes both AI-extracted and - * user-edited due_date (v0.2.3 #5 spec §1 Q1=B). + * Notes whose due_date is today (KST calendar) or already past, that are still + * active (inbox only — completed/archived/trashed 제외), and AI-processed. + * Includes both AI-extracted and user-edited due_date. + * + * 정렬: due_date DESC → 오늘 당일 먼저, 그 다음 어제, 그 전... 같은 due_date 내에선 + * created_at DESC, id DESC tiebreak. * * Caller may inject `now` for testability; defaults to `new Date()`. */ @@ -1133,10 +1136,11 @@ export class NoteRepository { .prepare( `SELECT * FROM notes WHERE due_date IS NOT NULL - AND due_date < ? + AND due_date <= ? AND deleted_at IS NULL + AND status = 'active' AND ai_status = 'done' - ORDER BY created_at DESC, id DESC` + ORDER BY due_date DESC, created_at DESC, id DESC` ) .all(today) as Record[]; return rows.map((r) => this.hydrate(r)); diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index ceca28f..052584b 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -146,9 +146,9 @@ export class CaptureService { } /** - * 만료 후보 (due_date < today KST, active, ai_status=done) 조회. + * 마감 임박 후보 (due_date ≤ today KST, status=active inbox, ai_status=done) 조회. + * 오늘 당일 마감 메모도 포함하여 사용자에게 미리 인지시킨다 (정렬은 due_date DESC). * candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit. - * v0.2.3 #5 spec §6.2 — dedup 위치 main 통합. */ async listExpired(now: Date = new Date()): Promise { const candidates = this.repo.findExpiredCandidates(now); diff --git a/src/renderer/inbox/components/ExpiryBanner.tsx b/src/renderer/inbox/components/ExpiryBanner.tsx index a08bcf9..d00a3a0 100644 --- a/src/renderer/inbox/components/ExpiryBanner.tsx +++ b/src/renderer/inbox/components/ExpiryBanner.tsx @@ -2,6 +2,35 @@ import React, { useEffect, useState } from 'react'; import type { Note } from '@shared/types'; import { useInbox } from '../store.js'; import { Banner } from './Banner.js'; +import { DAY_MS, kstTodayIso } from '@shared/util/kstDate.js'; + +/** + * due_date 대비 오늘 (KST) 의 상대 라벨. 오늘 = "오늘", 지난 = "N일 지남". + * findExpiredCandidates 가 미래 due 는 반환하지 않으므로 음수 케이스 미고려. + */ +function dueRelativeLabel(due: string, todayKst: string): string { + if (due === todayKst) return '오늘'; + const dueUtc = Date.UTC( + Number(due.slice(0, 4)), Number(due.slice(5, 7)) - 1, Number(due.slice(8, 10)) + ); + const todayUtc = Date.UTC( + Number(todayKst.slice(0, 4)), Number(todayKst.slice(5, 7)) - 1, Number(todayKst.slice(8, 10)) + ); + const days = Math.round((todayUtc - dueUtc) / DAY_MS); + return `${days}일 지남`; +} + +function headingText(todayCount: number, overdueCount: number): string { + if (todayCount > 0 && overdueCount > 0) return `오늘 마감 ${todayCount} · 지난 ${overdueCount}`; + if (todayCount > 0) return `오늘 마감 ${todayCount}개`; + return `지난 ${overdueCount}개`; +} + +/** RecallBanner 와 동일 패턴 — NoteCard 의 `note-{id}` element 로 smooth scroll. */ +function scrollToNote(id: string): void { + const el = document.getElementById(`note-${id}`); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); +} export function ExpiryBanner(): React.ReactElement | null { const candidates = useInbox((s) => s.expiredCandidates); @@ -58,6 +87,10 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id)); const someSelected = selected.size > 0 && !allSelected; + const todayKst = kstTodayIso(); + const todayCount = candidates.filter((c) => c.dueDate === todayKst).length; + const overdueCount = candidates.length - todayCount; + function toggleAll() { if (allSelected) setSelected(new Set()); else setSelected(new Set(candidates.map((c) => c.id))); @@ -75,7 +108,7 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React return (
- 오늘 기준 만료 {candidates.length}개 + {headingText(todayCount, overdueCount)} + + {dueRelativeLabel(n.dueDate ?? todayKst, todayKst)} - )} - - ))} + {n.tags[0] && ( + + #{n.tags[0].name} + + )} +
+ ); + })}