# Offline Mode Support
## Assessment
Using a Worker only strategy is in-complete as it has the possibility of causing data inconsistencies, and lacks our ability to have fine grain control of the data flow. For a full offline mode that works well and has a great UX we will need to augment our strategy with the following technologies:
- Redux (Data controller)
- IndexDB (Local Storage)
- Data colission management (Local vs Cloud)
- Data integrity (current data is not well structured in the database)
Through various iterations this project has accumalted a termendous amount of technical debt, which is overdue to be fixed.
Many aspects of the code need a rewrite for developer experience, maintainabilty and readability. Without addressing these issues the addition of new features will take much longer and there is higher risk of regresssion and new bugs.
The reccomended approach is to use a [Strangler Fig Pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/strangler-fig) to migrate the application to a more maintainable code set.
With the addition of Redux, refactoring the app to follow a particular data flow (as outlined in the architecture section), we are more aligned with industry best practices. See [Slack](https://slack.engineering/service-workers-at-slack-our-quest-for-faster-boot-times-and-offline-support/)
## App States
There are numerous UX decisions needed around how to handle the various app states offline mode introduces. Outlining them below:
| Network | Cached | Auth Expired | UX Updates |
|-----------|-----------------|----------------|--------------|
| Online | First Time Visit| Not Applicable | Ensure the app is "installed/available" for subsequent visits. Server worker caches on startup. |
| Online | Subsequent Visit| Token Valid | |
| Online | Subsequent Visit| Token Expired | |
| Offline | Cached | Token Valid | |
| Offline | Cached | Token Expired | Current Authentication renders this state unusable. This requires a different strategy of what parts of the app require authentication and which don't. It was suggested to allow the user to submit forms but not see previous data. However, the data will still persist on the unauthenticated users device, this may be security concern. User experience and Product decision is needed. |
| Offline | Non Cached | N/A | This state is technically impossible. Without the user having originally "installed" or "visited" the website there is no way to render the website. |
Please not Offline, Non Cached is an impossible state. If the user does not first login then they will not be able to access the app!
UX Improvemant may be to introduce making the app [installable](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable)
## Proposed Architecture
```
User Action (e.g., Save Form)
|
v
Dispatch SAVE_FORM_BEGIN
|
v
Try Saving to API --------------> API Call Successful? ---> Yes ---> Dispatch SAVE_FORM_SUCCESS
| |
| v
| Update IndexedDB with Response
| |
No v
| Dispatch additional actions as needed (e.g., for UI update)
v
Queue Form for Sync
|
v
Dispatch QUEUE_FORM_FOR_SYNC
|
v
Register sync event with Service Worker ('syncForms')
|
(Service Worker)
|
v
Background Sync Event
|
v
Retry API Call with Valid Token
|
v
API Call Successful? ----------> Yes ---> Update IndexedDB with New Data
| |
No v
| Dispatch additional actions based on result
v
Handle Failure (e.g., Re-queue or Alert User)
```
- Redux: main logic resides here, the apps controller.
- IndexedDB: acts as a local store, cache.
- Backend: source of truth.
- Service Worker: execution of queue for sending data, and caching website for later use.
# Technical Details
Below are some blueprints for fixes and approaches to implementing the above architecture.
```js
// Redux actions for handling forms
// Action Types
const SAVE_FORM_BEGIN = "SAVE_FORM_BEGIN";
const SAVE_FORM_SUCCESS = "SAVE_FORM_SUCCESS";
const SAVE_FORM_FAILURE = "SAVE_FORM_FAILURE";
const QUEUE_FORM_FOR_SYNC = "QUEUE_FORM_FOR_SYNC";
import { saveFormDataToIndexedDB, queueFormForSync } from './indexedDBHelpers';
export const saveForm = (formData) => async (dispatch) => {
dispatch({ type: SAVE_FORM_BEGIN });
try {
// Try saving the form data to the backend API
const response = await makeApiCallToSaveForm(formData);
dispatch({ type: SAVE_FORM_SUCCESS, payload: response.data });
// After successful API call, update IndexedDB as well for consistency
saveFormDataToIndexedDB(formData).catch(err => console.error("Saving to IndexedDB failed", err));
} catch (error) {
console.error("Saving form failed", error);
// If the API call fails, queue the form for later synchronization
queueFormForSync(formData).then(() => {
dispatch({ type: QUEUE_FORM_FOR_SYNC, payload: formData });
// Register a sync event with the Service Worker to try resending later
if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready.then(registration => {
registration.sync.register('syncForms').catch(err => console.error('Error registering sync event:', err));
});
}
}).catch(err => console.error("Queuing form for sync failed", err));
}
};
```
## Token Management
```js
import { db } from './indexedDBUtil';
// Fetch token from IndexedDB
const getToken = async () => {
const token = await db.tokens.get('authToken');
return token;
};
// Refresh token API call
const refreshToken = async (refreshToken) => {
return fetch('/api/token/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
}).then(response => response.json());
};
// Check token validity and refresh if necessary
export const ensureValidToken = async () => {
const { authToken, refreshToken, expiry } = await getToken();
if (new Date() >= new Date(expiry)) {
const refreshedToken = await refreshToken(refreshToken);
// Save the new token details to IndexedDB
await db.tokens.put({ id: 'authToken', ...refreshedToken });
return refreshedToken.accessToken;
}
return authToken;
};
```
## Redux State
```js
const initialState = {
forms: {
byId: {
'form1': {
id: 'form1',
formType: 'MV1234',
leaseExpiry: new Date(),
userGuid: 'user1',
agencyId: 'agency1',
data: {
// Form specific data like questions and answers
}
},
// Other forms...
},
allIds: ['form1'], // List of all form IDs for easy iteration
},
users: {
byId: {
'user1': {
guid: 'user1',
name: 'John Doe',
// other user properties
},
// Other users...
},
allIds: ['user1']
},
agencies: {
byId: {
'agency1': {
id: 'agency1',
name: 'Agency Name',
// other agency properties
},
// Other agencies...
},
allIds: ['agency1']
},
};
```
## Service worker
```js
self.addEventListener('sync', (event) => {
if (event.tag === 'syncForms') {
// Logic to handle form synchronization
event.waitUntil(
// Function to get all queued forms from IndexedDB and sync them
syncAllQueuedForms()
);
}
});
async function syncAllQueuedForms() {
const queuedForms = await getQueuedFormsFromIndexedDB(); // Fetch queued forms
for (const formData of queuedForms) {
try {
// Attempt to send each form to the backend API
await sendFormDataToAPI(formData);
// On successful sync, remove the form from the IndexedDB queue
await removeFormFromIndexedDBQueue(formData.id);
// Optionally, post a message back to the web app to update the UI or Redux store
self.clients.matchAll().then(all => all.forEach(client => {
client.postMessage({ type: 'FORM_SYNCED', payload: { formId: formData.id } });
}));
} catch (error) {
console.error('Sync failed for form', formData.id, error);
// Handle failures, e.g., by leaving the form in the queue for a future sync attempt
}
}
}
```
## Refactor Form Validation for Maintainability and Readability
```js
// Conditional schema for drivers_licence_jurisdiction
const jurisdictionConditionalSchema = Yup.object().shape({
drivers_licence_jurisdiction: Yup.object().when(['VI', 'TwentyFourHour'], {
is: (VI, TwentyFourHour) => VI || TwentyFourHour,
then: Yup.object()
.shape({
value: Yup.string().required('Jurisdiction is required'),
label: Yup.string().required('Jurisdiction is required'), // Assuming a select dropdown is used
})
.required('Jurisdiction is required'),
otherwise: Yup.object().shape({
value: Yup.string(),
label: Yup.string(),
}),
}),
});
// Base schema for the driver's information section
const driverInformationSchema = Yup.object().shape({
driver_licence_no: Yup.string().required('Driver License No is required'),
driver_last_name: Yup.string().required('Last Name is required'),
driver_given_name: Yup.string().notRequired(),
driver_dob: Yup.string()
.nullable()
.required('Date of Birth is required'),
driver_address: Yup.string().notRequired(),
driver_phone: Yup.string()
.notRequired()
.matches(/^$|^\d{3}-\d{3}-\d{4}$/, 'Phone Number format ###-###-####'),
driver_city: Yup.string().notRequired(),
driver_prov_state: Yup.object().shape({
value: Yup.string().required('Province/State is required'),
label: Yup.string().required(),
}),
driver_postal: Yup.string().notRequired(),
});
// Combine the standalone schemas into the main validation schema
export const validationSchema = Yup.object().shape({
...driverInformationSchema.fields,
...jurisdictionConditionalSchema.fields,
// Include other conditional schemas or fields as necessary
});
```