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:
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,23 +140,40 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
<span style={{ color: '#666' }}>전체 선택 ({selected.size}/{candidates.length})</span>
|
||||
</label>
|
||||
<div>
|
||||
{candidates.map((n) => (
|
||||
<label
|
||||
{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', cursor: 'pointer'
|
||||
padding: '4px 0'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(n.id)}
|
||||
onChange={() => toggle(n.id)}
|
||||
aria-label={`${title} 선택`}
|
||||
/>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{n.aiTitle ?? n.rawText.slice(0, 60)}
|
||||
<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>
|
||||
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
|
||||
{n.tags[0] && (
|
||||
<span style={{
|
||||
background: '#fce8b2', color: '#946100', padding: '0 6px',
|
||||
@@ -132,8 +182,9 @@ function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React
|
||||
#{n.tags[0].name}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onTrash(Array.from(selected))}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user