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.title}>Quick capture</Text>
|
||||||
<Text style={styles.subtitle}>
|
<Text style={styles.subtitle}>
|
||||||
{activeWorkspaceId
|
{activeWorkspaceId
|
||||||
? `Create a lightweight mobile draft in ${activeWorkspaceName}. 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. Offline queue wiring comes in a later batch.'}
|
: 'Choose a workspace to save this mobile draft. Failed saves are queued automatically for retry.'}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.workspaceRow}>
|
<View style={styles.workspaceRow}>
|
||||||
{workspaces.map((workspace: MobileWorkspace) => {
|
{workspaces.map((workspace: MobileWorkspace) => {
|
||||||
@ -81,7 +81,7 @@ export default function CaptureScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
{saved ? <Text style={styles.saved}>Draft saved to the product backend.</Text> : null}
|
{saved ? <Text style={styles.saved}>Draft saved to the product backend.</Text> : null}
|
||||||
<View style={styles.card}>
|
<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}>Queue capacity: {OFFLINE_QUEUE_MAX_SIZE} items</Text>
|
||||||
<Text style={styles.cardBody}>Retry policy: {OFFLINE_QUEUE_MAX_RETRIES} attempts</Text>
|
<Text style={styles.cardBody}>Retry policy: {OFFLINE_QUEUE_MAX_RETRIES} attempts</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
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 { useAuthStore, type AuthState } from '../store/auth-store';
|
||||||
import { useInboxStore, type InboxState } from '../store/inbox-store';
|
import { useInboxStore, type InboxState } from '../store/inbox-store';
|
||||||
import { useNotesStore, type NotesState } from '../store/notes-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 { checkKillSwitch, initPlatform } from '../lib/platform';
|
||||||
import { getBroadcastClient } from '../lib/broadcast-client';
|
import { getBroadcastClient } from '../lib/broadcast-client';
|
||||||
import { getSurveyClient } from '../lib/survey-client';
|
import { getSurveyClient } from '../lib/survey-client';
|
||||||
|
import { flushNoteQueue, getNoteQueueSize } from '../lib/offline-queue';
|
||||||
import type { InAppMessage } from '@bytelyst/broadcast-client';
|
import type { InAppMessage } from '@bytelyst/broadcast-client';
|
||||||
import type { ActiveSurvey, Question, QuestionAnswer } from '@bytelyst/survey-client';
|
import type { ActiveSurvey, Question, QuestionAnswer } from '@bytelyst/survey-client';
|
||||||
import { colors } from '../theme';
|
import { colors } from '../theme';
|
||||||
@ -20,6 +21,10 @@ export default function RootLayout() {
|
|||||||
const [surveyIndex, setSurveyIndex] = useState(0);
|
const [surveyIndex, setSurveyIndex] = useState(0);
|
||||||
const [surveyAnswers, setSurveyAnswers] = useState<Record<string, QuestionAnswer>>({});
|
const [surveyAnswers, setSurveyAnswers] = useState<Record<string, QuestionAnswer>>({});
|
||||||
const [textAnswer, setTextAnswer] = useState('');
|
const [textAnswer, setTextAnswer] = useState('');
|
||||||
|
const [queueStatus, setQueueStatus] = useState<{ pending: number; lastFlushed: number }>({
|
||||||
|
pending: getNoteQueueSize(),
|
||||||
|
lastFlushed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const [killSwitchState, setKillSwitchState] = useState<{
|
const [killSwitchState, setKillSwitchState] = useState<{
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
@ -52,6 +57,14 @@ export default function RootLayout() {
|
|||||||
return { type: 'text', value };
|
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> {
|
async function loadBroadcasts(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { messages } = await getBroadcastClient().listMessages();
|
const { messages } = await getBroadcastClient().listMessages();
|
||||||
@ -86,6 +99,7 @@ export default function RootLayout() {
|
|||||||
void hydrateNotes();
|
void hydrateNotes();
|
||||||
void hydrateWorkspaces();
|
void hydrateWorkspaces();
|
||||||
void hydrateInbox();
|
void hydrateInbox();
|
||||||
|
void flushQueuedNoteMutations();
|
||||||
void loadBroadcasts();
|
void loadBroadcasts();
|
||||||
void loadSurvey();
|
void loadSurvey();
|
||||||
|
|
||||||
@ -97,9 +111,16 @@ export default function RootLayout() {
|
|||||||
void loadSurvey();
|
void loadSurvey();
|
||||||
}, 10 * 60_000);
|
}, 10 * 60_000);
|
||||||
|
|
||||||
|
const appStateSubscription = AppState.addEventListener('change', (nextState) => {
|
||||||
|
if (nextState === 'active') {
|
||||||
|
void flushQueuedNoteMutations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(broadcastTimer);
|
clearInterval(broadcastTimer);
|
||||||
clearInterval(surveyTimer);
|
clearInterval(surveyTimer);
|
||||||
|
appStateSubscription.remove();
|
||||||
};
|
};
|
||||||
}, [bootstrapAuth, hydrateInbox, hydrateNotes, hydrateWorkspaces]);
|
}, [bootstrapAuth, hydrateInbox, hydrateNotes, hydrateWorkspaces]);
|
||||||
|
|
||||||
@ -213,6 +234,14 @@ export default function RootLayout() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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 ? (
|
{activeSurvey && !surveyStarted ? (
|
||||||
<View style={styles.surveyPrompt}>
|
<View style={styles.surveyPrompt}>
|
||||||
<Text style={styles.surveyPromptText}>{activeSurvey.title}</Text>
|
<Text style={styles.surveyPromptText}>{activeSurvey.title}</Text>
|
||||||
@ -303,6 +332,24 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.bgCanvas,
|
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: {
|
bannerSection: {
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { createOfflineQueue } from '@bytelyst/offline-queue';
|
import { createOfflineQueue } from '@bytelyst/offline-queue';
|
||||||
import { PRODUCT_ID } from '../api/config';
|
import { PRODUCT_ID } from '../api/config';
|
||||||
|
import { getApiClient } from '../api/client';
|
||||||
import { mmkvStorage } from '../store/mmkv-storage';
|
import { mmkvStorage } from '../store/mmkv-storage';
|
||||||
|
|
||||||
export const OFFLINE_QUEUE_MAX_RETRIES = 5;
|
export const OFFLINE_QUEUE_MAX_RETRIES = 5;
|
||||||
@ -14,3 +15,56 @@ export const noteOfflineQueue = createOfflineQueue({
|
|||||||
maxRetries: OFFLINE_QUEUE_MAX_RETRIES,
|
maxRetries: OFFLINE_QUEUE_MAX_RETRIES,
|
||||||
maxQueueSize: OFFLINE_QUEUE_MAX_SIZE,
|
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 getNoteMock = vi.fn();
|
||||||
const createNoteMock = vi.fn();
|
const createNoteMock = vi.fn();
|
||||||
const updateNoteMock = vi.fn();
|
const updateNoteMock = vi.fn();
|
||||||
|
const enqueueNoteCreateMock = vi.fn();
|
||||||
|
const enqueueNoteUpdateMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../api/notes', () => ({
|
vi.mock('../api/notes', () => ({
|
||||||
listNotes: (...args: unknown[]) => listNotesMock(...args),
|
listNotes: (...args: unknown[]) => listNotesMock(...args),
|
||||||
@ -12,6 +14,11 @@ vi.mock('../api/notes', () => ({
|
|||||||
updateNote: (...args: unknown[]) => updateNoteMock(...args),
|
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';
|
import { useNotesStore } from './notes-store';
|
||||||
|
|
||||||
function resetStore() {
|
function resetStore() {
|
||||||
@ -74,6 +81,14 @@ describe('useNotesStore', () => {
|
|||||||
expect(createNoteMock).not.toHaveBeenCalled();
|
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 () => {
|
it('updateNote persists and updates state', async () => {
|
||||||
useNotesStore.setState({ notes: [fakeNote] });
|
useNotesStore.setState({ notes: [fakeNote] });
|
||||||
const updated = { ...fakeNote, title: 'Updated' };
|
const updated = { ...fakeNote, title: 'Updated' };
|
||||||
@ -86,4 +101,12 @@ describe('useNotesStore', () => {
|
|||||||
await useNotesStore.getState().updateNote('missing', 'Title', 'Body');
|
await useNotesStore.getState().updateNote('missing', 'Title', 'Body');
|
||||||
expect(updateNoteMock).not.toHaveBeenCalled();
|
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 { create } from 'zustand';
|
||||||
import { createNote as persistNewNote, getNote, listNotes, updateNote as persistNote, type MobileNote } from '../api/notes';
|
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 = {
|
export type NotesState = {
|
||||||
notes: MobileNote[];
|
notes: MobileNote[];
|
||||||
@ -37,12 +50,38 @@ export const useNotesStore = create<NotesState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
const created = await persistNewNote(workspaceId, title, body);
|
try {
|
||||||
set({
|
const created = await persistNewNote(workspaceId, title, body);
|
||||||
notes: [created, ...get().notes],
|
set({
|
||||||
selectedNote: created,
|
notes: [created, ...get().notes],
|
||||||
isLoading: false,
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
async updateNote(id: string, title: string, body: string) {
|
async updateNote(id: string, title: string, body: string) {
|
||||||
@ -69,12 +108,34 @@ export const useNotesStore = create<NotesState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
const updated = await persistNote(id, current.workspaceId, nextTitle, body);
|
try {
|
||||||
|
const updated = await persistNote(id, current.workspaceId, nextTitle, body);
|
||||||
|
|
||||||
set({
|
set({
|
||||||
notes: get().notes.map((note: MobileNote) => (note.id === id ? updated : note)),
|
notes: get().notes.map((note: MobileNote) => (note.id === id ? updated : note)),
|
||||||
selectedNote: updated,
|
selectedNote: updated,
|
||||||
isLoading: false,
|
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