chore(release): v0.3.8 — UX hole 일괄 hotfix 8건
전수 audit 후 핵심 root fix 3 + edge cases 5: ROOT - inbox:set-status IPC 가 pushNoteUpdated emit (이전엔 stale → 호출처별 refreshMeta 필요) - upsertNote 가 current view status 인식 (이전엔 잘못된 status 노트 잔류) - store async 함수 try/catch (이전엔 IPC fail 시 무한 loading) EDGE - restoreNote 가 status='active' 도 갱신 - upsertNote trash 판정 deletedAt → status='trashed' - Modal Escape dismiss 통일 (5개 modal) - OnboardingWizard IPC fail fallback (try/catch + skip) - MoveStatusModal overlay 클릭 close Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,9 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
// v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함).
|
||||
// Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는
|
||||
// NoteRepository.setStatus 내부에서 처리 (deleted_at sync).
|
||||
// v0.3.8 — setStatus 도 pushNoteUpdated emit. 이전엔 emit 안 해서 renderer 의 store
|
||||
// (counts/search/list) 가 stale 되어 NoteCard 호출처마다 명시적 refreshMeta 호출이
|
||||
// 필요했음. push 화 시 onNoteUpdated 콜백 1개 path 로 일관 갱신.
|
||||
ipcMain.handle(
|
||||
'inbox:set-status',
|
||||
async (_e, id: string, status: NoteStatus, reason: string | null) => {
|
||||
@@ -206,6 +209,8 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
return { ok: false as const, reason: 'invalid status' as const };
|
||||
}
|
||||
deps.repo.setStatus(id, status, reason);
|
||||
const updated = deps.repo.findById(id);
|
||||
if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated);
|
||||
return { ok: true as const };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -38,6 +38,15 @@ export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Escape key 로 닫기.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function onChoose(path: string, choice: 'local' | 'remote') {
|
||||
setBusy(path);
|
||||
setError(null);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
import type { NoteStatus } from '@shared/types';
|
||||
|
||||
@@ -34,6 +34,15 @@ export function MoveStatusModal({
|
||||
} | null>(null);
|
||||
const [classifying, setClassifying] = useState(false);
|
||||
|
||||
// Escape key 로 modal 닫기. mount 동안만 listener 활성.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function move(status: NoteStatus): Promise<void> {
|
||||
const trimmedReason = reason.trim() === '' ? null : reason.trim();
|
||||
await inboxApi.setStatus(noteId, status, trimmedReason);
|
||||
@@ -55,6 +64,7 @@ export function MoveStatusModal({
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="이동"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
@@ -69,6 +79,7 @@ export function MoveStatusModal({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: 16,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
/**
|
||||
@@ -10,12 +10,42 @@ import { inboxApi } from '../api.js';
|
||||
* 가 SettingsPage 에서 추후 선택 가능.
|
||||
*/
|
||||
export function OnboardingWizard({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function choose(aiEnabled: boolean | null): Promise<void> {
|
||||
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
|
||||
await inboxApi.setOnboardingCompleted(true);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (aiEnabled !== null) await inboxApi.setAiEnabled(aiEnabled);
|
||||
await inboxApi.setOnboardingCompleted(true);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// IPC 실패 (예: settings 저장 throw) 시 modal stuck 방지. 사용자에게 메시지 표시 +
|
||||
// "지금 건너뛰기" 로 fallback 길 제공. choose() 가 throw 하지 않고 무한 wizard 잠금
|
||||
// 회피.
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function skip(): void {
|
||||
// IPC 자체가 실패하는 상태 → ai_enabled 변경/onboarding flag 저장 모두 포기하고 wizard 만 닫기.
|
||||
// 다음 launch 에 다시 wizard 가 뜸 (onboarding_completed=false 상태). 그래도 사용자가
|
||||
// 진입 자체는 가능.
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Escape key 로 wizard 종료 (skip 동일 — onboarding flag 미저장).
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') skip();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label="시작 안내" style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
@@ -32,10 +62,16 @@ export function OnboardingWizard({ onClose }: { onClose: () => void }): React.Re
|
||||
<a href="https://ollama.com/download" target="_blank" rel="noopener noreferrer">ollama.com/download</a>
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button onClick={() => choose(true)}>AI 자동 처리 사용 (Ollama 필요)</button>
|
||||
<button onClick={() => choose(false)}>원문만 저장 (AI 처리 안 함)</button>
|
||||
<button onClick={() => choose(null)} style={{ marginTop: 4 }}>나중에 설정</button>
|
||||
<button onClick={() => choose(true)} disabled={busy}>AI 자동 처리 사용 (Ollama 필요)</button>
|
||||
<button onClick={() => choose(false)} disabled={busy}>원문만 저장 (AI 처리 안 함)</button>
|
||||
<button onClick={() => choose(null)} disabled={busy} style={{ marginTop: 4 }}>나중에 설정</button>
|
||||
</div>
|
||||
{error !== null && (
|
||||
<div style={{ marginTop: 12, padding: 8, background: '#fdecea', color: '#a3261c', fontSize: 12, borderRadius: 4 }}>
|
||||
<div>설정 저장 실패: {error}</div>
|
||||
<button onClick={skip} style={{ marginTop: 8 }}>지금 건너뛰기</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,15 @@ export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): Re
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Escape key 로 닫기.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
|
||||
@@ -36,6 +36,15 @@ export function SyncHelpModal({ onClose, initialAnchor }: Props): React.ReactEle
|
||||
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
}, [initialAnchor]);
|
||||
|
||||
// Escape key 로 닫기.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div ref={bodyRef} style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -102,68 +102,117 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
searchResults: null,
|
||||
reviewData: null,
|
||||
async loadInitial() {
|
||||
// v0.3.8 — IPC 실패 시 loading=true 영구 stuck 방지. catch 로 reset.
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
|
||||
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
|
||||
inboxApi.listByStatus('active', { limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
|
||||
try {
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
// inbox 탭은 status='active' 만 표시 — loadByView('inbox') 와 동일 path 로 일관성 확보.
|
||||
// listNotes 는 deleted_at IS NULL 만 필터 (= active+completed+archived 혼재) 이라 부정확.
|
||||
inboxApi.listByStatus('active', { limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false });
|
||||
} catch (e) {
|
||||
// 첫 launch 의 IPC 실패 (DB migration 실패 / main process 비정상) 시 무한 loading 회피.
|
||||
// 빈 데이터로 진입하면 사용자가 캡처 시도 → 실제 fail 이 표면화 → 재시도 가능.
|
||||
console.error('[inbox] loadInitial failed', e);
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
|
||||
try {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate(),
|
||||
inboxApi.countsByStatus(),
|
||||
inboxApi.getSettings()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true });
|
||||
} catch (e) {
|
||||
// refreshMeta 는 background poll/event 에서 자주 호출 → fail 무시 (다음 호출에 회복).
|
||||
console.error('[inbox] refreshMeta failed', e);
|
||||
}
|
||||
},
|
||||
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;
|
||||
// v0.3.8 — status 가 current view 와 매칭될 때만 notes 에 유지. 그 외엔 제거.
|
||||
// 이전 구현은 trashed 외 모든 status 를 notes 에 누적 → 사용자가 inbox view 에서
|
||||
// 완료/보관 으로 옮긴 노트가 list 에 잔류하는 버그. push-based (setStatus 도 emit) 로
|
||||
// 모든 status 전이가 upsertNote 를 거치므로 view-aware filter 가 필수.
|
||||
//
|
||||
// trashCount/trashNotes 는 server-authoritative. trashNotes 가 cache-loaded
|
||||
// (view='trash') 일 때만 trashCount 를 local recompute. 그 외엔 server 값
|
||||
// (refreshMeta) 보존. searchResults 도 별도로 갱신 (status 변경 시 list 에서 제거).
|
||||
const state = get();
|
||||
const view = state.view;
|
||||
const showTrash = state.showTrash;
|
||||
const viewStatus: 'active' | 'completed' | 'archived' | 'trashed' | null =
|
||||
view === 'inbox' ? 'active' :
|
||||
view === 'completed' ? 'completed' :
|
||||
view === 'archived' ? 'archived' :
|
||||
view === 'trash' ? 'trashed' : null;
|
||||
|
||||
// trashNotes — note.status='trashed' 면 upsert, 아니면 제거.
|
||||
const cleanTrash = state.trashNotes.filter((n) => n.id !== note.id);
|
||||
let nextTrash = cleanTrash;
|
||||
if (note.status === 'trashed') {
|
||||
const ti = state.trashNotes.findIndex((n) => n.id === note.id);
|
||||
nextTrash = cleanTrash.slice();
|
||||
if (ti >= 0) nextTrash.splice(ti, 0, 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 } : {})
|
||||
});
|
||||
}
|
||||
|
||||
// notes — current view 의 status 와 매칭되는 경우만 유지/upsert.
|
||||
// viewStatus=null (review/settings/검색) 이면 notes 직접 렌더 안 함 → 갱신 skip.
|
||||
const cleanNotes = state.notes.filter((n) => n.id !== note.id);
|
||||
let nextNotes = state.notes;
|
||||
if (viewStatus !== null) {
|
||||
if (note.status === viewStatus) {
|
||||
const i = state.notes.findIndex((n) => n.id === note.id);
|
||||
nextNotes = cleanNotes.slice();
|
||||
if (i >= 0) nextNotes.splice(i, 0, note);
|
||||
else nextNotes.unshift(note);
|
||||
} else {
|
||||
nextNotes = cleanNotes;
|
||||
}
|
||||
}
|
||||
|
||||
// searchResults — null 아니면 동일 패턴으로 갱신 (status 가 current search status 와
|
||||
// 안 맞으면 제거, 맞으면 upsert).
|
||||
let nextSearch = state.searchResults;
|
||||
if (state.searchResults !== null) {
|
||||
const cleanSearch = state.searchResults.filter((n) => n.id !== note.id);
|
||||
if (viewStatus === null || note.status === viewStatus) {
|
||||
// search 가 active 한 view 가 review/settings 면 status filter 없음 → 모두 keep.
|
||||
const i = state.searchResults.findIndex((n) => n.id === note.id);
|
||||
nextSearch = cleanSearch.slice();
|
||||
if (i >= 0) nextSearch.splice(i, 0, note);
|
||||
else nextSearch.unshift(note);
|
||||
} else {
|
||||
nextSearch = cleanSearch;
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
notes: nextNotes,
|
||||
trashNotes: nextTrash,
|
||||
searchResults: nextSearch,
|
||||
...(showTrash ? { trashCount: nextTrash.length } : {})
|
||||
});
|
||||
},
|
||||
removeNote(id) {
|
||||
const cleanNotes = get().notes.filter((n) => n.id !== id);
|
||||
@@ -204,13 +253,21 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
if (view === 'review-monthly') void get().loadReview('monthly');
|
||||
},
|
||||
async loadByView(view) {
|
||||
// v0.3.8 — IPC 실패 시 stale 한 이전 view 의 notes 가 계속 노출되는 사고 방지.
|
||||
// fail 시 빈 배열로 reset 해서 사용자에게 "비어있음" 으로 표시 (혼동 < stale).
|
||||
const status =
|
||||
view === 'trash' ? 'trashed' : view === 'inbox' ? 'active' : view;
|
||||
const notes = await inboxApi.listByStatus(status, { limit: 200 });
|
||||
if (view === 'trash') {
|
||||
set({ trashNotes: notes, trashCount: notes.length });
|
||||
} else {
|
||||
set({ notes });
|
||||
try {
|
||||
const notes = await inboxApi.listByStatus(status, { limit: 200 });
|
||||
if (view === 'trash') {
|
||||
set({ trashNotes: notes, trashCount: notes.length });
|
||||
} else {
|
||||
set({ notes });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[inbox] loadByView failed', view, e);
|
||||
if (view === 'trash') set({ trashNotes: [] });
|
||||
else set({ notes: [] });
|
||||
}
|
||||
},
|
||||
async toggleShowTrash() {
|
||||
@@ -225,11 +282,12 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
async restoreNote(id) {
|
||||
await inboxApi.restoreNote(id);
|
||||
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
|
||||
// (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
|
||||
// (현재 AiWorker.onUpdate + setStatus 만 push). 자가 반영이 primary 메커니즘.
|
||||
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
|
||||
// v0.3.8 — status 도 'active' 로 함께 갱신. upsertNote 가 status='trashed' 만 trash 로 라우팅.
|
||||
const note = get().trashNotes.find((n) => n.id === id);
|
||||
if (note) {
|
||||
get().upsertNote({ ...note, deletedAt: null });
|
||||
get().upsertNote({ ...note, deletedAt: null, status: 'active' });
|
||||
}
|
||||
},
|
||||
async permanentDeleteNote(id) {
|
||||
@@ -306,14 +364,27 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
const status = view === 'completed' || view === 'archived' || view === 'trash'
|
||||
? (view === 'trash' ? 'trashed' : view)
|
||||
: view === 'inbox' ? 'active' : undefined;
|
||||
const r = await inboxApi.search(q, status ? { status } : {});
|
||||
set({ searchResults: r });
|
||||
try {
|
||||
const r = await inboxApi.search(q, status ? { status } : {});
|
||||
set({ searchResults: r });
|
||||
} catch (e) {
|
||||
// FTS5 query parse error (special char 미escape) / IPC fail → 빈 결과로.
|
||||
console.error('[inbox] searchNotes failed', e);
|
||||
set({ searchResults: [] });
|
||||
}
|
||||
},
|
||||
clearSearch() {
|
||||
set({ searchQuery: '', searchResults: null });
|
||||
},
|
||||
async loadReview(period) {
|
||||
const data = await inboxApi.reviewAggregate(period);
|
||||
set({ reviewData: data });
|
||||
try {
|
||||
const data = await inboxApi.reviewAggregate(period);
|
||||
set({ reviewData: data });
|
||||
} catch (e) {
|
||||
// review IPC fail 시 reviewData=null → ReviewView 의 "불러오는 중…" 영구 표시 회피.
|
||||
// 빈 aggregate 로 set 해서 사용자에게 "0건" 표기.
|
||||
console.error('[inbox] loadReview failed', period, e);
|
||||
set({ reviewData: { totalCount: 0, tagCounts: [], dueProgress: { total: 0, passed: 0, pending: 0 }, recentNotes: [] } });
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user