diff --git a/apps/desktop/src/editor/use-editor-autocomplete.test.tsx b/apps/desktop/src/editor/use-editor-autocomplete.test.tsx index b9a453eed..0b7e417b0 100644 --- a/apps/desktop/src/editor/use-editor-autocomplete.test.tsx +++ b/apps/desktop/src/editor/use-editor-autocomplete.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderHook } from 'vitest-browser-react' import { useEditorAutocomplete } from './use-editor-autocomplete' +const materializeDailyNote = vi.hoisted(() => vi.fn()) const resolveOrCreateNoteWithTitle = vi.hoisted(() => vi.fn()) const suggestWikiLinkTargets = vi.hoisted(() => vi.fn()) const operationFail = vi.hoisted(() => vi.fn()) @@ -13,6 +14,7 @@ vi.mock('@reflect/core', async (importOriginal) => ({ suggestWikiTargets: async () => [], suggestWikiLinkTargets, suggestTags: async () => [], + materializeDailyNote, resolveOrCreateNoteWithTitle, })) vi.mock('@/providers/graph-provider', () => ({ @@ -33,6 +35,7 @@ vi.mock('@/hooks/use-contacts-authorization', () => ({ vi.mock('@/lib/operations', () => ({ startOperation })) beforeEach(() => { + materializeDailyNote.mockReset().mockResolvedValue('daily/2026-07-27.md') resolveOrCreateNoteWithTitle.mockReset() suggestWikiLinkTargets.mockReset() suggestWikiLinkTargets.mockResolvedValue({ @@ -45,6 +48,88 @@ beforeEach(() => { }) describe('useEditorAutocomplete', () => { + it.each([ + { query: '2026-07-27', generated: undefined }, + { query: 'six days from now', generated: { phrase: 'Six days from now' } }, + ])('materializes a new daily when selecting $query', async ({ query, generated }) => { + suggestWikiLinkTargets.mockResolvedValue({ + suggestions: [ + { + target: '2026-07-27', + insertText: '2026-07-27', + title: '2026-07-27', + alias: null, + date: '2026-07-27', + path: null, + ...(generated === undefined ? {} : { generated }), + }, + ], + claimedTargetKeys: [], + queryReadsAsDate: true, + }) + const { result } = renderHook(() => useEditorAutocomplete()) + const items = await result.current.onWikilinkSearch(query) + + act(() => { + items[0]!.onSelect?.() + }) + + await waitFor(() => + expect(materializeDailyNote).toHaveBeenCalledWith('2026-07-27', 7), + ) + }) + + it('does not try to recreate an existing daily suggestion', async () => { + suggestWikiLinkTargets.mockResolvedValue({ + suggestions: [ + { + target: '2026-07-27', + insertText: '2026-07-27', + title: '2026-07-27', + alias: null, + date: '2026-07-27', + path: 'daily/2026-07-27.md', + }, + ], + claimedTargetKeys: ['2026-07-27'], + queryReadsAsDate: true, + }) + const { result } = renderHook(() => useEditorAutocomplete()) + const items = await result.current.onWikilinkSearch('2026-07-27') + + expect(items[0]?.onSelect).toBeUndefined() + expect(materializeDailyNote).not.toHaveBeenCalled() + }) + + it('surfaces a failed daily creation instead of leaving a silent dangling link', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + materializeDailyNote.mockRejectedValue(new Error('graph changed')) + suggestWikiLinkTargets.mockResolvedValue({ + suggestions: [ + { + target: '2026-07-27', + insertText: '2026-07-27', + title: '2026-07-27', + alias: null, + date: '2026-07-27', + path: null, + }, + ], + claimedTargetKeys: [], + queryReadsAsDate: true, + }) + const { result } = renderHook(() => useEditorAutocomplete()) + const items = await result.current.onWikilinkSearch('2026-07-27') + + act(() => { + items[0]!.onSelect?.() + }) + + await waitFor(() => expect(operationFail).toHaveBeenCalledWith('graph changed')) + expect(startOperation).toHaveBeenCalledWith('Creating daily note') + consoleError.mockRestore() + }) + it('does not offer create when the exact query has an unaddressable claim', async () => { suggestWikiLinkTargets.mockResolvedValue({ suggestions: [], diff --git a/apps/desktop/src/editor/use-editor-autocomplete.ts b/apps/desktop/src/editor/use-editor-autocomplete.ts index e3d40b22b..f95d6846c 100644 --- a/apps/desktop/src/editor/use-editor-autocomplete.ts +++ b/apps/desktop/src/editor/use-editor-autocomplete.ts @@ -11,6 +11,7 @@ import { errorMessage, hasBridge, isContactsReadable, + materializeDailyNote, resolveOrCreateNoteWithTitle, suggestTags, suggestWikiLinkTargets, @@ -73,6 +74,19 @@ export function useEditorAutocomplete(): EditorAutocomplete { [generation], ) + const materializeDailyFromAutocomplete = useCallback( + (date: string): void => { + if (generation === null) { + return + } + void materializeDailyNote(date, generation).catch((error: unknown) => { + console.error('create-daily-from-autocomplete failed:', error) + startOperation('Creating daily note').fail(errorMessage(error)) + }) + }, + [generation], + ) + const onWikilinkSearch = useCallback( async (query: string): Promise => { if (!hasBridge() || graph === null) { @@ -135,10 +149,22 @@ export function useEditorAutocomplete(): EditorAutocomplete { } } const { title, alias, date, path, generated, insertText: target } = entry.suggestion + // Selecting a pathless date is the create action: materialize its empty + // daily file in the background so the inserted link resolves and gains + // a backlink without requiring the user to visit or edit that day. + const dateSelection = + date !== null && path === null + ? { onSelect: () => materializeDailyFromAutocomplete(date) } + : {} // A generated date leads with its phrase ("Next Friday"), resolved day // as the detail; everything else keeps the title/alias/daily form. if (generated !== undefined && date !== null) { - return { target, label: generated.phrase, detail: formatDayLabel(date, settings.dateFormat) } + return { + target, + label: generated.phrase, + detail: formatDayLabel(date, settings.dateFormat), + ...dateSelection, + } } // A rich title reads as its rendered form; the raw form stays the identity. const displayedTitle = displayNoteTitle(title) @@ -151,7 +177,12 @@ export function useEditorAutocomplete(): EditorAutocomplete { ? `${date} ยท new` : date : undefined - return { target, label, ...(detail !== undefined ? { detail } : {}) } + return { + target, + label, + ...(detail !== undefined ? { detail } : {}), + ...dateSelection, + } }) }, [ @@ -159,6 +190,7 @@ export function useEditorAutocomplete(): EditorAutocomplete { settings.dateFormat, settings.weekStartDay, resolveOrCreateFromAutocomplete, + materializeDailyFromAutocomplete, contactsInMenu, generation, ], diff --git a/packages/core/src/exports/platform.ts b/packages/core/src/exports/platform.ts index 94e314313..c7683a707 100644 --- a/packages/core/src/exports/platform.ts +++ b/packages/core/src/exports/platform.ts @@ -157,6 +157,7 @@ export { untitledNotePath, isUntitledNotePath, createNoteWithTitle, + materializeDailyNote, resolveOrCreateNoteWithTitle, type ResolveOrCreateNoteResult, } from '../graph/create-note' diff --git a/packages/core/src/graph/create-note.test.ts b/packages/core/src/graph/create-note.test.ts index ec60c3ff2..4cd03d478 100644 --- a/packages/core/src/graph/create-note.test.ts +++ b/packages/core/src/graph/create-note.test.ts @@ -3,6 +3,7 @@ import { setBridge } from '../ipc/bridge' import { createNoteWithTitle, isUntitledNotePath, + materializeDailyNote, resolveOrCreateNoteWithTitle, untitledNotePath, untitledNoteSeed, @@ -146,6 +147,43 @@ describe('createNoteWithTitle', () => { }) }) +describe('materializeDailyNote', () => { + it('atomically creates an empty daily file at the date path', async () => { + const invoke = bindBridge() + + await expect(materializeDailyNote('2026-07-27', 7)).resolves.toBe( + 'daily/2026-07-27.md', + ) + expect(invoke).toHaveBeenCalledWith('note_create', { + path: 'daily/2026-07-27.md', + contents: '', + generation: 7, + }) + }) + + it('does not replace an existing daily file', async () => { + const invoke = bindBridge({ files: { 'daily/2026-07-27.md': 'existing\n' } }) + + await expect(materializeDailyNote('2026-07-27', 7)).resolves.toBe( + 'daily/2026-07-27.md', + ) + expect(invoke).toHaveBeenCalledWith('note_create', { + path: 'daily/2026-07-27.md', + contents: '', + generation: 7, + }) + }) + + it('rejects impossible dates before attempting a write', async () => { + const invoke = bindBridge() + + await expect(materializeDailyNote('2026-02-31', 7)).rejects.toThrow( + 'dailyPath expects a valid calendar date', + ) + expect(invoke.mock.calls.some(([command]) => command === 'note_create')).toBe(false) + }) +}) + describe('resolveOrCreateNoteWithTitle', () => { it('uses exact index resolution before reading the slug family', async () => { const invoke = bindBridge({ diff --git a/packages/core/src/graph/create-note.ts b/packages/core/src/graph/create-note.ts index 541bda048..732c1a62f 100644 --- a/packages/core/src/graph/create-note.ts +++ b/packages/core/src/graph/create-note.ts @@ -2,7 +2,7 @@ import { ulid } from 'ulidx' import { upsertFrontmatter } from '../markdown/frontmatter' import { slugForTitle } from '../markdown/slug' import { createNoteIfAbsent } from './commands' -import { notePath } from './paths' +import { dailyPath, notePath } from './paths' import { resolveExistingWikiTarget, type ExistingWikiTargetResolution, @@ -81,6 +81,19 @@ export async function createNoteWithTitle( return claimed.path } +/** + * Materialize a daily note selected from wiki-link autocomplete without + * replacing one created concurrently. Daily notes have no title seed: their + * date path is their identity, and an empty file is the canonical new state. + * + * @returns The graph-relative daily-note path. + */ +export async function materializeDailyNote(date: string, generation: number): Promise { + const path = dailyPath(date) + await createNoteIfAbsent(path, '', generation) + return path +} + /** Far beyond any real graph's same-slug population; fail loud instead of spinning. */ const MAX_CREATE_ATTEMPTS = 1000