Skip to content

Commit

Permalink
#151 Enhanced sync strategy (#156)
Browse files Browse the repository at this point in the history
  • Loading branch information
thekingofcity authored Jan 5, 2025
1 parent 3392bf0 commit 6b30577
Show file tree
Hide file tree
Showing 19 changed files with 490 additions and 159 deletions.
62 changes: 3 additions & 59 deletions src/components/menu/account-view/account-status.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,21 @@
import { Avatar, Button, Text, useToast } from '@chakra-ui/react';
import { logger } from '@railmapgen/rmg-runtime';
import { Avatar, Button, Text } from '@chakra-ui/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MdArrowForwardIos } from 'react-icons/md';
import { useRootDispatch, useRootSelector } from '../../../redux';
import { fetchSaveList, setToken } from '../../../redux/account/account-slice';
import { fetchSaveList } from '../../../redux/account/account-slice';
import { setMenuView } from '../../../redux/app/app-slice';
import { API_ENDPOINT, SAVE_KEY } from '../../../util/constants';
import { getRMPSave, notifyRMPSaveChange, setRMPSave } from '../../../util/local-storage-save';
import { apiFetch } from '../../../util/utils';

const AccountStatus = () => {
const toast = useToast();
const { t } = useTranslation();
const dispatch = useRootDispatch();
const { isLoggedIn, name, token, refreshToken, saves, currentSaveId } = useRootSelector(state => state.account);

const showErrorToast = (msg: string) =>
toast({
title: msg,
status: 'error' as const,
duration: 9000,
isClosable: true,
});
const { isLoggedIn, name } = useRootSelector(state => state.account);

React.useEffect(() => {
if (!isLoggedIn) return;
dispatch(fetchSaveList());
}, [isLoggedIn]);

// Below: Set RMP save with cloud latest on logged in

React.useEffect(() => {
if (!currentSaveId) return;
checkIfRMPSaveNeedsUpdated();
}, [currentSaveId]);

const checkIfRMPSaveNeedsUpdated = async () => {
const save = await getRMPSave(SAVE_KEY.RMP);
if (!save) {
setRMPSaveWithCloudLatest();
return;
}
const { hash: localHash } = save;
const cloudHash = saves.filter(save => save.id === currentSaveId).at(0)?.hash;
if (!cloudHash) return;
if (cloudHash !== localHash) {
setRMPSaveWithCloudLatest();
}
};

const setRMPSaveWithCloudLatest = async () => {
const {
rep,
token: updatedToken,
refreshToken: updatedRefreshToken,
} = await apiFetch(API_ENDPOINT.SAVES + '/' + currentSaveId, {}, token, refreshToken);
if (!updatedRefreshToken || !updatedToken) {
showErrorToast(t('Login status expired'));
return;
}
dispatch(setToken({ access: updatedToken, refresh: updatedRefreshToken }));
if (rep.status !== 200) {
showErrorToast(await rep.text());
return;
}
logger.info(`Set ${SAVE_KEY.RMP} with save id: ${currentSaveId}`);
setRMPSave(SAVE_KEY.RMP, await rep.text());
notifyRMPSaveChange();
};

// Above: Set RMP save with cloud latest on logged in

return (
<Button
variant="ghost"
Expand Down
2 changes: 1 addition & 1 deletion src/components/menu/account-view/forgot-password-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ const ForgotPasswordView = (props: { setLoginState: (_: 'login' | 'register' | '
onChange: setPassword,
debouncedDelay: 0,
validator: passwordValidator,
helper: t('Mininum 8 characters. Contain at least 1 letter and 1 number.'),
helper: t('Minimum 8 characters. Contain at least 1 letter and 1 number.'),
},
]}
minW="full"
Expand Down
2 changes: 1 addition & 1 deletion src/components/menu/account-view/register-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ const RegisterView = (props: { setLoginState: (_: 'login' | 'register') => void
onChange: setPassword,
debouncedDelay: 0,
validator: passwordValidator,
helper: t('Mininum 8 characters. Contain at least 1 letter and 1 number.'),
helper: t('Minimum 8 characters. Contain at least 1 letter and 1 number.'),
},
]}
minW="full"
Expand Down
80 changes: 35 additions & 45 deletions src/components/menu/account-view/saves-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { logger } from '@railmapgen/rmg-runtime';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useRootDispatch, useRootSelector } from '../../../redux';
import { fetchSaveList, setToken } from '../../../redux/account/account-slice';
import { API_ENDPOINT, SAVE_KEY } from '../../../util/constants';
import { fetchSaveList, setToken, syncAfterLogin } from '../../../redux/account/account-slice';
import { setLastChangedAtTimeStamp } from '../../../redux/rmp-save/rmp-save-slice';
import { API_ENDPOINT, APISaveList, SAVE_KEY } from '../../../util/constants';
import { getRMPSave, notifyRMPSaveChange, setRMPSave } from '../../../util/local-storage-save';
import { apiFetch } from '../../../util/utils';

