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

@@ -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<string, unknown>[];
return rows.map((r) => this.hydrate(r));

View File

@@ -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<Note[]> {
const candidates = this.repo.findExpiredCandidates(now);

View File

@@ -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 (
<Banner severity="warning">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span> <b> {candidates.length}</b></span>
<span> <b>{headingText(todayCount, overdueCount)}</b></span>
<button
onClick={() => setExpanded((e) => !e)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
@@ -107,33 +140,51 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
<span style={{ color: '#666' }}> ({selected.size}/{candidates.length})</span>
</label>
<div>
{candidates.map((n) => (
<label
key={n.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 0', cursor: 'pointer'
}}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
/>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{n.aiTitle ?? n.rawText.slice(0, 60)}
</span>
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
{n.tags[0] && (
<span style={{
background: '#fce8b2', color: '#946100', padding: '0 6px',
borderRadius: 10, fontSize: 11
}}>
#{n.tags[0].name}
{candidates.map((n) => {
const title = n.aiTitle ?? n.rawText.slice(0, 60);
return (
<div
key={n.id}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '4px 0'
}}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
aria-label={`${title} 선택`}
/>
<button
type="button"
onClick={() => scrollToNote(n.id)}
title="해당 메모로 이동"
style={{
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
background: 'none', border: 'none', padding: 0,
cursor: 'pointer', color: 'inherit', font: 'inherit', textAlign: 'left'
}}
>
{title}
</button>
<span
style={{ color: '#946100', fontSize: 12 }}
title={`due ${n.dueDate}`}
>
{dueRelativeLabel(n.dueDate ?? todayKst, todayKst)}
</span>
)}
</label>
))}
{n.tags[0] && (
<span style={{
background: '#fce8b2', color: '#946100', padding: '0 6px',
borderRadius: 10, fontSize: 11
}}>
#{n.tags[0].name}
</span>
)}
</div>
);
})}
</div>
<button
onClick={() => onTrash(Array.from(selected))}

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]);
});
});