- i1 (Important): RecallBanner shownIds → useRef (state setState 트리거 race 차단)
store 의 recallShownIds 필드 제거 (dead — useRef 가 대체)
- m1 (Minor): snoozeRecall candidate-null race 코멘트 (의도적 emit skip 명시)
- m2 (Minor): dismissRecallNote 후 recallSnoozeUntilMs = null clear
- m3 (Minor): CaptureService.markRecallOpened 의 dead local 'before' inline check 로 제거
- m4 (Minor): RecallBanner title 빈 케이스 fallback '(제목 없음)'
- n2 (Nit): NoteCard id load-bearing 의미 1줄 코멘트
skip: n1 (KST 4번째 inline duplicate — 프로젝트 전반 패턴, v0.2.4 nextKstMidnightMs 통합),
n3 (ipcMain.on vs handle — 다른 IPC 와 패턴 일관)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
229 lines
8.5 KiB
TypeScript
229 lines
8.5 KiB
TypeScript
import { create } from 'zustand';
|
|
import type { Note, WeeklyContinuity } from '@shared/types';
|
|
import { inboxApi } from './api.js';
|
|
|
|
export { selectFilteredNotes } from './selectFilteredNotes.js';
|
|
|
|
interface InboxState {
|
|
notes: Note[];
|
|
trashNotes: Note[];
|
|
trashCount: number;
|
|
showTrash: boolean;
|
|
continuity: WeeklyContinuity;
|
|
pendingCount: number;
|
|
ollamaStatus: { ok: boolean; reason?: string };
|
|
todayCount: number;
|
|
loading: boolean;
|
|
tagFilter: string | null;
|
|
expiredCandidates: Note[];
|
|
expiredSnoozeUntilMs: number | null;
|
|
failedCount: number;
|
|
recallCandidate: Note | null;
|
|
recallSnoozeUntilMs: number | null;
|
|
loadInitial: () => Promise<void>;
|
|
refreshMeta: () => Promise<void>;
|
|
upsertNote: (note: Note) => void;
|
|
removeNote: (id: string) => void;
|
|
setTagFilter: (tag: string | null) => void;
|
|
toggleShowTrash: () => Promise<void>;
|
|
loadTrash: () => Promise<void>;
|
|
restoreNote: (id: string) => Promise<void>;
|
|
permanentDeleteNote: (id: string) => Promise<void>;
|
|
emptyTrash: () => Promise<void>;
|
|
loadExpired: () => Promise<void>;
|
|
trashExpiredBatch: (ids: string[]) => Promise<void>;
|
|
snoozeExpired: () => void;
|
|
recheckOllama: () => Promise<void>;
|
|
retryAllFailed: () => Promise<void>;
|
|
loadRecallCandidate: () => Promise<void>;
|
|
openRecall: (id: string) => Promise<void>;
|
|
dismissRecallNote: (id: string) => Promise<void>;
|
|
snoozeRecall: () => Promise<void>;
|
|
}
|
|
|
|
const emptyContinuity: WeeklyContinuity = {
|
|
weekStart: '', weekCount: 0, weekTarget: 7,
|
|
consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null
|
|
};
|
|
|
|
export const useInbox = create<InboxState>((set, get) => ({
|
|
notes: [],
|
|
trashNotes: [],
|
|
trashCount: 0,
|
|
showTrash: false,
|
|
continuity: emptyContinuity,
|
|
pendingCount: 0,
|
|
ollamaStatus: { ok: true },
|
|
todayCount: 0,
|
|
loading: false,
|
|
tagFilter: null,
|
|
expiredCandidates: [],
|
|
expiredSnoozeUntilMs: null,
|
|
failedCount: 0,
|
|
recallCandidate: null,
|
|
recallSnoozeUntilMs: null,
|
|
async loadInitial() {
|
|
set({ loading: true });
|
|
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
|
inboxApi.listNotes({ limit: 50 }),
|
|
inboxApi.getContinuity(),
|
|
inboxApi.getPendingCount(),
|
|
inboxApi.getOllamaStatus(),
|
|
inboxApi.getTodayCount(),
|
|
inboxApi.getTrashCount(),
|
|
inboxApi.listExpired(),
|
|
inboxApi.getFailedCount(),
|
|
inboxApi.listRecallCandidate()
|
|
]);
|
|
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
|
|
},
|
|
async refreshMeta() {
|
|
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
|
inboxApi.getContinuity(),
|
|
inboxApi.getPendingCount(),
|
|
inboxApi.getOllamaStatus(),
|
|
inboxApi.getTodayCount(),
|
|
inboxApi.getTrashCount(),
|
|
inboxApi.listExpired(),
|
|
inboxApi.getFailedCount(),
|
|
inboxApi.listRecallCandidate()
|
|
]);
|
|
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
|
|
},
|
|
upsertNote(note) {
|
|
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
|
|
// 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존.
|
|
const showTrash = get().showTrash;
|
|
if (note.deletedAt !== null) {
|
|
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
|
|
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
|
|
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
|
|
const nextTrash = get().trashNotes.slice();
|
|
if (ti >= 0) nextTrash[ti] = note;
|
|
else nextTrash.unshift(note);
|
|
set({
|
|
notes: cleanNotes,
|
|
trashNotes: nextTrash,
|
|
...(showTrash ? { trashCount: nextTrash.length } : {})
|
|
});
|
|
} else {
|
|
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
|
|
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
|
|
const i = get().notes.findIndex((n) => n.id === note.id);
|
|
const nextNotes = get().notes.slice();
|
|
if (i >= 0) nextNotes[i] = note;
|
|
else nextNotes.unshift(note);
|
|
set({
|
|
notes: nextNotes,
|
|
trashNotes: cleanTrash,
|
|
...(showTrash ? { trashCount: cleanTrash.length } : {})
|
|
});
|
|
}
|
|
},
|
|
removeNote(id) {
|
|
const cleanNotes = get().notes.filter((n) => n.id !== id);
|
|
const cleanTrash = get().trashNotes.filter((n) => n.id !== id);
|
|
const showTrash = get().showTrash;
|
|
set({
|
|
notes: cleanNotes,
|
|
trashNotes: cleanTrash,
|
|
...(showTrash ? { trashCount: cleanTrash.length } : {})
|
|
});
|
|
},
|
|
setTagFilter(tag) {
|
|
set({ tagFilter: tag });
|
|
},
|
|
async toggleShowTrash() {
|
|
const next = !get().showTrash;
|
|
set({ showTrash: next });
|
|
if (next) await get().loadTrash();
|
|
},
|
|
async loadTrash() {
|
|
const trashNotes = await inboxApi.listTrash({ limit: 200 });
|
|
set({ trashNotes, trashCount: trashNotes.length });
|
|
},
|
|
async restoreNote(id) {
|
|
await inboxApi.restoreNote(id);
|
|
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
|
|
// (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
|
|
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
|
|
const note = get().trashNotes.find((n) => n.id === id);
|
|
if (note) {
|
|
get().upsertNote({ ...note, deletedAt: null });
|
|
}
|
|
},
|
|
async permanentDeleteNote(id) {
|
|
const r = await inboxApi.permanentDeleteNote(id);
|
|
if (r.confirmed) get().removeNote(id);
|
|
},
|
|
async emptyTrash() {
|
|
const r = await inboxApi.emptyTrash();
|
|
if (r.confirmed) {
|
|
set({ trashNotes: [], trashCount: 0 });
|
|
}
|
|
},
|
|
async loadExpired() {
|
|
const expiredCandidates = await inboxApi.listExpired();
|
|
set({ expiredCandidates });
|
|
},
|
|
async trashExpiredBatch(ids: string[]) {
|
|
const r = await inboxApi.trashExpiredBatch(ids);
|
|
if (!r.confirmed) return;
|
|
const idSet = new Set(ids);
|
|
set({
|
|
expiredCandidates: get().expiredCandidates.filter((n) => !idSet.has(n.id)),
|
|
notes: get().notes.filter((n) => !idSet.has(n.id)),
|
|
trashCount: get().trashCount + r.trashedCount
|
|
});
|
|
},
|
|
snoozeExpired() {
|
|
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
const now = Date.now();
|
|
const kstNow = now + KST_OFFSET_MS;
|
|
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
|
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
|
set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
|
},
|
|
async recheckOllama() {
|
|
const status = await inboxApi.ollamaRecheck();
|
|
set({ ollamaStatus: status });
|
|
},
|
|
async retryAllFailed() {
|
|
await inboxApi.retryAllFailed();
|
|
// 낙관적 갱신: failedCount = 0. AiWorker 처리 진행 중에 PendingBanner 가 N건 노출.
|
|
// refreshMeta 가 트리거되면 자연 동기 (worker.onUpdate → main → renderer).
|
|
// 반환된 r.count 는 의도적으로 무시 — 단일 process 환경 (Electron) 이라 race 무관,
|
|
// 모든 ai_status='failed' 가 retry 대상이므로 사용자 시점 카운트는 0 으로 reset 가 정확.
|
|
set({ failedCount: 0 });
|
|
},
|
|
async loadRecallCandidate() {
|
|
const recallCandidate = await inboxApi.listRecallCandidate();
|
|
set({ recallCandidate });
|
|
},
|
|
async openRecall(id) {
|
|
await inboxApi.markRecallOpened(id);
|
|
const recallCandidate = await inboxApi.listRecallCandidate();
|
|
set({ recallCandidate });
|
|
},
|
|
async dismissRecallNote(id) {
|
|
await inboxApi.dismissRecall(id);
|
|
const recallCandidate = await inboxApi.listRecallCandidate();
|
|
// m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear
|
|
set({ recallCandidate, recallSnoozeUntilMs: null });
|
|
},
|
|
async snoozeRecall() {
|
|
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
const now = Date.now();
|
|
const kstNow = now + KST_OFFSET_MS;
|
|
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
|
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
|
set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
|
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
|
|
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
|
|
const candidate = get().recallCandidate;
|
|
if (candidate) {
|
|
await inboxApi.emitRecallSnoozed(candidate.id);
|
|
}
|
|
}
|
|
}));
|