feat(dae): updates (+ lint)

This commit is contained in:
devthejo 2026-03-08 18:03:19 +01:00
parent c366f8f9e8
commit 9914bd5276
No known key found for this signature in database
GPG key ID: 00CCA7A92B1D5351
14 changed files with 760 additions and 71 deletions

View file

@ -191,7 +191,14 @@ function formatAddress(p) {
// Strip parenthesized cp from city name, e.g. "GANAC (09000)" → "GANAC"
let city = (p.c_com_nom || "").trim();
if (cp && city) {
city = city.replace(new RegExp("\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)"), "").trim();
city = city
.replace(
new RegExp(
"\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)",
),
"",
)
.trim();
}
// Strip cp+city already embedded in street field
@ -199,7 +206,9 @@ function formatAddress(p) {
if (cp && street.includes(cp)) {
const cpEscaped = cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Trailing: "street 38200 Vienne" → "street"
street = street.replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "").trim();
street = street
.replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "")
.trim();
// Leading: "62117 rue de Lambres" → "rue de Lambres"
street = street.replace(new RegExp("^" + cpEscaped + "\\s+"), "").trim();
}
@ -359,7 +368,8 @@ function fixCoordinates(lat, lon, geometry) {
// 3. Try power-of-10 normalization for misplaced decimals
const fixedLat = tryNormalizeCoord(lat, 90);
const fixedLon = tryNormalizeCoord(lon, 180);
if (isPlausibleFrance(fixedLat, fixedLon)) return { lat: fixedLat, lon: fixedLon };
if (isPlausibleFrance(fixedLat, fixedLon))
return { lat: fixedLat, lon: fixedLon };
return null;
}

View file

@ -50,6 +50,28 @@ export default function getDb() {
return _dbPromise;
}
/**
* Close the current DB connection and clear all cached state.
* After calling this, the next `getDb()` / `getDbSafe()` call will re-open
* the DB from disk picking up any file that was swapped in the meantime.
*/
export function resetDb() {
// Close the op-sqlite backend if it was loaded.
try {
// eslint-disable-next-line global-require
const { resetDbOpSqlite } = require("./openDbOpSqlite");
if (typeof resetDbOpSqlite === "function") {
resetDbOpSqlite();
}
} catch {
// op-sqlite not available — nothing to close.
}
_dbPromise = null;
_backendPromise = null;
_selectedBackendName = null;
}
/**
* Non-throwing DB opener.
*

View file

@ -212,6 +212,24 @@ async function openDbOpSqlite() {
return _dbPromise;
}
/**
* Close the current DB connection and clear cached promises.
* After calling this, the next `openDbOpSqlite()` call will re-open the DB.
*/
function resetDbOpSqlite() {
if (_rawDb) {
try {
if (typeof _rawDb.close === "function") {
_rawDb.close();
}
} catch {
// Non-fatal: DB may already be closed or in an invalid state.
}
_rawDb = null;
}
_dbPromise = null;
}
// Exports (CJS + ESM-ish):
// Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes
// cannot hand back a nullish / unexpected callable export shape.
@ -220,6 +238,7 @@ module.exports = {
openDbOpSqlite,
openDb: openDbOpSqlite,
default: openDbOpSqlite,
resetDbOpSqlite,
// Named export for unit tests.
adaptDbToRepoInterface,
};

213
src/db/updateDaeDb.js Normal file
View file

@ -0,0 +1,213 @@
// Over-the-air DAE database update.
//
// Downloads a fresh geodae.db from the Minio/S3 bucket, validates it,
// swaps the on-device copy, and resets the DB connection so subsequent
// queries use the new data.
//
// IMPORTANT:
// - All native requires must stay inside functions so this file can be loaded
// in Jest/node without crashing.
import env from "~/env";
import { STORAGE_KEYS } from "~/storage/storageKeys";
const DB_NAME = "geodae.db";
const GEODAE_BUCKET = "geodae";
const METADATA_FILE = "metadata.json";
/**
* Build the public Minio URL for a given bucket/object.
* @param {string} object - object key within the geodae bucket
* @returns {string}
*/
function geodaeUrl(object) {
const base = env.MINIO_URL.replace(/\/+$/, "");
return `${base}/${GEODAE_BUCKET}/${object}`;
}
/**
* @typedef {Object} UpdateProgress
* @property {number} totalBytesWritten
* @property {number} totalBytesExpectedToWrite
*/
/**
* @typedef {Object} UpdateResult
* @property {boolean} success
* @property {boolean} [alreadyUpToDate]
* @property {string} [updatedAt]
* @property {Error} [error]
*/
/**
* Download and install the latest geodae.db from the server.
*
* @param {Object} options
* @param {function(UpdateProgress): void} [options.onProgress] - download progress callback
* @param {function(string): void} [options.onPhase] - phase change callback ("checking"|"downloading"|"installing")
* @returns {Promise<UpdateResult>}
*/
export async function updateDaeDb({ onProgress, onPhase } = {}) {
// Lazy requires to keep Jest/node stable.
// eslint-disable-next-line global-require
const FileSystemModule = require("expo-file-system");
const FileSystem = FileSystemModule?.default ?? FileSystemModule;
const sqliteDirUri = `${FileSystem.documentDirectory}SQLite`;
const dbUri = `${sqliteDirUri}/${DB_NAME}`;
const tmpUri = `${FileSystem.cacheDirectory}geodae-update-${Date.now()}.db`;
try {
// ── Phase 1: Check metadata ──────────────────────────────────────────
onPhase?.("checking");
const metadataUrl = geodaeUrl(METADATA_FILE);
const metaResponse = await fetch(metadataUrl);
if (!metaResponse.ok) {
throw new Error(
`[DAE_UPDATE] Failed to fetch metadata: HTTP ${metaResponse.status}`,
);
}
const metadata = await metaResponse.json();
const remoteUpdatedAt = metadata.updatedAt;
if (!remoteUpdatedAt) {
throw new Error("[DAE_UPDATE] Metadata missing updatedAt field");
}
// Compare with stored last update timestamp
// eslint-disable-next-line global-require
const memoryAsyncStorageModule = require("~/storage/memoryAsyncStorage");
const memoryAsyncStorage =
memoryAsyncStorageModule?.default ?? memoryAsyncStorageModule;
const storedUpdatedAt = await memoryAsyncStorage.getItem(
STORAGE_KEYS.DAE_DB_UPDATED_AT,
);
if (
storedUpdatedAt &&
new Date(remoteUpdatedAt).getTime() <= new Date(storedUpdatedAt).getTime()
) {
return { success: true, alreadyUpToDate: true };
}
// ── Phase 2: Download ────────────────────────────────────────────────
onPhase?.("downloading");
const dbUrl = geodaeUrl(DB_NAME);
const downloadResumable = FileSystem.createDownloadResumable(
dbUrl,
tmpUri,
{},
onProgress,
);
const downloadResult = await downloadResumable.downloadAsync();
if (!downloadResult?.uri) {
throw new Error("[DAE_UPDATE] Download failed: no URI returned");
}
// Verify the downloaded file is non-empty
const tmpInfo = await FileSystem.getInfoAsync(tmpUri);
if (!tmpInfo.exists || tmpInfo.size === 0) {
throw new Error("[DAE_UPDATE] Downloaded file is empty or missing");
}
// ── Phase 3: Validate ────────────────────────────────────────────────
onPhase?.("installing");
// Quick validation: open the downloaded DB and check schema
// We use the same validation as the main DB opener.
// eslint-disable-next-line global-require
const { assertDbHasTable } = require("./validateDbSchema");
// Try to open the temp DB with op-sqlite for validation
let validationDb = null;
try {
// eslint-disable-next-line global-require
const opSqliteMod = require("@op-engineering/op-sqlite");
const open = opSqliteMod?.open ?? opSqliteMod?.default?.open;
if (typeof open === "function") {
// op-sqlite needs the directory and filename separately
const tmpDir = tmpUri.substring(0, tmpUri.lastIndexOf("/"));
const tmpName = tmpUri.substring(tmpUri.lastIndexOf("/") + 1);
validationDb = open({ name: tmpName, location: tmpDir });
// Wrap for assertDbHasTable compatibility
const getAllAsync = async (sql, params = []) => {
const exec =
typeof validationDb.executeAsync === "function"
? validationDb.executeAsync.bind(validationDb)
: validationDb.execute?.bind(validationDb);
if (!exec) throw new Error("No execute method on validation DB");
const res = params.length ? await exec(sql, params) : await exec(sql);
return res?.rows ?? [];
};
await assertDbHasTable({ getAllAsync }, "defibs");
}
} catch (validationError) {
// Clean up temp file
try {
await FileSystem.deleteAsync(tmpUri, { idempotent: true });
} catch {
// ignore cleanup errors
}
const err = new Error("[DAE_UPDATE] Downloaded DB failed validation");
err.cause = validationError;
throw err;
} finally {
// Close validation DB
if (validationDb && typeof validationDb.close === "function") {
try {
validationDb.close();
} catch {
// ignore
}
}
}
// ── Phase 4: Swap ────────────────────────────────────────────────────
// IMPORTANT: resetDb() closes the DB and clears cached promises.
// No concurrent DB queries should be in flight at this point.
// The caller (store action) is the only code path that triggers this,
// and it awaits completion before allowing new queries.
// eslint-disable-next-line global-require
const { resetDb } = require("./openDb");
resetDb();
// Ensure SQLite directory exists
const dirInfo = await FileSystem.getInfoAsync(sqliteDirUri);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(sqliteDirUri, {
intermediates: true,
});
}
// Replace the DB file
await FileSystem.moveAsync({ from: tmpUri, to: dbUri });
// Persist the update timestamp
await memoryAsyncStorage.setItem(
STORAGE_KEYS.DAE_DB_UPDATED_AT,
remoteUpdatedAt,
);
console.warn(
"[DAE_UPDATE] Successfully updated geodae.db to version:",
remoteUpdatedAt,
);
return { success: true, updatedAt: remoteUpdatedAt };
} catch (error) {
// Clean up temp file on any error (FileSystem is in scope from the outer try)
try {
await FileSystem.deleteAsync(tmpUri, { idempotent: true });
} catch {
// ignore cleanup errors
}
console.warn("[DAE_UPDATE] Update failed:", error?.message, error);
return { success: false, error };
}
}

