diff --git a/dev-client/__tests__/snapshot/SlopeScreen-test.tsx b/dev-client/__tests__/snapshot/SlopeScreen-test.tsx index 94a131ced..33ee58ecd 100644 --- a/dev-client/__tests__/snapshot/SlopeScreen-test.tsx +++ b/dev-client/__tests__/snapshot/SlopeScreen-test.tsx @@ -39,6 +39,7 @@ test('renders correctly', () => { slopeSteepnessSelect: 'FLAT', }, }, + soilChanges: {}, projectSettings: { '1': { ...fromEntries( diff --git a/dev-client/src/App.tsx b/dev-client/src/App.tsx index a129c3271..6f2efdf14 100644 --- a/dev-client/src/App.tsx +++ b/dev-client/src/App.tsx @@ -53,7 +53,10 @@ import {SitesScreenContextProvider} from 'terraso-mobile-client/context/SitesScr import {RootNavigator} from 'terraso-mobile-client/navigation/navigators/RootNavigator'; import {Toasts} from 'terraso-mobile-client/screens/Toasts'; import {createStore} from 'terraso-mobile-client/store'; -import {loadPersistedReduxState} from 'terraso-mobile-client/store/persistence'; +import { + loadPersistedReduxState, + patchPersistedReduxState, +} from 'terraso-mobile-client/store/persistence'; import {paperTheme, theme} from 'terraso-mobile-client/theme'; enableFreeze(true); @@ -87,7 +90,11 @@ LogBox.ignoreLogs([ 'In React 18, SSRProvider is not necessary and is a noop. You can remove it from your app.', ]); -const store = createStore(loadPersistedReduxState()); +let persistedReduxState = loadPersistedReduxState(); +if (persistedReduxState) { + persistedReduxState = patchPersistedReduxState(persistedReduxState); +} +const store = createStore(persistedReduxState); function App(): React.JSX.Element { const [headerHeight, setHeaderHeight] = useState(0); diff --git a/dev-client/src/components/buttons/SyncButton.tsx b/dev-client/src/components/buttons/SyncButton.tsx index 0be53b603..e516840f3 100644 --- a/dev-client/src/components/buttons/SyncButton.tsx +++ b/dev-client/src/components/buttons/SyncButton.tsx @@ -19,7 +19,9 @@ import {useCallback} from 'react'; import {Button} from 'native-base'; +import {Text} from 'terraso-mobile-client/components/NativeBaseAdapters'; import {RestrictByFlag} from 'terraso-mobile-client/components/RestrictByFlag'; +import {selectUnsyncedSiteIds} from 'terraso-mobile-client/model/soilId/soilIdSelectors'; import {fetchSoilDataForUser} from 'terraso-mobile-client/model/soilId/soilIdSlice'; import {useDispatch, useSelector} from 'terraso-mobile-client/store'; @@ -31,6 +33,7 @@ export const SyncButton = () => { const currentUserID = useSelector( state => state.account.currentUser?.data?.id, ); + const unsyncedIds = useSelector(selectUnsyncedSiteIds); const onSync = useCallback(() => { if (currentUserID !== undefined) { @@ -42,6 +45,7 @@ export const SyncButton = () => { // TODO-offline: Create string in en.json if we actually want this button for reals + ({unsyncedIds.length} changed sites) ); }; diff --git a/dev-client/src/model/soilId/soilIdSelectors.ts b/dev-client/src/model/soilId/soilIdSelectors.ts index aca7f626a..4d318e888 100644 --- a/dev-client/src/model/soilId/soilIdSelectors.ts +++ b/dev-client/src/model/soilId/soilIdSelectors.ts @@ -15,12 +15,26 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import {SoilIdKey} from 'terraso-client-shared/soilId/soilIdTypes'; +import {createSelector} from '@reduxjs/toolkit'; + +import {SoilData, SoilIdKey} from 'terraso-client-shared/soilId/soilIdTypes'; import {SoilIdEntry} from 'terraso-mobile-client/model/soilId/soilIdSlice'; +import { + ChangeRecords, + getUnsyncedRecords, +} from 'terraso-mobile-client/model/sync/sync'; import {AppState} from 'terraso-mobile-client/store'; export const selectSoilIdMatches = (key: SoilIdKey) => (state: AppState): SoilIdEntry | undefined => state.soilId.matches[key]; + +export const selectUnsyncedSites = (state: AppState): ChangeRecords => + getUnsyncedRecords(state.soilId.soilChanges); + +export const selectUnsyncedSiteIds = createSelector( + selectUnsyncedSites, + changes => Object.keys(changes), +); diff --git a/dev-client/src/model/soilId/soilIdSlice.ts b/dev-client/src/model/soilId/soilIdSlice.ts index 6c1264611..621064f25 100644 --- a/dev-client/src/model/soilId/soilIdSlice.ts +++ b/dev-client/src/model/soilId/soilIdSlice.ts @@ -43,6 +43,11 @@ import { soilIdEntryLocationBased, soilIdKey, } from 'terraso-mobile-client/model/soilId/soilIdFunctions'; +import { + ChangeRecords, + markAllChanged, + markChanged, +} from 'terraso-mobile-client/model/sync/sync'; export * from 'terraso-client-shared/soilId/soilIdTypes'; export * from 'terraso-mobile-client/model/soilId/soilIdFunctions'; @@ -53,14 +58,18 @@ export type MethodRequired< export type SoilState = { soilData: Record; + soilChanges: ChangeRecords; + projectSettings: Record; status: LoadingState; matches: Record; }; -const initialState: SoilState = { +export const initialState: SoilState = { soilData: {}, + soilChanges: {}, + projectSettings: {}, status: 'loading', @@ -73,6 +82,7 @@ const soilIdSlice = createSlice({ reducers: { setSoilData: (state, action: PayloadAction>) => { state.soilData = action.payload; + state.soilChanges = {}; state.matches = {}; }, updateSoilData: ( @@ -80,6 +90,11 @@ const soilIdSlice = createSlice({ action: PayloadAction>, ) => { Object.assign(state.soilData, action.payload); + markAllChanged( + state.soilChanges, + Object.keys(action.payload), + Date.now(), + ); }, setProjectSettings: ( state, @@ -103,21 +118,25 @@ const soilIdSlice = createSlice({ extraReducers: builder => { builder.addCase(updateSoilData.fulfilled, (state, action) => { state.soilData[action.meta.arg.siteId] = action.payload; + markChanged(state.soilChanges, action.meta.arg.siteId, Date.now()); flushDataBasedMatches(state); }); builder.addCase(updateDepthDependentSoilData.fulfilled, (state, action) => { state.soilData[action.meta.arg.siteId] = action.payload; + markChanged(state.soilChanges, action.meta.arg.siteId, Date.now()); flushDataBasedMatches(state); }); builder.addCase(updateSoilDataDepthInterval.fulfilled, (state, action) => { state.soilData[action.meta.arg.siteId] = action.payload; + markChanged(state.soilChanges, action.meta.arg.siteId, Date.now()); flushDataBasedMatches(state); }); builder.addCase(deleteSoilDataDepthInterval.fulfilled, (state, action) => { state.soilData[action.meta.arg.siteId] = action.payload; + markChanged(state.soilChanges, action.meta.arg.siteId, Date.now()); flushDataBasedMatches(state); }); diff --git a/dev-client/src/model/sync/sync.test.ts b/dev-client/src/model/sync/sync.test.ts new file mode 100644 index 000000000..21e24af2d --- /dev/null +++ b/dev-client/src/model/sync/sync.test.ts @@ -0,0 +1,201 @@ +/* + * Copyright © 2024 Technology Matters + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { + ChangeRecords, + getChanges, + getUnsyncedRecords, + getUpToDateResults, + isUnsynced, + markChanged, + markSynced, + nextRevisionId, +} from 'terraso-mobile-client/model/sync/sync'; + +describe('sync', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2020-01-01')); + }); + + describe('nextRevisionId', () => { + test('assumes zero initial value', () => { + expect(nextRevisionId(undefined)).toEqual(1); + }); + + test('increments by one', () => { + expect(nextRevisionId(1)).toEqual(2); + }); + }); + + describe('getChanges', () => { + test('returns changes for ids', () => { + const changes = {a: {revisionId: 1}, b: {revisionId: 2}}; + expect(getChanges(changes, ['a'])).toEqual({a: {revisionId: 1}}); + }); + + test('returns empty changes when missing', () => { + const changes = {}; + expect(getChanges(changes, ['a'])).toEqual({a: {}}); + }); + }); + + describe('markChanged', () => { + let changes: ChangeRecords; + + beforeEach(() => { + changes = {}; + }); + + test('initializes change record if not present', () => { + markChanged(changes, 'a', Date.now()); + expect(changes.a).toBeDefined(); + }); + + test('initializes revision id if not present', () => { + changes.a = {}; + markChanged(changes, 'a', Date.now()); + expect(changes.a.revisionId).toEqual(1); + }); + + test('increments revision id', () => { + changes.a = {revisionId: 122}; + markChanged(changes, 'a', Date.now()); + expect(changes.a.revisionId).toEqual(123); + }); + + test('records modified date', () => { + const at = Date.now(); + markChanged(changes, 'a', at); + expect(changes.a.lastModifiedAt).toEqual(at); + }); + + test('preserves other properties', () => { + changes.a = { + lastSyncedRevisionId: 100, + lastSyncedData: 'data', + lastSyncedAt: 10000, + }; + markChanged(changes, 'a', Date.now()); + expect(changes.a.lastSyncedRevisionId).toEqual(100); + expect(changes.a.lastSyncedData).toEqual('data'); + expect(changes.a.lastSyncedAt).toEqual(10000); + }); + }); + + describe('markSynced', () => { + let changes: ChangeRecords; + + beforeEach(() => { + changes = {}; + }); + + test('initializes change record if not present', () => { + markSynced(changes, 'a', {}, Date.now()); + expect(changes.a).toBeDefined(); + }); + + test('records synced revision id', () => { + markSynced(changes, 'a', {revisionId: 123}, Date.now()); + expect(changes.a.lastSyncedRevisionId).toEqual(123); + }); + + test('records synced data', () => { + markSynced(changes, 'a', {data: 'data'}, Date.now()); + expect(changes.a.lastSyncedData).toEqual('data'); + }); + + test('records synced date', () => { + const at = Date.now(); + markSynced(changes, 'a', {}, at); + expect(changes.a.lastSyncedAt).toEqual(at); + }); + + test('preserves other properties', () => { + changes.a = { + revisionId: 100, + lastModifiedAt: 10000, + }; + markSynced(changes, 'a', {}, Date.now()); + expect(changes.a.revisionId).toEqual(100); + expect(changes.a.lastModifiedAt).toEqual(10000); + }); + }); + + describe('getUnsyncedRecords', () => { + test('returns un-synced records', () => { + const changes = { + a: {revisionId: 1, lastSyncedRevisionId: 0}, + b: {revisionId: 1, lastSyncedRevisionId: 1}, + }; + expect(getUnsyncedRecords(changes)).toEqual({ + a: {revisionId: 1, lastSyncedRevisionId: 0}, + }); + }); + }); + + describe('isUnsynced', () => { + test('returns synced for empty records', () => { + expect(isUnsynced({})).toBeFalsy(); + }); + + test('returns synced for records with matching revision ids', () => { + expect( + isUnsynced({ + revisionId: 10, + lastSyncedRevisionId: 10, + }), + ).toBeFalsy(); + }); + + test('returns unsynced for records without matching revision ids', () => { + expect( + isUnsynced({ + revisionId: 10, + lastSyncedRevisionId: 9, + }), + ).toBeTruthy(); + }); + + test('returns unsynced for never-synced records', () => { + expect( + isUnsynced({ + revisionId: 10, + }), + ).toBeTruthy(); + }); + }); + + describe('getUpToDateResults', () => { + test('returns results with matching revision IDs', () => { + expect( + getUpToDateResults( + {a: {revisionId: 1}, b: {revisionId: 1}, c: {revisionId: 2}}, + {a: {revisionId: 2}, b: {revisionId: 1}, c: {revisionId: 1}}, + ), + ).toEqual({b: {revisionId: 1}}); + }); + + test('handles results with no change records', () => { + expect( + getUpToDateResults( + {}, + {a: {revisionId: undefined}, b: {revisionId: 1}}, + ), + ).toEqual({a: {revisionId: undefined}}); + }); + }); +}); diff --git a/dev-client/src/model/sync/sync.ts b/dev-client/src/model/sync/sync.ts new file mode 100644 index 000000000..7f7b19a67 --- /dev/null +++ b/dev-client/src/model/sync/sync.ts @@ -0,0 +1,161 @@ +/* + * Copyright © 2024 Technology Matters + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +export type ChangeRecords = Record>; + +export type ChangeTimestamp = number; + +export type ChangeRevisionId = number; + +export type ChangeRecord = { + /** + * Unique ID for the entity's current state since the last sync, monotonically increasing for each change. + * A record is considered to be un-synced if its revision ID and last-synced revision ID do not match. + * Allows code to determine which entities need to be synced, but also allows entites to determine whether + * a sync result is stale (if it declares that it is for a revision ID that no longer matches the entity). + */ + revisionId?: ChangeRevisionId; + lastModifiedAt?: ChangeTimestamp; + + lastSyncedRevisionId?: ChangeRevisionId; + lastSyncedData?: T; + lastSyncedAt?: ChangeTimestamp; +}; + +export type SyncResults = Record>; + +export type SyncResult = { + data?: T; + revisionId?: ChangeRevisionId; +}; + +export const INITIAL_REVISION_ID = 0; + +export const nextRevisionId = ( + revisionId?: ChangeRevisionId, +): ChangeRevisionId => { + return (revisionId ?? INITIAL_REVISION_ID) + 1; +}; + +export const getChanges = ( + records: ChangeRecords, + ids: string[], +): ChangeRecords => { + return Object.fromEntries(ids.map(id => [id, getChange(records, id)])); +}; + +export const getChange = ( + records: ChangeRecords, + id: string, +): ChangeRecord => { + return records[id] ?? {}; +}; + +export const markAllChanged = ( + records: ChangeRecords, + ids: string[], + at: ChangeTimestamp, +) => { + for (const id of ids) { + markChanged(records, id, at); + } +}; + +export const markChanged = ( + records: ChangeRecords, + id: string, + at: ChangeTimestamp, +) => { + const prevRecord = getChange(records, id); + const revisionId = nextRevisionId(prevRecord.revisionId); + records[id] = { + revisionId: revisionId, + lastModifiedAt: at, + + lastSyncedRevisionId: prevRecord.lastSyncedRevisionId, + lastSyncedData: prevRecord.lastSyncedData, + lastSyncedAt: prevRecord.lastSyncedAt, + }; +}; + +export const markAllSynced = ( + records: ChangeRecords, + results: SyncResults, + at: ChangeTimestamp, +) => { + for (const [id, result] of Object.entries(results)) { + markSynced(records, id, result, at); + } +}; + +export const markSynced = ( + records: ChangeRecords, + id: string, + result: SyncResult, + at: ChangeTimestamp, +) => { + const prevRecord = getChange(records, id); + records[id] = { + revisionId: prevRecord.revisionId, + lastModifiedAt: prevRecord.lastModifiedAt, + lastSyncedAt: at, + lastSyncedRevisionId: result.revisionId, + lastSyncedData: result.data, + }; +}; + +export const getUnsyncedRecords = ( + records: ChangeRecords, +): ChangeRecords => { + return Object.fromEntries( + Object.entries(records).filter(([_, record]) => isUnsynced(record)), + ); +}; + +export const isUnsynced = (record: ChangeRecord): boolean => { + if ( + record.lastSyncedRevisionId === undefined && + record.revisionId === undefined + ) { + /* Never-synced never-changed records have no need for syncing */ + return false; + } else if (record.lastSyncedRevisionId === undefined) { + /* Unsynced changes if the record has changes but no last-synced id */ + return true; + } else { + /* Unsynced changes if the record's current revision is not the last-synced one */ + return record.revisionId !== record.lastSyncedRevisionId; + } +}; + +export const getUpToDateResults = ( + records: ChangeRecords, + results: SyncResults, +): SyncResults => { + return Object.fromEntries( + Object.entries(results).filter(([id, result]) => + isUpToDate(getChange(records, id), result), + ), + ); +}; + +export const isUpToDate = ( + record: ChangeRecord, + result: SyncResult, +): boolean => { + return record.revisionId === result.revisionId; +}; diff --git a/dev-client/src/store/persistence.test.ts b/dev-client/src/store/persistence.test.ts index 618771d8a..4351810ed 100644 --- a/dev-client/src/store/persistence.test.ts +++ b/dev-client/src/store/persistence.test.ts @@ -17,16 +17,7 @@ import {configureStore, createSlice} from '@reduxjs/toolkit'; -const { - reducer, - actions: {increment}, -} = createSlice({ - name: 'test', - initialState: {counter: 0}, - reducers: { - increment: ({counter}) => ({counter: counter + 1}), - }, -}); +import {patchPersistedReduxState} from 'terraso-mobile-client/store/persistence'; jest.mock('terraso-mobile-client/config/index', () => ({ APP_CONFIG: { @@ -34,73 +25,106 @@ jest.mock('terraso-mobile-client/config/index', () => ({ }, })); -test('persistence middleware saves state to disk', () => { - jest.isolateModules(() => { - const {kvStorage} = require('terraso-mobile-client/persistence/kvStorage'); - kvStorage.setBool('FF_offline', true); +describe('persistence middleware', () => { + const { + reducer, + actions: {increment}, + } = createSlice({ + name: 'test', + initialState: {counter: 0}, + reducers: { + increment: ({counter}) => ({counter: counter + 1}), + }, + }); - const { - persistenceMiddleware, - loadPersistedReduxState, - } = require('terraso-mobile-client/store/persistence'); + test('persistence middleware saves state to disk', () => { + jest.isolateModules(() => { + const { + kvStorage, + } = require('terraso-mobile-client/persistence/kvStorage'); + kvStorage.setBool('FF_offline', true); - const store = configureStore({ - middleware: [persistenceMiddleware], - reducer, - }); + const { + persistenceMiddleware, + loadPersistedReduxState, + } = require('terraso-mobile-client/store/persistence'); - expect(loadPersistedReduxState()).toBe(undefined); + const store = configureStore({ + middleware: [persistenceMiddleware], + reducer, + }); - store.dispatch(increment()); + expect(loadPersistedReduxState()).toBe(undefined); - expect(loadPersistedReduxState()).toEqual({counter: 1}); - }); -}); + store.dispatch(increment()); -test('can initialize store with persisted state', () => { - jest.isolateModules(() => { - const {kvStorage} = require('terraso-mobile-client/persistence/kvStorage'); - kvStorage.setBool('FF_offline', true); - - const { - persistenceMiddleware, - loadPersistedReduxState, - } = require('terraso-mobile-client/store/persistence'); - - kvStorage.setMap('persisted-redux-state', {counter: 1}); - const store = configureStore({ - middleware: [persistenceMiddleware], - reducer, - preloadedState: loadPersistedReduxState(), + expect(loadPersistedReduxState()).toEqual({counter: 1}); }); + }); - expect(store.getState()).toEqual({counter: 1}); + test('can initialize store with persisted state', () => { + jest.isolateModules(() => { + const { + kvStorage, + } = require('terraso-mobile-client/persistence/kvStorage'); + kvStorage.setBool('FF_offline', true); - store.dispatch(increment()); + const { + persistenceMiddleware, + loadPersistedReduxState, + } = require('terraso-mobile-client/store/persistence'); - expect(loadPersistedReduxState()).toEqual({counter: 2}); - }); -}); + kvStorage.setMap('persisted-redux-state', {counter: 1}); + const store = configureStore({ + middleware: [persistenceMiddleware], + reducer, + preloadedState: loadPersistedReduxState(), + }); -test('persistence middleware does nothing without feature flag', () => { - jest.isolateModules(() => { - const {kvStorage} = require('terraso-mobile-client/persistence/kvStorage'); - kvStorage.setBool('FF_offline', false); + expect(store.getState()).toEqual({counter: 1}); - const { - persistenceMiddleware, - loadPersistedReduxState, - } = require('terraso-mobile-client/store/persistence'); + store.dispatch(increment()); - const store = configureStore({ - middleware: [persistenceMiddleware], - reducer, + expect(loadPersistedReduxState()).toEqual({counter: 2}); }); + }); - expect(loadPersistedReduxState()).toBe(undefined); + test('persistence middleware does nothing without feature flag', () => { + jest.isolateModules(() => { + const { + kvStorage, + } = require('terraso-mobile-client/persistence/kvStorage'); + kvStorage.setBool('FF_offline', false); - store.dispatch(increment()); + const { + persistenceMiddleware, + loadPersistedReduxState, + } = require('terraso-mobile-client/store/persistence'); + + const store = configureStore({ + middleware: [persistenceMiddleware], + reducer, + }); + + expect(loadPersistedReduxState()).toBe(undefined); + + store.dispatch(increment()); + + expect(loadPersistedReduxState()).toBe(undefined); + }); + }); +}); + +describe('patchPersistedReduxState', () => { + test('adds changes if absent', () => { + const state: any = {soilId: {soilChanges: undefined}}; + const result = patchPersistedReduxState(state); + expect(result.soilId!.soilChanges).toEqual({}); + }); - expect(loadPersistedReduxState()).toBe(undefined); + test('retains changes if present', () => { + const state: any = {soilId: {soilChanges: {a: {}}}}; + const result = patchPersistedReduxState(state); + expect(result.soilId!.soilChanges).toEqual({a: {}}); }); }); diff --git a/dev-client/src/store/persistence.ts b/dev-client/src/store/persistence.ts index 1f4663291..20cbe0b73 100644 --- a/dev-client/src/store/persistence.ts +++ b/dev-client/src/store/persistence.ts @@ -16,6 +16,7 @@ */ import {Middleware} from '@reduxjs/toolkit'; +import {merge} from 'lodash/fp'; import {isFlagEnabled} from 'terraso-mobile-client/config/featureFlags'; import {kvStorage} from 'terraso-mobile-client/persistence/kvStorage'; @@ -38,3 +39,9 @@ export const loadPersistedReduxState = () => { ); } }; + +export const patchPersistedReduxState = ( + state: Partial, +): Partial => { + return merge(state, {soilId: {soilChanges: {}}}); +};