diff --git a/src/app.ts b/src/app.ts index 77e4cf7c..5e6cc0bc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import { getTransactions, getAccounts, getCurrentLocation, + getCurrentTabStatus, } from "./redux/selectors"; import { navigateToTabs, @@ -38,6 +39,9 @@ function mapStateToProps(state: AllState) { currentLocation: getCurrentLocation(state), initialLoadingDone: state.app.initialLoadingDone, tabInfo: getTabInfo(state), + currentTabLastSyncedSucessfully: + getCurrentTabStatus(state)?.lastSyncedSuccessfully, + currentTabSyncError: getCurrentTabStatus(state)?.syncError, transaction: state.app.docsById[ state.location.payload.transactionId ] as Transaction, diff --git a/src/components/__snapshots__/main.test.tsx.snap b/src/components/__snapshots__/main.test.tsx.snap index be372e87..36cd34de 100644 --- a/src/components/__snapshots__/main.test.tsx.snap +++ b/src/components/__snapshots__/main.test.tsx.snap @@ -5,28 +5,64 @@ exports[`renders empty view with no tab selected 1`] = ` className="scene mainScene" >
- -

- + + + + +

+
+
+
+ + + + + + + ❌ + +
+
+ Not synced, yet +
+
+
+ +

- -

- + + + + +

+
+
+
+ + + + + + + ❌ + +
+
+ Not synced, yet +
+
+
+ +

- -

- My Tab -

- + + + + +

+ My Tab +

+
+
+
+ + + + + + + ❌ + +
+
+ Not synced, yet +
+
+
+ +
- -

- My Tab -

- + + + + +

+ My Tab +

+
+
+
+ + + + + + + ❌ + +
+
+ Not synced, yet +
+
+
+ +
{ />
= (props) => { }; const renderHeader = (showAddButton?: boolean) => ( -
- -

{props.tabInfo?.name || ""}

- {showAddButton && ( - - )} +

{props.tabInfo?.name || ""}

+
+ +
+ {showAddButton && ( + + )} +
); diff --git a/src/components/syncstatus.test.tsx b/src/components/syncstatus.test.tsx new file mode 100644 index 00000000..c52be04d --- /dev/null +++ b/src/components/syncstatus.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import SyncStatus from "./syncstatus"; + +let realNavigator: typeof window.navigator; + +beforeEach(() => { + realNavigator = navigator; + // eslint-disable-next-line no-global-assign + navigator = { ...navigator }; +}); + +afterEach(() => { + window.navigator = realNavigator; +}); + +function setOnLine(value: boolean) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.navigator.onLine = value; +} + +it("renders initial offline status", () => { + setOnLine(false); + render(); + expect(screen.queryByText(/❌/i)).toBeInTheDocument(); + expect(screen.queryByText(/not synced, yet/i)).toBeInTheDocument(); +}); + +it("displays error", () => { + render(); + expect(screen.queryByText("⚠️")).toBeInTheDocument(); +}); + +it("displays no error if recently synced", () => { + const recentDateString = new Date(new Date().getTime() - 8000).toISOString(); + render(); + expect(screen.queryByText("⚠️")).not.toBeInTheDocument(); +}); + +it("renders recently synced", () => { + const recentDateString = new Date(new Date().getTime() - 5000).toISOString(); + render(); + expect(screen.queryByText("✅")).toBeInTheDocument(); + expect(screen.queryByText(/last synced:/i)).toBeInTheDocument(); +}); + +it("renders last synced timestamp if syncing was a while back", () => { + const longAgoDateString = new Date( + new Date().getTime() - 555000, + ).toISOString(); + render(); + expect(screen.queryByText(/last synced:/i)).toBeInTheDocument(); +}); diff --git a/src/components/syncstatus.tsx b/src/components/syncstatus.tsx new file mode 100644 index 00000000..d1535cb9 --- /dev/null +++ b/src/components/syncstatus.tsx @@ -0,0 +1,67 @@ +import { FunctionComponent, memo } from "react"; +import { useRerenderInterval } from "../hooks/rerender"; +import { useNavigatorOnLine } from "../hooks/onlinestatus"; + +interface Props { + lastSyncedSuccessfully?: string | null; + syncError?: boolean; +} + +enum LastSyncedStatus { + NEVER, + RECENTLY, + PAST, +} + +const SyncStatus: FunctionComponent = (props) => { + useRerenderInterval(5000); + const isOnline = useNavigatorOnLine(); + + let syncStatus: LastSyncedStatus = LastSyncedStatus.NEVER; + if (props.lastSyncedSuccessfully) { + if ( + new Date().getTime() - new Date(props.lastSyncedSuccessfully).getTime() < + 17000 + ) { + syncStatus = LastSyncedStatus.RECENTLY; + } else { + syncStatus = LastSyncedStatus.PAST; + } + } + + return ( +
+
+ + + + + + + {(() => { + if (isOnline) { + if (syncStatus === LastSyncedStatus.RECENTLY) { + return "✅"; + } else if (props.syncError) { + return "⚠️"; + } + } + return "❌"; + })()} + +
+
+ {(() => { + if (syncStatus === LastSyncedStatus.NEVER) { + return "Not synced, yet"; + } + return `Last synced: ${new Date( + props.lastSyncedSuccessfully!, + ).toLocaleString()}`; + })()} +
+
+ ); +}; + +export default memo(SyncStatus); diff --git a/src/db/manager.ts b/src/db/manager.ts index d089dafe..ac1ed27a 100644 --- a/src/db/manager.ts +++ b/src/db/manager.ts @@ -12,13 +12,21 @@ export default class DbManager { private readonly dbs: { [dbName: string]: Tab }; private onChanges?: changesCallback; + private onSyncSuccess?: (tabId: string) => void; + private onSyncEror?: (tabId: string) => void; constructor() { this.dbs = {}; } - async init(callback: changesCallback): Promise { - this.onChanges = callback; + async init( + onChanges: changesCallback, + onSyncSuccess?: (tabId: string) => void, + onSyncError?: (tabId: string) => void, + ): Promise { + this.onChanges = onChanges; + this.onSyncSuccess = onSyncSuccess; + this.onSyncEror = onSyncError; await this.checkIndexedDb(); this.initDbs(); @@ -150,6 +158,8 @@ export default class DbManager { dbName, remoteDbLocation, this.handleChanges.bind(this, tabId), + this.onSyncSuccess?.bind(this, tabId), + this.onSyncEror?.bind(this, tabId), this.isIndexedDbAvailable === false ? "memory" : undefined, ); this.dbs[tabId] = tab; diff --git a/src/db/tab.ts b/src/db/tab.ts index 39cba780..9ae6f626 100644 --- a/src/db/tab.ts +++ b/src/db/tab.ts @@ -10,9 +10,6 @@ interface Content { } export type Document = PouchDB.Core.Document; type Database = PouchDB.Database; -type ChangesHandler = ( - results: PouchDB.Core.ChangesResponseChange[], -) => void; export default class Tab { private readonly db: Database; @@ -30,7 +27,11 @@ export default class Tab { constructor( private readonly localDbName: string, private readonly remoteDbLocation: string, - private readonly onChanges: ChangesHandler, + private readonly onChanges: ( + results: PouchDB.Core.ChangesResponseChange[], + ) => void, + private readonly onSyncSuccess?: () => void, + private readonly onSyncError?: () => void, adapter?: string, ) { const myLog = log.extend(this.localDbName); @@ -111,6 +112,7 @@ export default class Tab { this.logReplication("complete"); clearTimeout(timeoutHandle); resolve(); + this.onSyncSuccess?.(); }) .on("error", (err) => { this.logReplication("error", err); @@ -118,6 +120,7 @@ export default class Tab { // incomplete replication can be handled by next sync clearTimeout(timeoutHandle); resolve(); + this.onSyncError?.(); }); let timeoutHandle: ReturnType; if (initialReplicationTimeout !== undefined) { @@ -125,6 +128,7 @@ export default class Tab { replication.cancel(); this.logReplication("canceled (timeout)"); resolve(); + this.onSyncError?.(); }, initialReplicationTimeout); } }); @@ -160,11 +164,13 @@ export default class Tab { this.logSync("error", err); this.isSyncing = false; this.emitChanges(); + this.onSyncError?.(); }) .on("complete", () => { this.logSync("complete"); this.isSyncing = false; this.emitChanges(); + this.onSyncSuccess?.(); }); } diff --git a/src/hooks/onlinestatus.ts b/src/hooks/onlinestatus.ts new file mode 100644 index 00000000..ebc7c17b --- /dev/null +++ b/src/hooks/onlinestatus.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; + +const getOnLineStatus = () => + typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean' + ? navigator.onLine + : true; + +export const useNavigatorOnLine = () => { + const [status, setStatus] = useState(getOnLineStatus()); + + const setOnline = () => setStatus(true); + const setOffline = () => setStatus(false); + + useEffect(() => { + window.addEventListener('online', setOnline); + window.addEventListener('offline', setOffline); + + return () => { + window.removeEventListener('online', setOnline); + window.removeEventListener('offline', setOffline); + }; + }, []); + + return status; +}; diff --git a/src/hooks/rerender.ts b/src/hooks/rerender.ts new file mode 100644 index 00000000..60191d3f --- /dev/null +++ b/src/hooks/rerender.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from "react"; + +export function useRerenderInterval(intervalMs: number) { + const [, setMockState] = useState(true); + + useEffect(() => { + const intervalHandle = setInterval( + () => setMockState((mockState) => !mockState), + intervalMs, + ); + return () => clearInterval(intervalHandle); + }, [intervalMs]); +} diff --git a/src/images/cloud.svg b/src/images/cloud.svg new file mode 100644 index 00000000..3cfd037e --- /dev/null +++ b/src/images/cloud.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/index.css b/src/index.css index ce325dfb..4dc433e3 100644 --- a/src/index.css +++ b/src/index.css @@ -19,6 +19,8 @@ --text-input-background-color: #fff; --participant-status-none-color: #888; --list-item-divider-color: #e5e5e5; + --font-size-small: 12px; + --default-transition-time: 400ms; } @media (prefers-color-scheme: dark) { :root { @@ -158,10 +160,7 @@ button.create svg path { } .header button.create { - position: absolute; - right: 0; - top: 0; - margin: 5px; + margin: 5px 5px 5px 10px; box-shadow: 0 0 10px rgba(100, 100, 100, 0.5); border-radius: 100px; /* Pill-style rounded button */ } @@ -170,18 +169,6 @@ button.create svg path { font-size: 30px; } -.header-app button.left { - background: transparent; - padding: 16px 10px; -} -.header-app button.left path { - fill: var(--subtle-text-color); -} -.header-app button.left:hover path, -.header-app button.left:focus path { - fill: var(--main-text-color); -} - .editEntryScene .button-row { margin-top: 2em; text-align: center; @@ -237,7 +224,7 @@ input[type="number"] { height: 100%; display: flex; width: 300%; - transition: transform 400ms; + transition: transform var(--default-transition-time); } .scenes-container-active-2 { transform: translate3d(-33.33%, 0, 0); @@ -261,12 +248,18 @@ input[type="number"] { } } +/* allow ellipsis in heaading */ +.header-container { + display: table; + table-layout: fixed; + width: 100%; +} .header { position: relative; } .header::after { opacity: 0; - transition: opacity 400ms; + transition: opacity var(--default-transition-time); content: ""; position: absolute; left: 0; @@ -293,33 +286,38 @@ input[type="number"] { opacity: 1; } .header-app { + display: flex; height: 56px; - position: relative; } -.header-app h2 { - position: absolute; +.header-brand { + height: initial; + padding: 2em 0; +} +.header-app > h2 { + flex: 10; + text-align: center; font-size: 22px; - left: 50%; - transform: translateX(-50%); + font-weight: normal; padding: 14px 0; - top: 0; margin: 0; - max-width: 66%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.header-app .logo { - width: 44px; - position: absolute; - left: 50%; - transform: translateX(-200%); - border-radius: 50px; - padding: 6px; +.header-app button.left { + background: transparent; + padding: 16px 10px; } -.header-brand { - height: initial; - padding: 2em 0; +.header-app button.left path { + fill: var(--subtle-text-color); +} +.header-app button.left:hover path, +.header-app button.left:focus path { + fill: var(--main-text-color); +} +.header-app .header-slot-sync-status { + flex: 1; + padding: 20px 0 12px 10px; } .content { @@ -611,7 +609,7 @@ input[type="number"] { } #transactions .date { padding: 0 12px 6px; - font-size: 12px; + font-size: var(--font-size-small); color: var(--subtle-text-color); } #transactions .list-item-with-arrow { @@ -631,7 +629,7 @@ input[type="number"] { } #transactions .transaction .payments { margin-top: 2px; - font-size: 12px; + font-size: var(--font-size-small); } .total-sum { @@ -673,7 +671,7 @@ input[type="number"] { left: 0; width: 100%; background: var(--main-background-color); - transition: opacity 0.5s; + transition: opacity var(--default-transition-time); } .app-loader.hidden { opacity: 0; @@ -710,3 +708,41 @@ input[type="number"] { .load-error button { margin-top: 2em; } + +.sync-status { + position: relative; + color: var(--subtle-text-color); + width: 26px; +} +.sync-status .icon { + position: relative; +} +.sync-status .icon .cloud { + display: block; +} +.sync-status .icon path { + fill: var(--main-text-color); +} +.sync-status .icon .status { + font-size: 11px; + user-select: none; + position: absolute; + top: -4px; + right: 0; +} +.sync-status .tooltip { + position: absolute; + bottom: -27px; + right: -6px; + z-index: 5; /* above header shadow */ + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; + background: var(--text-input-background-color); + font-size: var(--font-size-small); + opacity: 0; + transition: opacity var(--default-transition-time); +} +.sync-status .icon:hover + .tooltip { + opacity: 1; +} diff --git a/src/redux/actioncreators.ts b/src/redux/actioncreators.ts index 14e6283c..288d010b 100644 --- a/src/redux/actioncreators.ts +++ b/src/redux/actioncreators.ts @@ -24,6 +24,8 @@ export const IMPORT_TAB_SUCCESS = "IMPORT_TAB_SUCCESS"; export const UPDATE_FROM_DB = "UPDATE_FROM_DB"; export const CREATE_OR_UPDATE_TRANSACTION = "CREATE_OR_UPDATE_TRANSACTION"; export const REMOVE_TRANSACTION = "REMOVE_TRANSACTION"; +export const SET_TAB_SYNCED_SUCCESSFULLY = "SET_TAB_SYNCED_SUCCESSFULLY"; +export const SET_TAB_SYNC_ERROR = "SET_TAB_SYNC_ERROR"; export const SET_CREATE_TAB_INPUT_VALUE = "SET_CREATE_TAB_INPUT_VALUE"; export const RESET_CREATE_TAB_INPUT_VALUE = "RESET_CREATE_TAB_INPUT_VALUE"; export const SET_IMPORT_TAB_INPUT_VALUE = "SET_IMPORT_TAB_INPUT_VALUE"; @@ -132,6 +134,31 @@ const createRemoveTransactionAction = ( doc, }); +interface SetTabSyncedSuccessfullyAction { + type: typeof SET_TAB_SYNCED_SUCCESSFULLY; + tabId: string; + timestamp: string; +} + +export const setTabSyncedSuccessfully = ( + tabId: string, + date: Date, +): SetTabSyncedSuccessfullyAction => ({ + type: SET_TAB_SYNCED_SUCCESSFULLY, + tabId, + timestamp: date.toISOString(), +}); + +interface SetTabSyncErrorAction { + type: typeof SET_TAB_SYNC_ERROR; + tabId: string; +} + +export const setTabSyncError = (tabId: string): SetTabSyncErrorAction => ({ + type: SET_TAB_SYNC_ERROR, + tabId, +}); + interface SetCreateTabInputValueAction { type: typeof SET_CREATE_TAB_INPUT_VALUE; value: string; @@ -356,6 +383,8 @@ export type GTAction = | UpdateFromDbAction | CreateOrUpdateTransactionAction | RemoveTransactionAction + | SetTabSyncedSuccessfullyAction + | SetTabSyncErrorAction | SetCreateTabInputValueAction | ResetCreateTabInputValueAction | SetImportTabInputValueAction @@ -442,9 +471,13 @@ export const ensureConnectedDb = return; } - await dbManager.init((actionMap) => { - dispatch(createUpdateFromDbAction(actionMap)); - }); + await dbManager.init( + (actionMap) => { + dispatch(createUpdateFromDbAction(actionMap)); + }, + (tabId: string) => dispatch(setTabSyncedSuccessfully(tabId, new Date())), + (tabId: string) => dispatch(setTabSyncError(tabId)), + ); await dbManager.connect(); }; diff --git a/src/redux/reducer.test.ts b/src/redux/reducer.test.ts index 53cf93cc..042edca7 100644 --- a/src/redux/reducer.test.ts +++ b/src/redux/reducer.test.ts @@ -5,6 +5,10 @@ import { addParticipantToTransactionSharedForm, SET_ALL_JOINED_ON_TRANSACTION_SHARED_FORM, setAllJoinedOnTransactionSharedForm, + SET_TAB_SYNCED_SUCCESSFULLY, + setTabSyncedSuccessfully, + SET_TAB_SYNC_ERROR, + setTabSyncError, } from "./actioncreators"; import { AllState } from "../main"; import { @@ -14,69 +18,116 @@ import { } from "../types"; import reducer from "./reducer"; -function createState(): Partial { - return { - transactionForm: { - transactionType: TransactionType.SHARED, - date: "2020-02-20", - direct: { - options: [], - }, - shared: [ - { - id: "p2", - inputType: InputType.EXISTING, - participant: "Jan", - status: Status.JOINED, +describe("Sync status", () => { + describe(`${SET_TAB_SYNCED_SUCCESSFULLY}`, () => { + it("sets synced state initially", () => { + const state: Partial = { + tabStatus: {}, + }; + const date = new Date(); + const action = setTabSyncedSuccessfully("tab1", date); + const result = reducer(state as any, action); + + expect(result.tabStatus).toEqual({ + tab1: { + lastSyncedSuccessfully: date.toISOString(), + syncError: false, + }, + } as Partial); + }); + + it("updates timestamp of one of multiple tabs", () => { + const state: Partial = { + tabStatus: { + tab1: { + lastSyncedSuccessfully: "x", + syncError: false, + }, + tab2: { + lastSyncedSuccessfully: "y", + syncError: false, + }, + }, + }; + const date = new Date(); + const action = setTabSyncedSuccessfully("tab2", date); + const result = reducer(state as any, action); + + expect(result.tabStatus).toEqual({ + tab1: { + lastSyncedSuccessfully: "x", + syncError: false, }, - { - id: "p1", - inputType: InputType.EXISTING, - participant: "Martin", - status: Status.JOINED, + tab2: { + lastSyncedSuccessfully: date.toISOString(), + syncError: false, }, - ], - }, - }; -} - -describe(`${UPDATE_TRANSACTION_PARTICIPANT}`, () => { - it("updates participant status", () => { - const state = createState(); - const action = updateTransactionParticipant("p1", "status", Status.PAID); - const result = reducer(state as any, action); - - const participants = result.transactionForm?.shared; - if (!participants) { - throw new Error(); - } - expect(participants[1].status).toBe(Status.PAID); - expect(participants).not.toBe(state.transactionForm?.shared); + } as Partial); + }); + + it("updates timestamp and resets sync error", () => { + const state: Partial = { + tabStatus: { + tab1: { + lastSyncedSuccessfully: "x", + syncError: true, + }, + }, + }; + const date = new Date(); + const action = setTabSyncedSuccessfully("tab1", date); + const result = reducer(state as any, action); + + expect(result.tabStatus).toEqual({ + tab1: { + lastSyncedSuccessfully: date.toISOString(), + syncError: false, + }, + } as Partial); + }); }); -}); -describe(`${ADD_PARTICIPANT_TO_TRANSACTION_SHARED_FORM}`, () => { - it("adds participant input to new transaction form", () => { - const state = createState(); - const action = addParticipantToTransactionSharedForm(); - const result = reducer(state as any, action); - - const participants = result.transactionForm?.shared; - if (!participants) { - throw new Error(); - } - expect(participants.length).toBe(3); - expect(participants[2].inputType).toBe(InputType.NEW); - expect(participants[2].participant).toBeUndefined(); - expect(participants[2].status).toBe(Status.JOINED); - expect(participants[2].amount).toBeUndefined(); - expect(participants).not.toBe(state.transactionForm?.shared); + describe(`${SET_TAB_SYNC_ERROR}`, () => { + it("sets sync error initially", () => { + const state: Partial = { + tabStatus: {}, + }; + const action = setTabSyncError("tab1"); + const result = reducer(state as any, action); + + expect(result.tabStatus).toEqual({ + tab1: { + lastSyncedSuccessfully: null, + syncError: true, + }, + } as Partial); + }); + + it("sets sync error on previously synced tab", () => { + const state: Partial = { + tabStatus: { + tab1: { + lastSyncedSuccessfully: "x", + syncError: false, + }, + }, + }; + const action = setTabSyncError("tab1"); + const result = reducer(state as any, action); + + expect(result.tabStatus).toEqual({ + tab1: { + lastSyncedSuccessfully: "x", + syncError: true, + }, + } as Partial); + }); }); }); -describe(`${SET_ALL_JOINED_ON_TRANSACTION_SHARED_FORM}`, () => { - it("sets all participant inputs of status NONE to JOINED", () => { - const state: Partial = { +describe("Transaction form", () => { + function createState(): Partial { + return { transactionForm: { transactionType: TransactionType.SHARED, date: "2020-02-20", @@ -94,41 +145,103 @@ describe(`${SET_ALL_JOINED_ON_TRANSACTION_SHARED_FORM}`, () => { id: "p1", inputType: InputType.EXISTING, participant: "Martin", - status: Status.NONE, - }, - { - id: "p3", - inputType: InputType.EXISTING, - participant: "Tilman", - status: Status.PAID, - amount: 12, - }, - { - id: "p8", - inputType: InputType.EXISTING, - participant: "Koos", - status: Status.NONE, - }, - { - id: "p4", - inputType: InputType.NEW, status: Status.JOINED, }, ], }, }; - const action = setAllJoinedOnTransactionSharedForm(); - const result = reducer(state as any, action); + } + + describe(`${UPDATE_TRANSACTION_PARTICIPANT}`, () => { + it("updates participant status", () => { + const state = createState(); + const action = updateTransactionParticipant("p1", "status", Status.PAID); + const result = reducer(state as any, action); + + const participants = result.transactionForm?.shared; + if (!participants) { + throw new Error(); + } + expect(participants[1].status).toBe(Status.PAID); + expect(participants).not.toBe(state.transactionForm?.shared); + }); + }); + + describe(`${ADD_PARTICIPANT_TO_TRANSACTION_SHARED_FORM}`, () => { + it("adds participant input to new transaction form", () => { + const state = createState(); + const action = addParticipantToTransactionSharedForm(); + const result = reducer(state as any, action); + + const participants = result.transactionForm?.shared; + if (!participants) { + throw new Error(); + } + expect(participants.length).toBe(3); + expect(participants[2].inputType).toBe(InputType.NEW); + expect(participants[2].participant).toBeUndefined(); + expect(participants[2].status).toBe(Status.JOINED); + expect(participants[2].amount).toBeUndefined(); + expect(participants).not.toBe(state.transactionForm?.shared); + }); + }); + + describe(`${SET_ALL_JOINED_ON_TRANSACTION_SHARED_FORM}`, () => { + it("sets all participant inputs of status NONE to JOINED", () => { + const state: Partial = { + transactionForm: { + transactionType: TransactionType.SHARED, + date: "2020-02-20", + direct: { + options: [], + }, + shared: [ + { + id: "p2", + inputType: InputType.EXISTING, + participant: "Jan", + status: Status.JOINED, + }, + { + id: "p1", + inputType: InputType.EXISTING, + participant: "Martin", + status: Status.NONE, + }, + { + id: "p3", + inputType: InputType.EXISTING, + participant: "Tilman", + status: Status.PAID, + amount: 12, + }, + { + id: "p8", + inputType: InputType.EXISTING, + participant: "Koos", + status: Status.NONE, + }, + { + id: "p4", + inputType: InputType.NEW, + status: Status.JOINED, + }, + ], + }, + }; + const action = setAllJoinedOnTransactionSharedForm(); + const result = reducer(state as any, action); - const participants = result.transactionForm?.shared; - if (!participants) { - throw new Error(); - } - expect(participants.length).toBe(5); + const participants = result.transactionForm?.shared; + if (!participants) { + throw new Error(); + } + expect(participants.length).toBe(5); - const statuss = participants.map((participant) => participant.status); - expect(new Set(statuss)).toEqual(new Set([Status.PAID, Status.JOINED])); + const statuss = participants.map((participant) => participant.status); + expect(new Set(statuss)).toEqual(new Set([Status.PAID, Status.JOINED])); - expect(participants).not.toBe(state.transactionForm?.shared); + expect(participants).not.toBe(state.transactionForm?.shared); + }); }); }); diff --git a/src/redux/reducer.ts b/src/redux/reducer.ts index 5535e8d1..6adf3807 100644 --- a/src/redux/reducer.ts +++ b/src/redux/reducer.ts @@ -16,6 +16,8 @@ import { IMPORT_TAB, CREATE_OR_UPDATE_TRANSACTION, REMOVE_TRANSACTION, + SET_TAB_SYNCED_SUCCESSFULLY, + SET_TAB_SYNC_ERROR, SET_TRANSACTION_FORM, RESET_TRANSACTION_FORM, SET_ERROR, @@ -41,6 +43,12 @@ interface AppState { importingTab: boolean; docsById: { [id: string]: Entity }; tabs: string[]; + tabStatus: { + [tabId: string]: { + lastSyncedSuccessfully: string | null; + syncError: boolean; + }; + }; transactionsByTab: { [tabId: string]: string[] }; createTabInput?: string; importTabInput?: string; @@ -55,6 +63,7 @@ const initialState: AppState = { importingTab: false, docsById: {}, tabs: [], + tabStatus: {}, transactionsByTab: {}, error: null, }; @@ -181,6 +190,33 @@ const reducer: Reducer = (state = initialState, action) => { }), }; + case SET_TAB_SYNCED_SUCCESSFULLY: + return { + ...state, + tabStatus: { + ...state.tabStatus, + [action.tabId]: { + ...state.tabStatus[action.tabId], + lastSyncedSuccessfully: action.timestamp, + syncError: false, + }, + }, + }; + + case SET_TAB_SYNC_ERROR: + return { + ...state, + tabStatus: { + ...state.tabStatus, + [action.tabId]: { + ...state.tabStatus[action.tabId], + lastSyncedSuccessfully: + state.tabStatus[action.tabId]?.lastSyncedSuccessfully || null, + syncError: true, + }, + }, + }; + case SET_CREATE_TAB_INPUT_VALUE: return { ...state, diff --git a/src/redux/selectors.ts b/src/redux/selectors.ts index 72a58a01..af3b1b29 100644 --- a/src/redux/selectors.ts +++ b/src/redux/selectors.ts @@ -99,13 +99,18 @@ function getDocsById(state: AllState) { } export function getCurrentTabId(state: AllState) { - return state.location.payload.tabId || state.location.prev?.payload.tabId; + return (state.location.payload.tabId || + state.location.prev?.payload.tabId) as string | undefined; } function getTransactionsByTab(state: AllState) { return state.app.transactionsByTab; } +function getTabStatus(state: AllState) { + return state.app.tabStatus; +} + function getRouteTransition(state: AllState) { return state.routeTransition.transition; } @@ -173,7 +178,7 @@ export const getTabInfo = createSelector( export const getTransactions = createSelector( [getDocsById, getCurrentTabId, getTransactionsByTab], (docsById, currentTab, transactionsByTab) => { - const transactionIds = transactionsByTab[currentTab] || []; + const transactionIds = (currentTab && transactionsByTab[currentTab]) || []; const transactions = transactionIds.map((transactionId) => { const transaction = docsById[transactionId] as Transaction; return mapTransaction(transaction); @@ -199,3 +204,8 @@ export const getTotal = createSelector([getTransactions], (transactions) => return total + transactionSum; }, 0), ); + +export const getCurrentTabStatus = createSelector( + [getTabStatus, getCurrentTabId], + (tabStatus, currentTab) => (currentTab ? tabStatus[currentTab] : undefined), +);