From 2818b64969c0ad5f312be44a0db2eba14e371e2e Mon Sep 17 00:00:00 2001 From: Magik <30931287+unseenmagik@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:38:09 +0100 Subject: [PATCH 1/3] fix(webhooks): stop GPS ticks overwriting the saved Poracle location The notification panel listened for Leaflet `locationfound` events and persisted every one of them via the `setLocation` mutation. Because `leaflet.locatecontrol` runs geolocation in watch mode, an active locate control keeps emitting position fixes for as long as it is running, so simply opening the panel was enough to silently rewrite a location the user had deliberately set elsewhere. Users who track by coordinates + distance are hit hardest: Poracle stores one lat/lng per human, so a single background tick moves every distance-based tracking they have. Reported by a user who set alerts for a park, then had them follow him home the next time he edited a tracking. Gate the handler behind a ref that only the "My Location" button arms, and consume it on the first event so a continuing watch cannot write again. `_onClick` toggles the control, so arm from `_active` after the call -- clicking it while active stops locating and no event is coming. Also fix two adjacent issues found while verifying: - The coordinate comparisons required *both* lat and lng to differ, so moving a pin along a single axis never saved. - `human` is empty until the query resolves, so the init effect seeded the store with `[undefined, undefined]`, and its `every(x => x === 0)` guard then never re-fired once real coordinates arrived. Co-Authored-By: Claude Opus 4.8 --- src/features/webhooks/human/Location.jsx | 27 ++++++++++++++++++++---- src/hooks/useLocation.js | 2 +- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/features/webhooks/human/Location.jsx b/src/features/webhooks/human/Location.jsx index 01058f2d1..adb511cc7 100644 --- a/src/features/webhooks/human/Location.jsx +++ b/src/features/webhooks/human/Location.jsx @@ -64,24 +64,38 @@ const Location = () => { } } + // The locate control watches the device position, so `locationfound` keeps + // firing for as long as it is active. Only consume the first event after the + // user explicitly asks for their location, otherwise a background GPS tick + // would silently overwrite a location they set on purpose. + const awaitingLocate = React.useRef(false) + const map = useMapEvents({ locationfound: (newLoc) => { + if (!awaitingLocate.current) return + awaitingLocate.current = false const { location } = useWebhookStore.getState() const { lat, lng } = newLoc.latlng - if (lat !== location[0] && lng !== location[1]) { + if (lat !== location[0] || lng !== location[1]) { handleLocationChange([lat, lng]) } }, }) React.useEffect(() => { - if (webhookLocation[0] !== latitude && webhookLocation[1] !== longitude) { + if (webhookLocation[0] !== latitude || webhookLocation[1] !== longitude) { handleLocationChange(webhookLocation) } }, [webhookLocation]) React.useEffect(() => { - if (webhookLocation.every((x) => x === 0)) { + // `human` is empty until the query resolves, so wait for real coordinates + // rather than seeding the store with undefined. + if ( + typeof latitude === 'number' && + typeof longitude === 'number' && + webhookLocation.every((x) => x === 0) + ) { useWebhookStore.setState({ location: [latitude, longitude] }) } }, [latitude, longitude]) @@ -120,7 +134,12 @@ const Location = () => { size="small" variant="contained" color={color} - onClick={() => lc._onClick()} + onClick={() => { + lc._onClick() + // `_onClick` toggles: if that turned the control off, no + // `locationfound` is coming and the request must not stay armed. + awaitingLocate.current = !!lc._active + }} startIcon={} > {t('my_location')} diff --git a/src/hooks/useLocation.js b/src/hooks/useLocation.js index 58bdf1308..cec0b31be 100644 --- a/src/hooks/useLocation.js +++ b/src/hooks/useLocation.js @@ -13,7 +13,7 @@ import { RecoveringLocateControl } from '@utils/locateControl' /** * Use location hook - * @returns {{ lc: import('leaflet.locatecontrol').LocateControl & { _onClick: () => void }, requesting: boolean, color: import('@mui/material').ButtonProps['color'], locationError: { show: boolean, message: string }, hideLocationError: () => void }} + * @returns {{ lc: import('leaflet.locatecontrol').LocateControl & { _onClick: () => void, _active?: boolean }, requesting: boolean, color: import('@mui/material').ButtonProps['color'], locationError: { show: boolean, message: string }, hideLocationError: () => void }} */ export function useLocation(dependency = false) { const map = useMap() From 1f42fc649f180385f4462fa278aefa3a581cee13 Mon Sep 17 00:00:00 2001 From: Magik <30931287+unseenmagik@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:47:43 +0100 Subject: [PATCH 2/3] fix(webhooks): clear pending locate when a location is saved A GPS fix can take many seconds on mobile. If the user clicked My Location and then picked a different location via search or Choose on Map while waiting, the ref stayed armed and the late fix overwrote the newer explicit pick -- reintroducing the silent overwrite through a race. Clear the pending request whenever a location is actually persisted, so the most recent deliberate choice wins. Co-Authored-By: Claude Opus 4.8 --- src/features/webhooks/human/Location.jsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/features/webhooks/human/Location.jsx b/src/features/webhooks/human/Location.jsx index adb511cc7..2d07db208 100644 --- a/src/features/webhooks/human/Location.jsx +++ b/src/features/webhooks/human/Location.jsx @@ -42,9 +42,19 @@ const Location = () => { const [save] = useMutation(SET_HUMAN, { fetchPolicy: 'no-cache' }) + // The locate control watches the device position, so `locationfound` keeps + // firing for as long as it is active. Only consume the first event after the + // user explicitly asks for their location, otherwise a background GPS tick + // would silently overwrite a location they set on purpose. + const awaitingLocate = React.useRef(false) + /** @param {[number, number]} location */ const handleLocationChange = (location) => { if (location.every((x) => x !== 0)) { + // Whatever we are about to save supersedes a locate request that has not + // come back yet, otherwise a slow GPS fix would land on top of a pick the + // user made while waiting for it. + awaitingLocate.current = false useWebhookStore.setState((prev) => ({ location: prev.location.some((x, i) => x !== location[i]) ? [location[0] ?? 0, location[1] ?? 0] @@ -64,12 +74,6 @@ const Location = () => { } } - // The locate control watches the device position, so `locationfound` keeps - // firing for as long as it is active. Only consume the first event after the - // user explicitly asks for their location, otherwise a background GPS tick - // would silently overwrite a location they set on purpose. - const awaitingLocate = React.useRef(false) - const map = useMapEvents({ locationfound: (newLoc) => { if (!awaitingLocate.current) return From 2c03e0a98d84ac331600111e3eb370c64a9a4f35 Mon Sep 17 00:00:00 2001 From: Magik <30931287+unseenmagik@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:25:16 +0100 Subject: [PATCH 3/3] fix(webhooks): avoid duplicate setLocation POST from sync effect Search and My Location call handleLocationChange directly, which writes the store and POSTs. That store write re-fires the webhookLocation sync effect (kept for the drag picker, whose only signal is the store), which then POSTs the same coordinates a second time. Switching the comparison to || widened this to single-axis moves as well. Track the coordinates currently being persisted in a ref and short-circuit the effect when it would only duplicate that in-flight save, clearing the ref once the mutation settles (guarded so a newer save isn't wiped by an older one). The drag picker still routes through the effect and saves exactly once. Co-Authored-By: Claude Opus 4.8 --- src/features/webhooks/human/Location.jsx | 41 +++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/features/webhooks/human/Location.jsx b/src/features/webhooks/human/Location.jsx index 2d07db208..b2abe35c4 100644 --- a/src/features/webhooks/human/Location.jsx +++ b/src/features/webhooks/human/Location.jsx @@ -48,6 +48,14 @@ const Location = () => { // would silently overwrite a location they set on purpose. const awaitingLocate = React.useRef(false) + // Search, My Location, and the map picker call `handleLocationChange` + // directly, which both writes the store and POSTs. That store write also + // re-fires the `webhookLocation` sync effect below (which exists for the + // drag picker, whose only signal is the store). Remember the coordinates we + // are already persisting so the effect can skip a duplicate save. + /** @type {React.MutableRefObject<[number, number] | null>} */ + const savingLocation = React.useRef(null) + /** @param {[number, number]} location */ const handleLocationChange = (location) => { if (location.every((x) => x !== 0)) { @@ -55,6 +63,7 @@ const Location = () => { // come back yet, otherwise a slow GPS fix would land on top of a pick the // user made while waiting for it. awaitingLocate.current = false + savingLocation.current = location useWebhookStore.setState((prev) => ({ location: prev.location.some((x, i) => x !== location[i]) ? [location[0] ?? 0, location[1] ?? 0] @@ -66,11 +75,23 @@ const Location = () => { data: location, status: 'POST', }, - }).then(({ data: newData }) => { - if (newData?.webhook) { - useWebhookStore.setState({ human: newData.webhook.human }) - } }) + .then(({ data: newData }) => { + if (newData?.webhook) { + useWebhookStore.setState({ human: newData.webhook.human }) + } + }) + .finally(() => { + // Only clear if a newer save has not already claimed the ref, so an + // in-flight save can't wipe the marker for the location after it. + if ( + savingLocation.current && + savingLocation.current[0] === location[0] && + savingLocation.current[1] === location[1] + ) { + savingLocation.current = null + } + }) } } @@ -87,6 +108,18 @@ const Location = () => { }) React.useEffect(() => { + const saving = savingLocation.current + if ( + saving && + saving[0] === webhookLocation[0] && + saving[1] === webhookLocation[1] + ) { + // `handleLocationChange` already saved these exact coordinates and only + // updated the store, which is what re-triggered this effect. The drag + // picker, by contrast, writes the store without saving and relies on the + // POST below. + return + } if (webhookLocation[0] !== latitude || webhookLocation[1] !== longitude) { handleLocationChange(webhookLocation) }