feat(expiry): inbox 만 대상 + 오늘 당일 포함 + 헤딩/라벨/메모 바로가기

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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-14 13:11:58 +09:00
parent 352457189e
commit 3c731cc754
4 changed files with 108 additions and 41 deletions

View File

@@ -592,6 +592,7 @@ describe('NoteRepository.findExpiredCandidates', () => {
edited?: boolean;
deletedAt?: string | null;
aiStatus?: 'pending' | 'done' | 'failed';
status?: 'active' | 'completed' | 'archived' | 'trashed';
}): string {
const { id } = repo.create({ rawText: opts.rawText });
db.prepare(
@@ -599,19 +600,21 @@ describe('NoteRepository.findExpiredCandidates', () => {
SET due_date = ?,
due_date_edited_by_user = ?,
ai_status = ?,
deleted_at = ?
deleted_at = ?,
status = ?
WHERE id = ?`
).run(
opts.dueDate,
opts.edited ? 1 : 0,
opts.aiStatus ?? 'done',
opts.deletedAt ?? null,
opts.status ?? 'active',
id
);
return id;
}
it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => {
it('returns notes with due_date <= today (KST), ORDER BY due_date DESC then created_at DESC', () => {
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
@@ -620,6 +623,14 @@ describe('NoteRepository.findExpiredCandidates', () => {
expect(r.map((n) => n.id)).toEqual([b, a]);
});
it('includes notes with due_date == today (오늘 당일 우선 표시)', () => {
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
const todayNote = makeDone({ rawText: 'b', dueDate: '2026-05-01' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
// 오늘 당일이 먼저, 그 다음 지난 메모.
expect(r.map((n) => n.id)).toEqual([todayNote, past]);
});
it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
@@ -629,7 +640,7 @@ describe('NoteRepository.findExpiredCandidates', () => {
it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' });
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z', status: 'trashed' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([a]);
});
@@ -649,11 +660,12 @@ describe('NoteRepository.findExpiredCandidates', () => {
expect(r.map((n) => n.id)).toEqual([dated]);
});
it('excludes notes with due_date == today (boundary, not expired)', () => {
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
makeDone({ rawText: 'b', dueDate: '2026-05-01' });
it('excludes completed / archived notes (inbox 만 — 사용자 의도: 완료/보관은 알림 제외)', () => {
const active = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
makeDone({ rawText: 'b', dueDate: '2026-04-20', status: 'completed' });
makeDone({ rawText: 'c', dueDate: '2026-04-20', status: 'archived' });
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
expect(r.map((n) => n.id)).toEqual([past]);
expect(r.map((n) => n.id)).toEqual([active]);
});
});