pending changes indication for device and component changes
This commit is contained in:
parent
10f0aa46cc
commit
71fb67bba4
13 changed files with 259 additions and 135 deletions
46
src/common/PendingChangesChip.tsx
Normal file
46
src/common/PendingChangesChip.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Chip, CircularProgress, Tooltip } from "@mui/material";
|
||||
import { CheckCircleOutline } from "@mui/icons-material";
|
||||
|
||||
interface Props {
|
||||
pending: boolean;
|
||||
accepted: boolean;
|
||||
size?: "small" | "medium";
|
||||
}
|
||||
|
||||
/**
|
||||
* Capsule tag shown alongside the other status chips while changes are
|
||||
* waiting to be synced to a device or component. Shows a small loading
|
||||
* circle while pending and a checkmark once the change is accepted.
|
||||
* Derive the flags with usePendingChanges.
|
||||
*/
|
||||
export default function PendingChangesChip(props: Props) {
|
||||
const { pending, accepted, size } = props;
|
||||
|
||||
if (pending) {
|
||||
return (
|
||||
<Tooltip title="Your most recent changes will be synced to the device as soon as possible">
|
||||
<Chip
|
||||
variant="outlined"
|
||||
label="Pending changes"
|
||||
icon={<CircularProgress size={14} thickness={5} color="inherit" />}
|
||||
size={size}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (accepted) {
|
||||
return (
|
||||
<Tooltip title="The device has accepted your most recent changes">
|
||||
<Chip
|
||||
variant="outlined"
|
||||
label="Settings synced"
|
||||
icon={<CheckCircleOutline style={{ color: "var(--status-ok)" }} />}
|
||||
size={size}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
27
src/common/PendingChangesIndicator.tsx
Normal file
27
src/common/PendingChangesIndicator.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { CircularProgress, Grid2 as Grid } from "@mui/material";
|
||||
import { CheckCircleOutline } from "@mui/icons-material";
|
||||
|
||||
interface Props {
|
||||
pending: boolean;
|
||||
accepted: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Small inline status line used inside cards: a loading circle beside the
|
||||
* caption while changes are pending, and a checkmark once they are accepted.
|
||||
* Derive the flags with usePendingChanges (or per-item tracking like
|
||||
* InteractionsOverview does).
|
||||
*/
|
||||
export default function PendingChangesIndicator(props: Props) {
|
||||
const { pending, accepted, text } = props;
|
||||
if (!pending && !accepted) return null;
|
||||
|
||||
return (
|
||||
<Grid container spacing={0.5} alignItems="center">
|
||||
{pending && <CircularProgress size={10} thickness={5} color="inherit" />}
|
||||
{accepted && <CheckCircleOutline sx={{ fontSize: 13, color: "var(--status-ok)" }} />}
|
||||
<Grid>{text}</Grid>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { Chip, Tooltip } from "@mui/material";
|
||||
import { getStatusHelper } from "pbHelpers/Status";
|
||||
|
||||
interface Props {
|
||||
status: string;
|
||||
size?: "small" | "medium";
|
||||
}
|
||||
|
||||
export default function StatusChip(props: Props) {
|
||||
const { status, size } = props;
|
||||
const statusHelper = getStatusHelper(status);
|
||||
const icon = statusHelper.icon;
|
||||
const description = statusHelper.description;
|
||||
|
||||
return icon !== null ? (
|
||||
<Tooltip title="Your most recent changes will be synced to the device as soon as possible">
|
||||
<Chip variant="outlined" label={description} icon={icon} size={size} />
|
||||
</Tooltip>
|
||||
) : null;
|
||||
}
|
||||
|
|
@ -13,11 +13,13 @@ import {
|
|||
} from "@mui/material";
|
||||
import Grid from '@mui/material/Grid2';
|
||||
import EventBlocker from "common/EventBlocker";
|
||||
import PendingChangesIndicator from "common/PendingChangesIndicator";
|
||||
import MeasurementSummary from "component/MeasurementSummary";
|
||||
import { useComponentAPI, useSnackbar, useThemeType } from "hooks";
|
||||
import { useComponentAPI, usePendingChanges, useSnackbar, useThemeType } from "hooks";
|
||||
import InteractionsOverview from "interactions/InteractionsOverview";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Component, Device, Interaction } from "models";
|
||||
import moment from "moment";
|
||||
import { getFriendlyAddressTypeName, getHumanReadableAddress } from "pbHelpers/AddressType";
|
||||
import { controllerModeLabel } from "pbHelpers/Component";
|
||||
import {
|
||||
|
|
@ -121,6 +123,9 @@ export default function ComponentCard(props: Props) {
|
|||
const location = useLocation()
|
||||
const [sensors, setSensors] = useState<Sensor[]>([]);
|
||||
const [{ user, showErrors, as }] = useGlobalState();
|
||||
const { pending: pendingChanges, accepted: changesAccepted } = usePendingChanges(
|
||||
component.status.synced
|
||||
);
|
||||
|
||||
const updateControllerState = (checked: boolean) => {
|
||||
let updatedComponent = cloneDeep(component);
|
||||
|
|
@ -133,6 +138,8 @@ export default function ComponentCard(props: Props) {
|
|||
.update(device.id(), updatedComponent.settings, undefined, undefined, as)
|
||||
.then(() => {
|
||||
success(component.name() + "'s mode was set to " + describe);
|
||||
updatedComponent.status.synced = false;
|
||||
updatedComponent.status.lastUpdate = moment().toISOString();
|
||||
refreshCallback(updatedComponent);
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -287,19 +294,34 @@ export default function ComponentCard(props: Props) {
|
|||
className={classes.cardHeader}
|
||||
titleTypographyProps={{ variant: "subtitle1" }}
|
||||
subheader={
|
||||
// !newStructure ? (
|
||||
// <MeasurementSummary
|
||||
// component={component}
|
||||
// reading={component.status.lastMeasurement}
|
||||
// dense
|
||||
// />
|
||||
// ) : (
|
||||
<React.Fragment>
|
||||
{(pendingChanges || changesAccepted) && (
|
||||
<Typography variant="caption" color="textSecondary" component="div">
|
||||
<PendingChangesIndicator
|
||||
pending={pendingChanges}
|
||||
accepted={changesAccepted}
|
||||
text={
|
||||
changesAccepted
|
||||
? "Pending change accepted"
|
||||
: "Pending " + moment(component.status.lastUpdate).fromNow()
|
||||
}
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
{/* !newStructure ? (
|
||||
<MeasurementSummary
|
||||
component={component}
|
||||
reading={component.status.lastMeasurement}
|
||||
dense
|
||||
/>
|
||||
) : ( */}
|
||||
<UnitMeasurementSummary
|
||||
component={component}
|
||||
reading={UnitMeasurement.convertLastMeasurement(measurements)}
|
||||
//dense
|
||||
/>
|
||||
// )
|
||||
{/* ) */}
|
||||
</React.Fragment>
|
||||
}
|
||||
action={
|
||||
<EventBlocker>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/material";
|
||||
import { DataUsage, SimCard, Launch, DeviceHub } from "@mui/icons-material";
|
||||
import StatusChip from "common/StatusChip";
|
||||
import PendingChangesChip from "common/PendingChangesChip";
|
||||
import DeviceTags from "device/DeviceTags";
|
||||
import VersionChip from "device/VersionChip";
|
||||
import { useSnackbar, useWidth, usePrevious } from "hooks";
|
||||
import { useSnackbar, useWidth, usePendingChanges, usePrevious } from "hooks";
|
||||
import { Device, latestFirmwareVersion, Component } from "models";
|
||||
import moment from "moment";
|
||||
import { describeConnectivity } from "pbHelpers/Connectivity";
|
||||
|
|
@ -54,6 +54,10 @@ export default function DeviceOverview(props: Props) {
|
|||
// const match = useRouteMatch<MatchParams>();
|
||||
const [powerComponent, setPowerComponent] = useState<Component | undefined | null>();
|
||||
const [modemComponent, setModemComponent] = useState<Component | undefined | null>();
|
||||
const { pending: pendingChanges, accepted: changesAccepted } = usePendingChanges(
|
||||
device.status.synced,
|
||||
!loading && device.id() > 0
|
||||
);
|
||||
// const [hologramDialog, setHologramDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -228,9 +232,9 @@ export default function DeviceOverview(props: Props) {
|
|||
</Tooltip>
|
||||
</Grid>
|
||||
)}
|
||||
{!device.status.synced && (
|
||||
{(pendingChanges || changesAccepted) && (
|
||||
<Grid>
|
||||
<StatusChip status="pending" />
|
||||
<PendingChangesChip pending={pendingChanges} accepted={changesAccepted} />
|
||||
</Grid>
|
||||
)}
|
||||
{device.settings.needsBusControl && (
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export {
|
|||
export * from "./useDebounce";
|
||||
// export * from "./useForceUpdate";
|
||||
// export * from "./useInterval";
|
||||
export * from "./usePendingChanges";
|
||||
export * from "./usePrevious";
|
||||
export * from "./useThemeType";
|
||||
export * from "./useWidth";
|
||||
|
|
|
|||
31
src/hooks/usePendingChanges.ts
Normal file
31
src/hooks/usePendingChanges.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Tracks a synced flag and reports whether changes are pending or were
|
||||
* accepted while mounted (a false -> true transition, e.g. from a websocket
|
||||
* status update). Pass enabled=false until real data has loaded so the
|
||||
* initial placeholder -> loaded flip is not mistaken for an accepted change.
|
||||
*/
|
||||
export function usePendingChanges(synced: boolean, enabled: boolean = true) {
|
||||
const prevSynced = useRef<boolean | undefined>(undefined);
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
prevSynced.current = undefined;
|
||||
setAccepted(false);
|
||||
return;
|
||||
}
|
||||
if (prevSynced.current === false && synced) {
|
||||
setAccepted(true);
|
||||
} else if (!synced) {
|
||||
setAccepted(false);
|
||||
}
|
||||
prevSynced.current = synced;
|
||||
}, [synced, enabled]);
|
||||
|
||||
return {
|
||||
pending: enabled && !synced,
|
||||
accepted: enabled && synced && accepted
|
||||
};
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ import {
|
|||
CardActions,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CircularProgress,
|
||||
darken,
|
||||
Grid2 as Grid,
|
||||
IconButton,
|
||||
|
|
@ -15,7 +14,8 @@ import {
|
|||
Tooltip,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import { CheckCircleOutline, Settings } from "@mui/icons-material";
|
||||
import { Settings } from "@mui/icons-material";
|
||||
import PendingChangesIndicator from "common/PendingChangesIndicator";
|
||||
import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { Component, Device, Interaction } from "models";
|
||||
|
|
@ -329,17 +329,11 @@ export default function InteractionsOverview(props: Props) {
|
|||
title={interactionResultText(interaction, sink)}
|
||||
subheader={
|
||||
statusText ? (
|
||||
<Grid container spacing={0.5} alignItems="center">
|
||||
{isPending && <CircularProgress size={10} thickness={5} color="inherit" />}
|
||||
{isAccepted && (
|
||||
<CheckCircleOutline
|
||||
sx={{ fontSize: 13, color: "var(--status-ok)" }}
|
||||
/>
|
||||
)}
|
||||
<Grid>
|
||||
{statusText}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<PendingChangesIndicator
|
||||
pending={isPending}
|
||||
accepted={isAccepted}
|
||||
text={statusText}
|
||||
/>
|
||||
) : ""}
|
||||
subheaderTypographyProps={{ variant: "caption" }}
|
||||
action={action}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap }
|
|||
import ComponentCard from "component/ComponentCard";
|
||||
import { sameComponentID, sortComponents } from "pbHelpers/Component";
|
||||
import { isController } from "pbHelpers/ComponentType";
|
||||
import { or } from "utils";
|
||||
import { getTimestampMillis, isStaleStatusUpdate, or } from "utils";
|
||||
import ComponentDiagnostics from "component/ComponentDiagnostics";
|
||||
import DeviceWizard from "device/DeviceWizard";
|
||||
import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents";
|
||||
|
|
@ -27,12 +27,6 @@ import { useWebSocket } from "hooks/useWebSocket";
|
|||
|
||||
type TimestampedReading = { timestamp?: string };
|
||||
|
||||
function getTimestampMillis(timestamp?: string): number | undefined {
|
||||
if (!timestamp) return undefined;
|
||||
const parsed = Date.parse(timestamp);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
function pickNewestReading<T extends TimestampedReading>(
|
||||
current: T | null | undefined,
|
||||
incoming: T | null | undefined
|
||||
|
|
@ -270,6 +264,10 @@ export default function DevicePage() {
|
|||
if (!data) return;
|
||||
const { key, component } = data;
|
||||
setComponents((prev) => {
|
||||
const existing = prev.get(key);
|
||||
if (existing && isStaleStatusUpdate(existing.status, component.status)) {
|
||||
return prev;
|
||||
}
|
||||
const updated = new Map(prev);
|
||||
updated.set(key, component);
|
||||
return updated;
|
||||
|
|
@ -315,17 +313,7 @@ export default function DevicePage() {
|
|||
return [...prev, interaction];
|
||||
}
|
||||
const existing = prev[index];
|
||||
const existingLastUpdate = getTimestampMillis(existing.status.lastUpdate);
|
||||
const incomingLastUpdate = getTimestampMillis(interaction.status.lastUpdate);
|
||||
const incomingLastSynced = getTimestampMillis(interaction.status.lastSynced);
|
||||
if (existingLastUpdate !== undefined && incomingLastUpdate !== undefined &&
|
||||
existingLastUpdate > incomingLastUpdate) {
|
||||
return prev;
|
||||
}
|
||||
if (!existing.status.synced && interaction.status.synced &&
|
||||
incomingLastUpdate !== undefined &&
|
||||
existingLastUpdate === incomingLastUpdate &&
|
||||
(incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)) {
|
||||
if (isStaleStatusUpdate(existing.status, interaction.status)) {
|
||||
return prev;
|
||||
}
|
||||
const updated = [...prev];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { NextMeasurementChip } from "common/NextMeasurementChip";
|
|||
import ResponsiveTable, { Column } from "common/ResponsiveTable";
|
||||
// import { getTableIcons } from "common/ResponsiveTable";
|
||||
import SmartBreadcrumb from "common/SmartBreadcrumb";
|
||||
import StatusChip from "common/StatusChip";
|
||||
import PendingChangesChip from "common/PendingChangesChip";
|
||||
import { GetDefaultDateRange } from "common/time/DateRange";
|
||||
import ComponentActions from "component/ComponentActions";
|
||||
import ComponentChart from "component/ComponentChart";
|
||||
|
|
@ -16,11 +16,14 @@ import {
|
|||
useComponentAPI,
|
||||
useDeviceAPI,
|
||||
useGroupAPI,
|
||||
useHTTP,
|
||||
useInteractionsAPI,
|
||||
usePendingChanges,
|
||||
usePrevious,
|
||||
useSnackbar,
|
||||
useUserAPI
|
||||
} from "hooks";
|
||||
import { useWebSocket } from "hooks/useWebSocket";
|
||||
import InteractionChip from "interactions/InteractionChip";
|
||||
import InteractionSettings from "interactions/InteractionSettings";
|
||||
import { cloneDeep } from "lodash";
|
||||
|
|
@ -40,9 +43,10 @@ import { getDefaultInteraction } from "pbHelpers/Interaction";
|
|||
import { pond } from "protobuf-ts/pond";
|
||||
import { useGlobalState } from "providers";
|
||||
import { useTeamAPI } from "providers/pond/teamAPI";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
// import { useRouteMatch } from "react-router";
|
||||
import { isStaleStatusUpdate } from "utils/syncStatus";
|
||||
import { or } from "utils/types";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
|
|
@ -142,6 +146,55 @@ export default function DeviceComponent() {
|
|||
const [deviceComponentPrefs, setDeviceComponentPrefs] = useState<
|
||||
pond.DeviceComponentPreferences
|
||||
>();
|
||||
const { token } = useHTTP();
|
||||
const componentPending = usePendingChanges(
|
||||
component.status.synced,
|
||||
!loadingInitial && component.key() !== ""
|
||||
);
|
||||
|
||||
const liveContextKeys = useMemo(() => getContextKeys(), []);
|
||||
const liveContextTypes = useMemo(() => getContextTypes(), []);
|
||||
/** Matches the REST calls: omit ?as= when first context type is "team". */
|
||||
const liveAs =
|
||||
as && !(liveContextTypes.length > 0 && liveContextTypes[0] === "team")
|
||||
? as
|
||||
: undefined;
|
||||
|
||||
// --- Real-time component updates ---
|
||||
// Streams component changes for this device, including status changes
|
||||
// when the device accepts pending component updates.
|
||||
useWebSocket<{ key: string; component: Component } | null>({
|
||||
path: `/v1/live/devices/${deviceID}/components`,
|
||||
parse: (e) => {
|
||||
try {
|
||||
const raw = JSON.parse(e.data);
|
||||
const comp = Component.any(raw);
|
||||
if (!comp?.settings) {
|
||||
console.warn("[ws] received component without settings:", raw);
|
||||
return null;
|
||||
}
|
||||
return { key: comp.key(), component: comp };
|
||||
} catch (err) {
|
||||
console.warn("[ws] failed to parse component:", err);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
onMessage: (data) => {
|
||||
if (!data || data.key !== componentID) return;
|
||||
setComponent((prev) => {
|
||||
if (prev.key() !== data.key) return prev;
|
||||
if (isStaleStatusUpdate(prev.status, data.component.status)) {
|
||||
return prev;
|
||||
}
|
||||
return data.component;
|
||||
});
|
||||
},
|
||||
keys: liveContextKeys,
|
||||
types: liveContextTypes,
|
||||
as: liveAs,
|
||||
token,
|
||||
enabled: !!deviceID && componentID !== "",
|
||||
});
|
||||
|
||||
const setDefaultState = () => {
|
||||
setDevice(Device.create());
|
||||
|
|
@ -389,9 +442,12 @@ export default function DeviceComponent() {
|
|||
alignItems="center"
|
||||
wrap="nowrap"
|
||||
className={classes.overviewContainer}>
|
||||
{!component.status.synced && (
|
||||
{(componentPending.pending || componentPending.accepted) && (
|
||||
<Grid >
|
||||
<StatusChip key="status" status="pending" />
|
||||
<PendingChangesChip
|
||||
pending={componentPending.pending}
|
||||
accepted={componentPending.accepted}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid >
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { cyan } from "@mui/material/colors";
|
||||
import AcceptedIcon from "@mui/icons-material/CloudDone";
|
||||
import PendingIcon from "@mui/icons-material/CloudQueueTwoTone";
|
||||
import RejectedIcon from "@mui/icons-material/SmsFailed";
|
||||
|
||||
export interface StatusHelper {
|
||||
description: string;
|
||||
icon: any;
|
||||
}
|
||||
|
||||
const Unknown: StatusHelper = {
|
||||
description: "Status unknown",
|
||||
icon: null
|
||||
};
|
||||
|
||||
const Pending: StatusHelper = {
|
||||
description: "Pending changes",
|
||||
icon: <PendingIcon style={{ color: cyan["600"] }} />
|
||||
};
|
||||
|
||||
const Stale: StatusHelper = {
|
||||
description: "Stale update",
|
||||
icon: null
|
||||
};
|
||||
|
||||
const Accepted: StatusHelper = {
|
||||
description: "Settings synced",
|
||||
icon: <AcceptedIcon style={{ color: "var(--status-ok)" }} />
|
||||
};
|
||||
|
||||
const Rejected: StatusHelper = {
|
||||
description: "Update failed",
|
||||
icon: <RejectedIcon style={{ color: "var(--status-warning)" }} />
|
||||
};
|
||||
|
||||
const Received: StatusHelper = {
|
||||
description: "Settings synced",
|
||||
icon: <AcceptedIcon style={{ color: "var(--status-ok)" }} />
|
||||
};
|
||||
|
||||
const STATUS_MAP = new Map<string, StatusHelper>([
|
||||
["pending", Pending],
|
||||
["stale", Stale],
|
||||
["accepted", Accepted],
|
||||
["rejected", Rejected],
|
||||
["received", Received]
|
||||
]);
|
||||
|
||||
export function getStatusHelper(status: string): StatusHelper {
|
||||
const statuses = Array.from(STATUS_MAP.keys());
|
||||
for (var i = 0; i < statuses.length; i++) {
|
||||
let key = statuses[i];
|
||||
if (status === key) {
|
||||
return STATUS_MAP.get(key) as StatusHelper;
|
||||
}
|
||||
}
|
||||
|
||||
return Unknown;
|
||||
}
|
||||
|
||||
export function getStatusDescription(status: string): string {
|
||||
return getStatusHelper(status).description;
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: string): any {
|
||||
return getStatusHelper(status).icon;
|
||||
}
|
||||
|
|
@ -2,5 +2,6 @@ export * from "./download";
|
|||
export * from "./environment";
|
||||
export * from "./numbers";
|
||||
export * from "./strings";
|
||||
export * from "./syncStatus";
|
||||
export * from "./types";
|
||||
export * from "./units";
|
||||
|
|
|
|||
41
src/utils/syncStatus.ts
Normal file
41
src/utils/syncStatus.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export function getTimestampMillis(timestamp?: string): number | undefined {
|
||||
if (!timestamp) return undefined;
|
||||
const parsed = Date.parse(timestamp);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
/** The sync-tracking fields shared by ComponentStatus and InteractionStatus. */
|
||||
export interface SyncStatusLike {
|
||||
synced: boolean;
|
||||
lastUpdate?: string;
|
||||
lastSynced?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides whether an incoming websocket status message is stale and should
|
||||
* not replace the status we already hold. A message is stale when it was
|
||||
* generated before our latest local update, including a synced echo that
|
||||
* predates a pending change we just submitted.
|
||||
*/
|
||||
export function isStaleStatusUpdate(existing: SyncStatusLike, incoming: SyncStatusLike): boolean {
|
||||
const existingLastUpdate = getTimestampMillis(existing.lastUpdate);
|
||||
const incomingLastUpdate = getTimestampMillis(incoming.lastUpdate);
|
||||
const incomingLastSynced = getTimestampMillis(incoming.lastSynced);
|
||||
if (
|
||||
existingLastUpdate !== undefined &&
|
||||
incomingLastUpdate !== undefined &&
|
||||
existingLastUpdate > incomingLastUpdate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!existing.synced &&
|
||||
incoming.synced &&
|
||||
incomingLastUpdate !== undefined &&
|
||||
existingLastUpdate === incomingLastUpdate &&
|
||||
(incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue