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,
+ });
+ }
},
}));