diff --git a/mobile/src/app/(tabs)/capture.tsx b/mobile/src/app/(tabs)/capture.tsx index 65fac6d..c1afe3c 100644 --- a/mobile/src/app/(tabs)/capture.tsx +++ b/mobile/src/app/(tabs)/capture.tsx @@ -22,8 +22,8 @@ export default function CaptureScreen() { Quick capture {activeWorkspaceId - ? `Create a lightweight mobile draft in ${activeWorkspaceName}. Offline queue wiring comes in a later batch.` - : 'Choose a workspace to save this mobile draft. Offline queue wiring comes in a later batch.'} + ? `Create a lightweight mobile draft in ${activeWorkspaceName}. If the network fails, this draft is queued and retried automatically.` + : 'Choose a workspace to save this mobile draft. Failed saves are queued automatically for retry.'} {workspaces.map((workspace: MobileWorkspace) => { @@ -81,7 +81,7 @@ export default function CaptureScreen() { {saved ? Draft saved to the product backend. : null} - Offline queue readiness + Offline queue is active Queue capacity: {OFFLINE_QUEUE_MAX_SIZE} items Retry policy: {OFFLINE_QUEUE_MAX_RETRIES} attempts diff --git a/mobile/src/app/_layout.tsx b/mobile/src/app/_layout.tsx index 84c2703..4b874c1 100644 --- a/mobile/src/app/_layout.tsx +++ b/mobile/src/app/_layout.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { AppState, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { useAuthStore, type AuthState } from '../store/auth-store'; import { useInboxStore, type InboxState } from '../store/inbox-store'; import { useNotesStore, type NotesState } from '../store/notes-store'; @@ -9,6 +9,7 @@ import { useWorkspaceStore, type WorkspaceState } from '../store/workspace-store import { checkKillSwitch, initPlatform } from '../lib/platform'; import { getBroadcastClient } from '../lib/broadcast-client'; import { getSurveyClient } from '../lib/survey-client'; +import { flushNoteQueue, getNoteQueueSize } from '../lib/offline-queue'; import type { InAppMessage } from '@bytelyst/broadcast-client'; import type { ActiveSurvey, Question, QuestionAnswer } from '@bytelyst/survey-client'; import { colors } from '../theme'; @@ -20,6 +21,10 @@ export default function RootLayout() { const [surveyIndex, setSurveyIndex] = useState(0); const [surveyAnswers, setSurveyAnswers] = useState>({}); const [textAnswer, setTextAnswer] = useState(''); + const [queueStatus, setQueueStatus] = useState<{ pending: number; lastFlushed: number }>({ + pending: getNoteQueueSize(), + lastFlushed: 0, + }); const [killSwitchState, setKillSwitchState] = useState<{ checked: boolean; @@ -52,6 +57,14 @@ export default function RootLayout() { return { type: 'text', value }; } + async function flushQueuedNoteMutations(): Promise { + const result = await flushNoteQueue().catch(() => ({ flushed: 0, failed: getNoteQueueSize() })); + setQueueStatus({ + pending: result.failed, + lastFlushed: result.flushed, + }); + } + async function loadBroadcasts(): Promise { try { const { messages } = await getBroadcastClient().listMessages(); @@ -86,6 +99,7 @@ export default function RootLayout() { void hydrateNotes(); void hydrateWorkspaces(); void hydrateInbox(); + void flushQueuedNoteMutations(); void loadBroadcasts(); void loadSurvey(); @@ -97,9 +111,16 @@ export default function RootLayout() { void loadSurvey(); }, 10 * 60_000); + const appStateSubscription = AppState.addEventListener('change', (nextState) => { + if (nextState === 'active') { + void flushQueuedNoteMutations(); + } + }); + return () => { clearInterval(broadcastTimer); clearInterval(surveyTimer); + appStateSubscription.remove(); }; }, [bootstrapAuth, hydrateInbox, hydrateNotes, hydrateWorkspaces]); @@ -213,6 +234,14 @@ export default function RootLayout() { ) : null} + {queueStatus.pending > 0 || queueStatus.lastFlushed > 0 ? ( + + Offline sync + Pending actions: {queueStatus.pending} + Flushed on last attempt: {queueStatus.lastFlushed} + + ) : null} + {activeSurvey && !surveyStarted ? ( {activeSurvey.title} @@ -303,6 +332,24 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: colors.bgCanvas, }, + queueStatusCard: { + marginHorizontal: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.borderDefault, + backgroundColor: colors.surfaceCard, + padding: 10, + gap: 4, + }, + queueStatusTitle: { + color: colors.textPrimary, + fontWeight: '700', + fontSize: 13, + }, + queueStatusBody: { + color: colors.textSecondary, + fontSize: 12, + }, bannerSection: { paddingHorizontal: 12, paddingTop: 10, diff --git a/mobile/src/lib/offline-queue.ts b/mobile/src/lib/offline-queue.ts index 0daebc3..d8f63d8 100644 --- a/mobile/src/lib/offline-queue.ts +++ b/mobile/src/lib/offline-queue.ts @@ -1,5 +1,6 @@ import { createOfflineQueue } from '@bytelyst/offline-queue'; import { PRODUCT_ID } from '../api/config'; +import { getApiClient } from '../api/client'; import { mmkvStorage } from '../store/mmkv-storage'; export const OFFLINE_QUEUE_MAX_RETRIES = 5; @@ -14,3 +15,56 @@ export const noteOfflineQueue = createOfflineQueue({ maxRetries: OFFLINE_QUEUE_MAX_RETRIES, maxQueueSize: OFFLINE_QUEUE_MAX_SIZE, }); + +export function getNoteQueueSize(): number { + return noteOfflineQueue.length(); +} + +export function enqueueNoteCreate(input: { + id: string; + workspaceId: string; + title: string; + body: string; + tags?: string[]; + links?: string[]; +}): void { + noteOfflineQueue.enqueue({ + id: input.id, + action: 'create', + path: '/notes', + payload: { + id: input.id, + workspaceId: input.workspaceId, + title: input.title, + body: input.body, + tags: input.tags ?? [], + links: input.links ?? [], + }, + }); +} + +export function enqueueNoteUpdate(input: { + id: string; + workspaceId: string; + title: string; + body: string; +}): void { + noteOfflineQueue.enqueue({ + id: input.id, + action: 'update', + path: `/notes/${input.id}?workspaceId=${encodeURIComponent(input.workspaceId)}`, + payload: { + title: input.title, + body: input.body, + }, + }); +} + +export async function flushNoteQueue(): Promise<{ flushed: number; failed: number }> { + return noteOfflineQueue.flush(async (action, path, payload) => { + await getApiClient().fetch(path, { + method: action === 'create' ? 'POST' : 'PATCH', + body: JSON.stringify(payload), + }); + }); +} diff --git a/mobile/src/store/notes-store.test.ts b/mobile/src/store/notes-store.test.ts index f78684f..c265539 100644 --- a/mobile/src/store/notes-store.test.ts +++ b/mobile/src/store/notes-store.test.ts @@ -4,6 +4,8 @@ const listNotesMock = vi.fn(); const getNoteMock = vi.fn(); const createNoteMock = vi.fn(); const updateNoteMock = vi.fn(); +const enqueueNoteCreateMock = vi.fn(); +const enqueueNoteUpdateMock = vi.fn(); vi.mock('../api/notes', () => ({ listNotes: (...args: unknown[]) => listNotesMock(...args), @@ -12,6 +14,11 @@ vi.mock('../api/notes', () => ({ updateNote: (...args: unknown[]) => updateNoteMock(...args), })); +vi.mock('../lib/offline-queue', () => ({ + enqueueNoteCreate: (...args: unknown[]) => enqueueNoteCreateMock(...args), + enqueueNoteUpdate: (...args: unknown[]) => enqueueNoteUpdateMock(...args), +})); + import { useNotesStore } from './notes-store'; function resetStore() { @@ -74,6 +81,14 @@ describe('useNotesStore', () => { expect(createNoteMock).not.toHaveBeenCalled(); }); + it('saveDraft enqueues create when persistence fails', async () => { + createNoteMock.mockRejectedValueOnce(new Error('offline')); + const ok = await useNotesStore.getState().saveDraft('ws-1', 'Queued', 'Body'); + expect(ok).toBe(true); + expect(enqueueNoteCreateMock).toHaveBeenCalledTimes(1); + expect(useNotesStore.getState().notes[0].title).toBe('Queued'); + }); + it('updateNote persists and updates state', async () => { useNotesStore.setState({ notes: [fakeNote] }); const updated = { ...fakeNote, title: 'Updated' }; @@ -86,4 +101,12 @@ describe('useNotesStore', () => { await useNotesStore.getState().updateNote('missing', 'Title', 'Body'); expect(updateNoteMock).not.toHaveBeenCalled(); }); + + it('updateNote enqueues update when persistence fails', async () => { + useNotesStore.setState({ notes: [fakeNote] }); + updateNoteMock.mockRejectedValueOnce(new Error('offline')); + await useNotesStore.getState().updateNote('n1', 'Updated offline', 'Body'); + expect(enqueueNoteUpdateMock).toHaveBeenCalledTimes(1); + expect(useNotesStore.getState().notes[0].title).toBe('Updated offline'); + }); }); diff --git a/mobile/src/store/notes-store.ts b/mobile/src/store/notes-store.ts index 96acfbb..0ee48fd 100644 --- a/mobile/src/store/notes-store.ts +++ b/mobile/src/store/notes-store.ts @@ -1,5 +1,18 @@ import { create } from 'zustand'; import { createNote as persistNewNote, getNote, listNotes, updateNote as persistNote, type MobileNote } from '../api/notes'; +import { enqueueNoteCreate, enqueueNoteUpdate } from '../lib/offline-queue'; + +function createOfflineNoteId(): string { + const randomUUID = globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function' + ? globalThis.crypto.randomUUID.bind(globalThis.crypto) + : null; + + if (randomUUID) { + return randomUUID(); + } + + return `offline-${Date.now()}`; +} export type NotesState = { notes: MobileNote[]; @@ -37,12 +50,38 @@ export const useNotesStore = create((set, get) => ({ } set({ isLoading: true }); - const created = await persistNewNote(workspaceId, title, body); - set({ - notes: [created, ...get().notes], - selectedNote: created, - isLoading: false, - }); + try { + const created = await persistNewNote(workspaceId, title, body); + set({ + notes: [created, ...get().notes], + selectedNote: created, + isLoading: false, + }); + } catch { + const queuedNote: MobileNote = { + id: createOfflineNoteId(), + workspaceId, + workspaceName: workspaceId, + title: title.trim() || 'Untitled draft', + body, + status: 'draft', + updatedAt: new Date().toISOString(), + }; + + enqueueNoteCreate({ + id: queuedNote.id, + workspaceId, + title: queuedNote.title, + body, + }); + + set({ + notes: [queuedNote, ...get().notes], + selectedNote: queuedNote, + isLoading: false, + }); + } + return true; }, async updateNote(id: string, title: string, body: string) { @@ -69,12 +108,34 @@ export const useNotesStore = create((set, get) => ({ } set({ isLoading: true }); - const updated = await persistNote(id, current.workspaceId, nextTitle, body); + try { + const updated = await persistNote(id, current.workspaceId, nextTitle, body); - set({ - notes: get().notes.map((note: MobileNote) => (note.id === id ? updated : note)), - selectedNote: updated, - isLoading: false, - }); + set({ + notes: get().notes.map((note: MobileNote) => (note.id === id ? updated : note)), + selectedNote: updated, + isLoading: false, + }); + } catch { + const updated: MobileNote = { + ...current, + title: nextTitle, + body, + updatedAt: new Date().toISOString(), + }; + + enqueueNoteUpdate({ + id, + workspaceId: current.workspaceId, + title: nextTitle, + body, + }); + + set({ + notes: get().notes.map((note: MobileNote) => (note.id === id ? updated : note)), + selectedNote: updated, + isLoading: false, + }); + } }, }));