From 3c731cc75423dea8fe0cb928df2ec11b74f22126 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 14 May 2026 13:11:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(expiry):=20inbox=20=EB=A7=8C=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20+=20=EC=98=A4=EB=8A=98=20=EB=8B=B9=EC=9D=BC=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20+=20=ED=97=A4=EB=94=A9/=EB=9D=BC=EB=B2=A8/?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=20=EB=B0=94=EB=A1=9C=EA=B0=80=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dogfood: 마감 알림이 (1) 완료/보관 status 노트도 포함하고 (2) 오늘 당일 마감 메모는 빠져 있어 사용자 불편. NoteRepository.findExpiredCandidates 변경: - due_date < today → <=today (오늘 당일 포함) - status='active' 필터 추가 (inbox 만, completed/archived/trashed 제외) - ORDER BY due_date DESC → 오늘 → 어제 → 그저께 순 ExpiryBanner UX: - 헤딩 분리 카운트 "오늘 마감 X · 지난 Y" (한 쪽만이면 단독 표시) - 노트 옆 due_date → 상대 라벨 ([오늘] / [N일 지남]) + hover tooltip 으로 원본 ISO 날짜 노출 - 노트 제목 클릭 → note-{id} 로 smooth scroll (RecallBanner 와 동일 패턴). checkbox 와 분리하기 위해 label → div + button 으로 구조 변경. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/repository/NoteRepository.ts | 14 ++- src/main/services/CaptureService.ts | 4 +- .../inbox/components/ExpiryBanner.tsx | 105 +++++++++++++----- tests/unit/NoteRepository.test.ts | 26 +++-- 4 files changed, 108 insertions(+), 41 deletions(-) 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} + + )} +
+ ); + })}