feat(mobile): wire offline queue enqueue and flush

This commit is contained in:
saravanakumardb1 2026-03-31 00:17:41 -07:00
parent c37d6443f4
commit 5d8216066a
5 changed files with 201 additions and 16 deletions

View File

@ -22,8 +22,8 @@ export default function CaptureScreen() {
<Text style={styles.title}>Quick capture</Text>
<Text style={styles.subtitle}>
{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.'}
</Text>
<View style={styles.workspaceRow}>
{workspaces.map((workspace: MobileWorkspace) => {
@ -81,7 +81,7 @@ export default function CaptureScreen() {
</Pressable>
{saved ? <Text style={styles.saved}>Draft saved to the product backend.</Text> : null}
<View style={styles.card}>
<Text style={styles.cardTitle}>Offline queue readiness</Text>
<Text style={styles.cardTitle}>Offline queue is active</Text>
<Text style={styles.cardBody}>Queue capacity: {OFFLINE_QUEUE_MAX_SIZE} items</Text>
<Text style={styles.cardBody}>Retry policy: {OFFLINE_QUEUE_MAX_RETRIES} attempts</Text>
</View>

View File

@ -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<Record<string, QuestionAnswer>>({});
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<void> {
const result = await flushNoteQueue().catch(() => ({ flushed: 0, failed: getNoteQueueSize() }));
setQueueStatus({
pending: result.failed,
lastFlushed: result.flushed,
});
}
async function loadBroadcasts(): Promise<void> {
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() {
</View>
) : null}
{queueStatus.pending > 0 || queueStatus.lastFlushed > 0 ? (
<View style={styles.queueStatusCard}>
<Text style={styles.queueStatusTitle}>Offline sync</Text>
<Text style={styles.queueStatusBody}>Pending actions: {queueStatus.pending}</Text>
<Text style={styles.queueStatusBody}>Flushed on last attempt: {queueStatus.lastFlushed}</Text>
</View>
) : null}
{activeSurvey && !surveyStarted ? (
<View style={styles.surveyPrompt}>
<Text style={styles.surveyPromptText}>{activeSurvey.title}</Text>
@ -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,

View File

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

View File

@ -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');
});
});

View File

@ -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<NotesState>((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<NotesState>((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,
});
}
},
}));