From a9a2236cc14a3b14faa819cb211a91f247cdf25f Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Fri, 8 May 2026 11:24:59 -0500 Subject: [PATCH] fix: refresh tasks when reopening kanban --- packages/app-core/src/store.test.ts | 109 ++++++++++++++++++++++++++++ packages/app-core/src/store.ts | 15 +++- 2 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 packages/app-core/src/store.test.ts diff --git a/packages/app-core/src/store.test.ts b/packages/app-core/src/store.test.ts new file mode 100644 index 00000000..e3f9d222 --- /dev/null +++ b/packages/app-core/src/store.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TASKS_TAB_PATH, type VaultTask } from '@shared/tasks' + +function makeTask(content: string, taskIndex = 0): VaultTask { + return { + id: `inbox/Note.md#${taskIndex}`, + sourcePath: 'inbox/Note.md', + noteTitle: 'Note', + noteFolder: 'inbox', + lineNumber: taskIndex, + taskIndex, + rawText: `- [ ] ${content}`, + content, + checked: false, + waiting: false, + tags: [] + } +} + +function makeNote(body: string) { + return { + path: 'inbox/Note.md', + title: 'Note', + folder: 'inbox' as const, + siblingOrder: 0, + createdAt: 0, + updatedAt: 1, + size: body.length, + tags: [], + wikilinks: [], + hasAttachments: false, + excerpt: body, + body + } +} + +function installZen(overrides: Record = {}): void { + Object.defineProperty(window, 'zen', { + configurable: true, + value: { + scanTasks: vi.fn().mockResolvedValue([]), + scanTasksForPath: vi.fn().mockResolvedValue([]), + listNotes: vi.fn().mockResolvedValue([makeNote('- [ ] old task')]), + listFolders: vi.fn().mockResolvedValue([]), + listAssets: vi.fn().mockResolvedValue([]), + hasAssetsDir: vi.fn().mockResolvedValue(false), + readNote: vi.fn().mockResolvedValue(makeNote('- [ ] old task')), + ...overrides + } + }) +} + +async function loadStore() { + vi.resetModules() + localStorage.clear() + return import('./store') +} + +async function flushAsyncWork(): Promise { + await new Promise((resolve) => window.setTimeout(resolve, 0)) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('tasks cache freshness', () => { + it('refreshes tasks when focusing an existing Tasks tab', async () => { + const freshTasks = [makeTask('new task')] + const scanTasks = vi.fn().mockResolvedValue(freshTasks) + installZen({ scanTasks }) + + const { useStore } = await loadStore() + const paneId = useStore.getState().activePaneId + await useStore.getState().openNoteInPane(paneId, TASKS_TAB_PATH) + await useStore.getState().openNoteInPane(paneId, 'inbox/Note.md') + useStore.setState({ vaultTasks: [makeTask('stale task')] }) + + await useStore.getState().focusTabInPane(paneId, TASKS_TAB_PATH) + await flushAsyncWork() + + expect(scanTasks).toHaveBeenCalledTimes(1) + expect(useStore.getState().vaultTasks).toEqual(freshTasks) + }) + + it('rescans changed notes while the Tasks tab is open but inactive', async () => { + const freshTasks = [makeTask('new task')] + const scanTasksForPath = vi.fn().mockResolvedValue(freshTasks) + installZen({ scanTasksForPath }) + + const { useStore } = await loadStore() + const paneId = useStore.getState().activePaneId + await useStore.getState().openNoteInPane(paneId, TASKS_TAB_PATH) + await useStore.getState().openNoteInPane(paneId, 'inbox/Note.md') + useStore.setState({ vaultTasks: [makeTask('stale task')] }) + + await useStore.getState().applyChange({ + kind: 'change', + path: 'inbox/Note.md', + folder: 'inbox', + scope: 'content' + }) + + expect(scanTasksForPath).toHaveBeenCalledWith('inbox/Note.md') + expect(useStore.getState().vaultTasks).toEqual(freshTasks) + }) +}) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index d9fc2b29..62e7ed0d 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -1149,6 +1149,10 @@ export function isTasksViewActive(state: { return leaf?.activeTab === TASKS_TAB_PATH } +function hasTasksViewOpen(state: { paneLayout: PaneLayout }): boolean { + return allLeaves(state.paneLayout).some((leaf) => leaf.tabs.includes(TASKS_TAB_PATH)) +} + /** True when the active pane's active tab is the vault-wide Tags view. */ export function isTagsViewActive(state: { paneLayout: PaneLayout @@ -2641,15 +2645,17 @@ export const useStore = create((set, get) => { if (ev.scope === 'vault-settings') return - // Keep the Tasks view in sync as files change externally or via our own - // writes — cheap per-path rescans instead of walking the whole vault. - if (isTasksViewActive(state)) { + // Keep an open Tasks tab in sync as files change externally or via our own + // writes — cheap per-path rescans instead of walking the whole vault. This + // also covers inactive Tasks tabs so returning to Kanban doesn't show stale + // cards from the last time the tab was focused. + if (hasTasksViewOpen(state)) { if (ev.kind === 'unlink') { set((s) => ({ vaultTasks: s.vaultTasks.filter((t) => t.sourcePath !== ev.path) })) } else { - void get().rescanTasksForPath(ev.path) + await get().rescanTasksForPath(ev.path) } } @@ -3516,6 +3522,7 @@ export const useStore = create((set, get) => { ...activeFieldsFrom(nextLayout, paneId, cur.noteContents, cur.noteDirty) } }) + if (!get().tasksLoading) void get().refreshTasks() return }