feat(mobile): wire offline queue enqueue and flush
This commit is contained in:
parent
c37d6443f4
commit
5d8216066a
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user