diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index 77d5dfa7..6fe16944 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -25,6 +25,7 @@ import { idleMobileEditorSaveState, type MobileEditorSaveState, } from './mobileEditorSaveState' +import { applySavedMobileEditorDraft } from './mobileSavedDraftProjection' import { MobileEditorAdapter } from './MobileEditorAdapter' import { createCompactNavigationState, @@ -57,6 +58,9 @@ export function MobileApp() { onStateChange: (noteId, saveState) => { setSaveStateByNoteId((state) => ({ ...state, [noteId]: saveState })) }, + onSavedDraft: (draft) => { + setAvailableNotes((notes) => applySavedMobileEditorDraft({ draft, notes })) + }, }), [], ) diff --git a/apps/mobile/src/mobileAutosaveQueue.test.ts b/apps/mobile/src/mobileAutosaveQueue.test.ts index 0aef9479..953b97b1 100644 --- a/apps/mobile/src/mobileAutosaveQueue.test.ts +++ b/apps/mobile/src/mobileAutosaveQueue.test.ts @@ -30,6 +30,7 @@ describe('mobile autosave queue', () => { it('ignores stale save results when a newer draft was queued', async () => { const scheduler = createManualScheduler() + const savedMarkdown: string[] = [] const states: MobileEditorSaveState[] = [] let resolveFirstSave: () => void = () => { throw new Error('First save was not scheduled') @@ -37,6 +38,7 @@ describe('mobile autosave queue', () => { const queue = createMobileAutosaveQueue({ delayMs: 300, scheduler, + onSavedDraft: (draft) => savedMarkdown.push(draft.canonicalMarkdown), onStateChange: (_noteId, state) => states.push(state), saveDraft: (draft) => draftMarkdown(draft).includes('First') @@ -54,6 +56,7 @@ describe('mobile autosave queue', () => { await scheduler.flush() expect(states.map((state) => state.state)).toEqual(['queued', 'saving', 'queued', 'saving', 'saved']) + expect(savedMarkdown).toEqual(['# Workflow\n\nSecond']) }) it('can cancel pending draft saves', async () => { diff --git a/apps/mobile/src/mobileAutosaveQueue.ts b/apps/mobile/src/mobileAutosaveQueue.ts index df251b42..baeff9be 100644 --- a/apps/mobile/src/mobileAutosaveQueue.ts +++ b/apps/mobile/src/mobileAutosaveQueue.ts @@ -20,13 +20,17 @@ export type MobileAutosaveQueue = { cancelAll: () => void } +export type SavedMobileEditorDraft = Extract + export function createMobileAutosaveQueue({ delayMs, + onSavedDraft, onStateChange, saveDraft, scheduler = nativeScheduler, }: { delayMs: number + onSavedDraft?: (draft: SavedMobileEditorDraft) => void onStateChange: (noteId: string, state: MobileEditorSaveState) => void saveDraft: (draft: MobileEditorDraft) => Promise scheduler?: MobileAutosaveScheduler @@ -43,7 +47,7 @@ export function createMobileAutosaveQueue({ draft.noteId, scheduler.set(() => { timerByNoteId.delete(draft.noteId) - void saveLatestDraft({ draft, generation, generationByNoteId, onStateChange, saveDraft }) + void saveLatestDraft({ draft, generation, generationByNoteId, onSavedDraft, onStateChange, saveDraft }) }, delayMs), ) }, @@ -65,12 +69,14 @@ async function saveLatestDraft({ draft, generation, generationByNoteId, + onSavedDraft, onStateChange, saveDraft, }: { draft: MobileEditorDraft generation: number generationByNoteId: Map + onSavedDraft?: (draft: SavedMobileEditorDraft) => void onStateChange: (noteId: string, state: MobileEditorSaveState) => void saveDraft: (draft: MobileEditorDraft) => Promise }) { @@ -79,6 +85,9 @@ async function saveLatestDraft({ const result = await saveDraft(draft) if (isLatestGeneration({ draft, generation, generationByNoteId })) { onStateChange(draft.noteId, saveResultState(result)) + if (result.status === 'saved' && draft.persistable) { + onSavedDraft?.(draft) + } } } catch { if (isLatestGeneration({ draft, generation, generationByNoteId })) { diff --git a/apps/mobile/src/mobileSavedDraftProjection.test.ts b/apps/mobile/src/mobileSavedDraftProjection.test.ts new file mode 100644 index 00000000..b97fc814 --- /dev/null +++ b/apps/mobile/src/mobileSavedDraftProjection.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { notes } from './demoData' +import { applySavedMobileEditorDraft } from './mobileSavedDraftProjection' + +describe('mobile saved draft projection', () => { + it('refreshes the saved note from canonical markdown', () => { + const updatedNotes = applySavedMobileEditorDraft({ + notes, + draft: { + noteId: 'workflow', + sourceMarkdown: notes[0].content, + editorHtml: '

New title

Updated body

', + persistable: true, + canonicalMarkdown: '# New title\n\nUpdated body', + }, + }) + + expect(updatedNotes[0]).toMatchObject({ + id: 'workflow', + title: 'New title', + snippet: 'Updated body', + modified: 'Saved now', + words: 2, + }) + expect(updatedNotes[1]).toBe(notes[1]) + }) + + it('returns the original note list when the saved note is not loaded', () => { + const updatedNotes = applySavedMobileEditorDraft({ + notes, + draft: { + noteId: 'missing', + sourceMarkdown: '', + editorHtml: '

Missing

', + persistable: true, + canonicalMarkdown: 'Missing', + }, + }) + + expect(updatedNotes).toBe(notes) + }) +}) diff --git a/apps/mobile/src/mobileSavedDraftProjection.ts b/apps/mobile/src/mobileSavedDraftProjection.ts new file mode 100644 index 00000000..2bdc23ba --- /dev/null +++ b/apps/mobile/src/mobileSavedDraftProjection.ts @@ -0,0 +1,28 @@ +import type { MobileNote } from './demoData' +import type { SavedMobileEditorDraft } from './mobileAutosaveQueue' +import { projectMobileNote } from './mobileNoteProjection' + +export function applySavedMobileEditorDraft({ + draft, + notes, +}: { + draft: SavedMobileEditorDraft + notes: MobileNote[] +}) { + let updated = false + const nextNotes = notes.map((note) => { + if (note.id !== draft.noteId) { + return note + } + + updated = true + return projectMobileNote({ + ...note, + content: draft.canonicalMarkdown, + filename: `${draft.noteId}.md`, + modified: 'Saved now', + }) + }) + + return updated ? nextNotes : notes +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index be48c7a7..0755da7b 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Branch: `codex/mobile` - Active phase: Phase 2 - Mobile Shell -- Active slice: Debounce mobile editor autosave +- Active slice: Refresh saved mobile note projection - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -61,14 +61,15 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Wired mobile editor draft changes to the app-local demo vault save path and added visible editor save states: Ready, Saving, Saved, Blocked, and Save failed. - Split editor save-state styles into a separate style module to keep mobile style files at CodeScene `10`. - Added a debounced mobile autosave queue that coalesces rapid TenTap draft changes, marks edited drafts as queued, and ignores stale save results when newer drafts supersede them. +- Refreshed the in-memory mobile note projection after the latest successful editor save so the note list, editor source, properties words, and snippets update from canonical Markdown. ## Next Action Continue Phase 2 with the next mobile shell slice: 1. Dismiss or suppress Expo Go's first-run tools modal during simulator QA so screenshots capture the app without the overlay. -2. Refresh the in-memory note projection after a successful editor save so the note list snippet/title stay in sync with saved Markdown. -3. Add the first local note create path against app-local vault storage, then wire it into the compact phone and iPad shells. +2. Add the first local note create path against app-local vault storage, then wire it into the compact phone and iPad shells. +3. Add a focused simulator interaction path for editor typing/autosave once Expo Go's overlay no longer blocks clean screenshots. ## Verification Log @@ -197,6 +198,11 @@ Continue Phase 2 with the next mobile shell slice: - `pnpm --filter @tolaria/mobile test` passed after debounced autosave: 15 files / 49 tests. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after debounced autosave. - `pnpm --filter @tolaria/mobile exec expo start --ios --clear --port 8089` launched on `iPhone 15` after debounced autosave; screenshots captured at `/tmp/tolaria-mobile-autosave-iphone.png` and `/tmp/tolaria-mobile-autosave-iphone-loaded.png`. The app rendered behind Expo Go's first-run Tools modal with no red runtime error overlay. +- `pnpm --filter @tolaria/mobile test -- src/mobileAutosaveQueue.test.ts src/mobileSavedDraftProjection.test.ts` passed after saved note projection refresh: 16 files / 51 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after saved note projection refresh. +- CodeScene after saved note projection refresh: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/mobileAutosaveQueue.ts`, `apps/mobile/src/mobileAutosaveQueue.test.ts`, `apps/mobile/src/mobileSavedDraftProjection.ts`, and `apps/mobile/src/mobileSavedDraftProjection.test.ts` scored `10`. +- `pnpm --filter @tolaria/mobile test` passed after saved note projection refresh: 16 files / 51 tests. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after saved note projection refresh. ## Risks / Watch Items