Expand All @@ -24,6 +25,7 @@ const SavesSection = () => {
currentSaveId,
saves: saveList,
} = useRootSelector(state => state.account);
const { lastChangedAtTimeStamp } = useRootSelector(state => state.rmpSave);

const canCreateNewSave =
isLoggedIn &&
Expand Down Expand Up @@ -79,12 +81,42 @@ const SavesSection = () => {
const handleSync = async (saveId: number) => {
if (!isLoggedIn || !token) return;
if (saveId === currentSaveId) {
// current sync, either fetch cloud (a) or update cloud (b)
setSyncButtonIsLoading(currentSaveId);
if (!currentSaveId || isUpdateDisabled(currentSaveId)) {
showErrorToast(t('Can not sync this save!'));
setSyncButtonIsLoading(undefined);
return;
}

// fetch cloud save metadata
const savesRep = await dispatch(fetchSaveList());
if (savesRep.meta.requestStatus !== 'fulfilled') {
showErrorToast(t('Login status expired')); // TODO: also might be !200 response
setSyncButtonIsLoading(undefined);
return;
}
const savesList = savesRep.payload as APISaveList;
const cloudSave = savesList.saves.filter(save => save.id === currentSaveId).at(0);
if (!cloudSave) {
showErrorToast(t(`Current save id is not in saveList!`));
// TODO: ask sever to reconstruct currentSaveId
setSyncButtonIsLoading(undefined);
return;
}
const lastUpdateAt = new Date(cloudSave.lastUpdateAt);
const lastChangedAt = new Date(lastChangedAtTimeStamp);
// a. if cloud save is newer, fetch and set the cloud save to local
if (lastChangedAt < lastUpdateAt) {
logger.warn(`Save id: ${currentSaveId} is newer in the cloud via local compare.`);
// TODO: There is no compare just fetch and set the cloud save to local
// might be better to have a dedicated thunk action for this
dispatch(syncAfterLogin());
setSyncButtonIsLoading(undefined);
return;
}

// b. local save is newer, update the cloud save
const save = await getRMPSave(SAVE_KEY.RMP);
if (!save) {
showErrorToast(t('Failed to get the RMP save!'));
Expand Down Expand Up @@ -138,6 +170,7 @@ const SavesSection = () => {
}
logger.info(`Set ${SAVE_KEY.RMP} with save id: ${saveId}`);
setRMPSave(SAVE_KEY.RMP, await rep.text());
dispatch(setLastChangedAtTimeStamp(new Date().valueOf()));
notifyRMPSaveChange();
setSyncButtonIsLoading(undefined);
}
Expand Down Expand Up @@ -166,49 +199,6 @@ const SavesSection = () => {
setDeleteButtonIsLoading(undefined);
};

// Below: Set RMP save with cloud latest on logged in

React.useEffect(() => {
if (!currentSaveId) return;
checkIfRMPSaveNeedsUpdated();
}, [currentSaveId]);

const checkIfRMPSaveNeedsUpdated = async () => {
const save = await getRMPSave(SAVE_KEY.RMP);
if (!save) {
setRMPSaveWithCloudLatest();
return;
}
const { hash: localHash } = save;
const cloudHash = saveList.filter(save => save.id === currentSaveId).at(0)?.hash;
if (!cloudHash) return;
if (cloudHash !== localHash) {
setRMPSaveWithCloudLatest();
}
};

const setRMPSaveWithCloudLatest = async () => {
const {
rep,
token: updatedToken,
refreshToken: updatedRefreshToken,
} = await apiFetch(API_ENDPOINT.SAVES + '/' + currentSaveId, {}, token, refreshToken);
if (!updatedRefreshToken || !updatedToken) {
showErrorToast(t('Login status expired'));
return;
}
dispatch(setToken({ access: updatedToken, refresh: updatedRefreshToken }));
if (rep.status !== 200) {
showErrorToast(await rep.text());
return;
}
logger.info(`Set ${SAVE_KEY.RMP} with save id: ${currentSaveId}`);
setRMPSave(SAVE_KEY.RMP, await rep.text());
notifyRMPSaveChange();
};

// Above: Set RMP save with cloud latest on logged in

return (
<RmgSection>
<RmgSectionHeader>
Expand Down
3 changes: 3 additions & 0 deletions src/components/menu/nav-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { useRootSelector } from '../../redux';
import { isShowDevtools } from '../../redux/app/app-slice';
import ResolveConflictModal from '../modal/resolve-conflict-modal';
import AccountStatus from './account-view/account-status';
import AccountView from './account-view/account-view';
import AppsSection from './main-view/apps-section';
Expand Down Expand Up @@ -136,6 +137,8 @@ export default function NavMenu() {

{/* menu-footer */}
<NavMenuFooter />

<ResolveConflictModal />
</Flex>
</Flex>
);
Expand Down
154 changes: 154 additions & 0 deletions src/components/modal/resolve-conflict-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import {
Button,
Card,
CardBody,
CardFooter,
Flex,
Icon,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Stack,
Text,
} from '@chakra-ui/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MdCloudCircle, MdComputer } from 'react-icons/md';
import { useRootDispatch, useRootSelector } from '../../redux';
import { fetchSaveList, logout, setToken, syncAfterLogin } from '../../redux/account/account-slice';
import { clearResolveConflictModal, setLastChangedAtTimeStamp } from '../../redux/rmp-save/rmp-save-slice';
import { SAVE_KEY } from '../../util/constants';
import { downloadAs } from '../../util/download';
import { getRMPSave, notifyRMPSaveChange, setRMPSave, updateSave } from '../../util/local-storage-save';

const ResolveConflictModal = () => {
const { t } = useTranslation();
const { token, refreshToken, currentSaveId } = useRootSelector(state => state.account);
const {
resolveConflictModal: { isOpen, lastChangedAtTimeStamp, lastUpdatedAtTimeStamp, cloudData },
} = useRootSelector(state => state.rmpSave);
const dispatch = useRootDispatch();

const [replaceCloudWithLocalLoading, setReplaceCloudWithLocalLoading] = React.useState(false);

const onClose = () => dispatch(clearResolveConflictModal());
const replaceLocalWithCloud = () => {
setRMPSave(SAVE_KEY.RMP, cloudData);
notifyRMPSaveChange();
dispatch(setLastChangedAtTimeStamp(new Date().valueOf()));
onClose();
};
const downloadCloud = () => {
downloadAs(`RMP_${lastUpdatedAtTimeStamp}.json`, 'application/json', cloudData);
};
const replaceCloudWithLocal = async () => {
if (!currentSaveId || !token || !refreshToken) return;
setReplaceCloudWithLocalLoading(true);
const {
rep,
token: updatedToken,
refreshToken: updatedRefreshToken,
} = await updateSave(currentSaveId, token, refreshToken, SAVE_KEY.RMP);
if (!updatedRefreshToken || !updatedToken) {
dispatch(logout());
setReplaceCloudWithLocalLoading(false);
return;
}
dispatch(setToken({ access: updatedToken, refresh: updatedRefreshToken }));
if (rep.status === 409) {
dispatch(syncAfterLogin());
setReplaceCloudWithLocalLoading(false);
return;
}
if (rep.status !== 200) return;
dispatch(fetchSaveList());
setReplaceCloudWithLocalLoading(false);
onClose();
};
const downloadLocal = async () => {
// fetchLogin will handle local save that does not exist
const { data: localData } = (await getRMPSave(SAVE_KEY.RMP))!;
downloadAs(`RMP_${lastChangedAtTimeStamp}.json`, 'application/json', localData);
};

return (
<Modal
isOpen={isOpen}
onClose={() => {}} // do not allow user to close before resolving the conflict
size="xl"
scrollBehavior="inside"
closeOnOverlayClick={false}
closeOnEsc={false}
>
<ModalOverlay />
<ModalContent>
<ModalHeader>{t("Oops! It seems there's a conflict")}</ModalHeader>

<ModalBody>
<Text>{t('The local save is newer than the cloud one. Which one would you like to keep?')}</Text>

<Stack direction={{ base: 'column', sm: 'row' }} mt="5">
<Card overflow="hidden" variant="outline" mb="3">
<CardBody>
<Flex align="center">
<Icon as={MdComputer} mr="2" />
<Text py="2" as="b">
{t('Local save')}
</Text>
</Flex>
<Text py="2">
{t('Update at:')} {new Date(lastChangedAtTimeStamp).toLocaleString()}
</Text>
</CardBody>
<CardFooter>
<Stack>
<Button
variant="solid"
colorScheme="red"
isLoading={replaceCloudWithLocalLoading}
onClick={() => replaceCloudWithLocal()}
>
{t('Replace cloud with local')}
</Button>
<Button variant="solid" colorScheme="primary" onClick={() => downloadLocal()}>
{t('Download Local save')}
</Button>
</Stack>
</CardFooter>
</Card>
<Card overflow="hidden" variant="outline" mb="3">
<CardBody>
<Flex align="center">
<Icon as={MdCloudCircle} mr="2" />
<Text py="2" as="b">
{t('Cloud save')}
</Text>
</Flex>
<Text py="2">
{t('Update at:')} {new Date(lastUpdatedAtTimeStamp).toLocaleString()}
</Text>
</CardBody>
<CardFooter>
<Stack>
<Button variant="solid" colorScheme="red" onClick={() => replaceLocalWithCloud()}>
{t('Replace local with cloud')}
</Button>
<Button variant="solid" colorScheme="primary" onClick={() => downloadCloud()}>
{t('Download Cloud save')}
</Button>
</Stack>
</CardFooter>
</Card>
</Stack>
</ModalBody>

<ModalFooter />
</ModalContent>
</Modal>
);
};

export default ResolveConflictModal;
Loading

0 comments on commit 6b30577

Please sign in to comment.