Files
inkling/src/renderer/inbox/store.ts

188 lines
6.6 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;
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>;
}
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,
async loadInitial() {
set({ loading: true });
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([
inboxApi.listNotes({ limit: 50 }),
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount()
]);
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, loading: false });
},
async refreshMeta() {
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([
inboxApi.getContinuity(),
inboxApi.getPendingCount(),
inboxApi.getOllamaStatus(),
inboxApi.getTodayCount(),
inboxApi.getTrashCount(),
inboxApi.listExpired(),
inboxApi.getFailedCount()
]);
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount });
},
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).
set({ failedCount: 0 });
}
}));