View file

@ -57,7 +57,9 @@ network.oaFilesKy = oaFilesKy;
export default function NetworkProviders({ children }) {
const [key, setKey] = useState(0);
const [transportClient, setTransportClient] = useState(() => network.apolloClient);
const [transportClient, setTransportClient] = useState(
() => network.apolloClient,
);
const networkState = useNetworkState([
"initialized",

View file

@ -23,7 +23,10 @@ import { useTheme } from "~/theme";
import { useDefibsState, useNetworkState } from "~/stores";
import useLocation from "~/hooks/useLocation";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
import { osmProfileUrl, profileDefaultModes } from "~/scenes/AlertCurMap/routing";
import {
osmProfileUrl,
profileDefaultModes,
} from "~/scenes/AlertCurMap/routing";
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
import {
announceForA11yIfScreenReaderEnabled,
@ -172,15 +175,12 @@ export default React.memo(function DAEItemCarte() {
const mapHeadSeeAllRef = useRef(null);
const lastStepsTriggerRef = useRef(null);
const openStepper = useCallback(
(triggerRef) => {
const openStepper = useCallback((triggerRef) => {
if (triggerRef) {
lastStepsTriggerRef.current = triggerRef;
}
setStepperIsOpened(true);
},
[],
);
}, []);
const closeStepper = useCallback(() => {
setStepperIsOpened(false);

View file

@ -392,6 +392,7 @@ export default React.memo(function DAEItemInfos() {
{/* In-app navigation option */}
<TouchableOpacity
accessibilityRole="button"
onPress={goToCarte}
style={modalStyles.option}
activeOpacity={0.6}
@ -411,6 +412,7 @@ export default React.memo(function DAEItemInfos() {
<React.Fragment key={app.id}>
<View style={modalStyles.separator} />
<TouchableOpacity
accessibilityRole="button"
onPress={() => openExternalApp(app)}
style={modalStyles.option}
activeOpacity={0.6}

View file

@ -146,9 +146,7 @@ export default React.memo(function DAEListCarte() {
// Waiting for location
if (!hasLocation && defibs.length === 0 && !hasCoords) {
return (
<LoadingView message="Recherche de votre position…" />
);
return <LoadingView message="Recherche de votre position…" />;
}
// Loading defibs from database

View file

@ -0,0 +1,309 @@
import React, { useEffect, useCallback } from "react";
import { View, StyleSheet, TouchableOpacity } from "react-native";
import { ProgressBar, ActivityIndicator } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import Text from "~/components/Text";
import { useTheme } from "~/theme";
import { defibsActions, useDefibsState } from "~/stores";
function formatDate(isoString) {
if (!isoString) return null;
try {
const d = new Date(isoString);
return d.toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return null;
}
}
export default React.memo(function DaeUpdateBanner() {
const { colors } = useTheme();
const {
daeUpdateState,
daeUpdateProgress,
daeUpdateError,
daeLastUpdatedAt,
} = useDefibsState([
"daeUpdateState",
"daeUpdateProgress",
"daeUpdateError",
"daeLastUpdatedAt",
]);
// Load persisted last-update date on mount
useEffect(() => {
defibsActions.loadLastDaeUpdate();
}, []);
const handleUpdate = useCallback(() => {
defibsActions.triggerDaeUpdate();
}, []);
const handleDismissError = useCallback(() => {
defibsActions.dismissDaeUpdateError();
}, []);
const isActive =
daeUpdateState === "checking" ||
daeUpdateState === "downloading" ||
daeUpdateState === "installing";
// Done state
if (daeUpdateState === "done") {
return (
<View
style={[
styles.banner,
{ backgroundColor: (colors.primary || "#4CAF50") + "15" },
]}
>
<MaterialCommunityIcons
name="check-circle-outline"
size={18}
color={colors.primary || "#4CAF50"}
/>
<Text
style={[styles.statusText, { color: colors.primary || "#4CAF50" }]}
>
{"Base de donn\u00e9es mise \u00e0 jour !"}
</Text>
</View>
);
}
// Already up-to-date
if (daeUpdateState === "up-to-date") {
return (
<View
style={[
styles.banner,
{
backgroundColor:
(colors.onSurfaceVariant || colors.grey || "#666") + "10",
},
]}
>
<MaterialCommunityIcons
name="check-circle-outline"
size={18}
color={colors.onSurfaceVariant || colors.grey}
/>
<Text
style={[
styles.statusText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{"Donn\u00e9es d\u00e9j\u00e0 \u00e0 jour"}
</Text>
</View>
);
}
// Error state
if (daeUpdateState === "error") {
return (
<View
style={[
styles.banner,
{ backgroundColor: (colors.error || "#F44336") + "15" },
]}
>
<MaterialCommunityIcons
name="alert-circle-outline"
size={18}
color={colors.error || "#F44336"}
/>
<Text
style={[styles.errorText, { color: colors.error || "#F44336" }]}
numberOfLines={2}
>
{daeUpdateError || "Erreur lors de la mise \u00e0 jour"}
</Text>
<TouchableOpacity
accessibilityRole="button"
onPress={handleUpdate}
style={styles.retryTouch}
>
<MaterialCommunityIcons
name="refresh"
size={20}
color={colors.error || "#F44336"}
/>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
onPress={handleDismissError}
style={styles.dismissTouch}
>
<MaterialCommunityIcons
name="close"
size={18}
color={colors.error || "#F44336"}
/>
</TouchableOpacity>
</View>
);
}
// Downloading state
if (daeUpdateState === "downloading") {
const pct = Math.round(daeUpdateProgress * 100);
return (
<View
style={[
styles.banner,
styles.progressBanner,
{ backgroundColor: (colors.primary || "#2196F3") + "10" },
]}
>
<View style={styles.progressHeader}>
<ActivityIndicator size={14} color={colors.primary} />
<Text
style={[styles.statusText, { color: colors.primary || "#2196F3" }]}
>
{`T\u00e9l\u00e9chargement\u2026 ${pct}%`}
</Text>
</View>
<ProgressBar
progress={daeUpdateProgress}
color={colors.primary}
style={styles.progressBar}
/>
</View>
);
}
// Checking / Installing state
if (isActive) {
const label =
daeUpdateState === "checking"
? "V\u00e9rification\u2026"
: "Installation\u2026";
return (
<View
style={[
styles.banner,
{ backgroundColor: (colors.primary || "#2196F3") + "10" },
]}
>
<ActivityIndicator size={14} color={colors.primary} />
<Text
style={[styles.statusText, { color: colors.primary || "#2196F3" }]}
>
{label}
</Text>
</View>
);
}
// Idle state
const formattedDate = formatDate(daeLastUpdatedAt);
return (
<View
style={[
styles.banner,
{
backgroundColor:
(colors.onSurfaceVariant || colors.grey || "#666") + "08",
borderBottomColor: colors.outlineVariant || colors.grey,
borderBottomWidth: StyleSheet.hairlineWidth,
},
]}
>
<MaterialCommunityIcons
name="database-sync-outline"
size={18}
color={colors.onSurfaceVariant || colors.grey}
/>
<View style={styles.idleTextContainer}>
<Text
style={[
styles.dateText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{formattedDate
? `Derni\u00e8re mise \u00e0 jour : ${formattedDate}`
: "Donn\u00e9es int\u00e9gr\u00e9es \u00e0 l'application"}
</Text>
</View>
<TouchableOpacity
accessibilityRole="button"
onPress={handleUpdate}
style={[
styles.updateButton,
{ backgroundColor: colors.primary || "#2196F3" },
]}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="download" size={14} color="#fff" />
<Text style={styles.updateButtonText}>{"Mettre \u00e0 jour"}</Text>
</TouchableOpacity>
</View>
);
});
const styles = StyleSheet.create({
banner: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingVertical: 8,
gap: 8,
},
progressBanner: {
flexDirection: "column",
alignItems: "stretch",
gap: 6,
},
progressHeader: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
progressBar: {
height: 4,
borderRadius: 2,
},
statusText: {
fontSize: 13,
fontWeight: "500",
flex: 1,
},
errorText: {
fontSize: 12,
flex: 1,
},
retryTouch: {
padding: 4,
},
dismissTouch: {
padding: 4,
},
idleTextContainer: {
flex: 1,
},
dateText: {
fontSize: 12,
},
updateButton: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 16,
},
updateButtonText: {
color: "#fff",
fontSize: 12,
fontWeight: "600",
},
});

View file

@ -192,9 +192,7 @@ export default React.memo(function DAEListListe() {
// Waiting for location
if (!hasLocation && allDefibs.length === 0) {
return (
<LoadingView message="Recherche de votre position…" />
);
return <LoadingView message="Recherche de votre position…" />;
}
// Loading defibs from database

View file

@ -1,4 +1,5 @@
import React from "react";
import { View, StyleSheet } from "react-native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { MaterialCommunityIcons } from "@expo/vector-icons";
@ -6,6 +7,7 @@ import { fontFamily, useTheme } from "~/theme";
import DAEListListe from "./Liste";
import DAEListCarte from "./Carte";
import DaeUpdateBanner from "./DaeUpdateBanner";
const Tab = createBottomTabNavigator();
@ -13,6 +15,9 @@ export default React.memo(function DAEList() {
const { colors } = useTheme();
return (
<View style={styles.container}>
<DaeUpdateBanner />
<View style={styles.tabContainer}>
<Tab.Navigator
screenOptions={{
headerShown: false,
@ -57,5 +62,16 @@ export default React.memo(function DAEList() {
}}
/>
</Tab.Navigator>
</View>
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
tabContainer: {
flex: 1,
},
});

View file

@ -12,12 +12,18 @@ const RADIUS_METERS = 10_000;
*/
export default function useNearbyDefibs() {
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
const { nearUserDefibs, loadingNearUser, errorNearUser, showUnavailable } =
useDefibsState([
const {
nearUserDefibs,
loadingNearUser,
errorNearUser,
showUnavailable,
daeUpdateState,
} = useDefibsState([
"nearUserDefibs",
"loadingNearUser",
"errorNearUser",
"showUnavailable",
"daeUpdateState",
]);
const hasLocation =
@ -42,6 +48,16 @@ export default function useNearbyDefibs() {
});
}, [hasLocation, coords]);
// After a successful DB update, reset the position cache so the next
// render re-queries the fresh database.
const prevUpdateState = useRef(daeUpdateState);
useEffect(() => {
if (prevUpdateState.current !== "done" && daeUpdateState === "done") {
lastLoadedRef.current = null;
}
prevUpdateState.current = daeUpdateState;
}, [daeUpdateState]);
useEffect(() => {
if (hasLocation) {
setNoLocation(false);

View file

@ -81,4 +81,5 @@ export const STORAGE_KEYS = {
EULA_ACCEPTED_SIMPLE: registerAsyncStorageKey("eula_accepted"),
EMULATOR_MODE_ENABLED: registerAsyncStorageKey("emulator_mode_enabled"),
SENTRY_ENABLED: registerAsyncStorageKey("@sentry_enabled"),
DAE_DB_UPDATED_AT: registerAsyncStorageKey("@dae_db_updated_at"),
};

View file

@ -5,11 +5,16 @@ import {
computeCorridorQueryRadiusMeters,
filterDefibsInCorridor,
} from "~/utils/geo/corridor";
import { updateDaeDb } from "~/db/updateDaeDb";
import memoryAsyncStorage from "~/storage/memoryAsyncStorage";
import { STORAGE_KEYS } from "~/storage/storageKeys";
const DEFAULT_NEAR_USER_RADIUS_M = 10_000;
const DEFAULT_CORRIDOR_M = 10_000;
const DEFAULT_LIMIT = 200;
const AUTO_DISMISS_DELAY = 4_000;
export default createAtom(({ merge, reset }) => {
const actions = {
reset,
@ -98,6 +103,78 @@ export default createAtom(({ merge, reset }) => {
return { defibs: [], error };
}
},
// ── DAE DB Over-the-Air Update ─────────────────────────────────────
loadLastDaeUpdate: async () => {
try {
const stored = await memoryAsyncStorage.getItem(
STORAGE_KEYS.DAE_DB_UPDATED_AT,
);
if (stored) {
merge({ daeLastUpdatedAt: stored });
}
} catch {
// Non-fatal
}
},
triggerDaeUpdate: async () => {
merge({
daeUpdateState: "checking",
daeUpdateProgress: 0,
daeUpdateError: null,
});
const result = await updateDaeDb({
onPhase: (phase) => {
merge({ daeUpdateState: phase });
},
onProgress: ({ totalBytesWritten, totalBytesExpectedToWrite }) => {
const progress =
totalBytesExpectedToWrite > 0
? totalBytesWritten / totalBytesExpectedToWrite
: 0;
merge({
daeUpdateState: "downloading",
daeUpdateProgress: progress,
});
},
});
if (result.alreadyUpToDate) {
merge({ daeUpdateState: "up-to-date" });
setTimeout(() => {
merge({ daeUpdateState: "idle" });
}, AUTO_DISMISS_DELAY);
return;
}
if (!result.success) {
merge({
daeUpdateState: "error",
daeUpdateError: result.error?.message || "Erreur inconnue",
});
return;
}
// Success: update stored timestamp and clear loaded defibs
// so the next query fetches from the fresh DB.
merge({
daeUpdateState: "done",
daeLastUpdatedAt: result.updatedAt,
nearUserDefibs: [],
corridorDefibs: [],
});
setTimeout(() => {
merge({ daeUpdateState: "idle" });
}, AUTO_DISMISS_DELAY);
},
dismissDaeUpdateError: () => {
merge({ daeUpdateState: "idle", daeUpdateError: null });
},
};
return {
@ -113,6 +190,12 @@ export default createAtom(({ merge, reset }) => {
loadingCorridor: false,
errorNearUser: null,
errorCorridor: null,
// DAE DB update state
daeUpdateState: "idle", // "idle"|"checking"|"downloading"|"installing"|"done"|"error"|"up-to-date"
daeUpdateProgress: 0, // 0..1
daeUpdateError: null,
daeLastUpdatedAt: null,
},
actions,
};