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