From c12e4cfcdeff2a41f6068cba49d0ad5595d2229e Mon Sep 17 00:00:00 2001 From: devthejo Date: Sat, 7 Mar 2026 07:53:08 +0100 Subject: [PATCH] feat: first darft mvp --- .claude/settings.local.json | 16 +- .eslintrc.js | 7 + android/app/build.gradle | 7 + cline_docs/progress.md | 29 ++ jest.config.js | 12 + package.json | 6 +- plans/PLAN_DAE-claude.md | 313 +++++++++++++++ plans/PLAN_DAE-gpt.md | 395 +++++++++++++++++++ plans/PLAN_DAE-merged.md | 370 +++++++++++++++++ scripts/dae/geodae-to-csv.js | 12 +- src/assets/img/marker-grey.png | Bin 0 -> 4143 bytes src/containers/DaeSuggestModal/index.js | 73 ++++ src/containers/Map/AlertSymbolLayer.js | 7 +- src/containers/Map/FeatureImages.js | 2 + src/containers/Map/ShapePoints.js | 14 + src/data/getNearbyDefibs.js | 11 +- src/db/defibsRepo.js | 49 ++- src/db/ensureEmbeddedDb.js | 123 ++++++ src/db/ensureEmbeddedDb.test.js | 98 +++++ src/db/openDb.js | 350 ++++++++++++++-- src/db/openDb.op-sqlite.js | 17 - src/db/openDbExpoSqlite.js | 87 ++++ src/db/openDbOpSqlite.js | 225 +++++++++++ src/db/openDbOpSqlite.test.js | 32 ++ src/db/validateDbSchema.js | 34 ++ src/db/validateDbSchema.test.js | 19 + src/layout/LayoutProviders.js | 5 + src/lib/h3/index.js | 63 +++ src/navigation/Drawer.js | 26 ++ src/navigation/RootStack.js | 5 + src/scenes/AlertCurMap/prioritizeFeatures.js | 8 + src/scenes/AlertCurMap/useFeatures.js | 64 ++- src/scenes/AlertCurMap/useOnPress.js | 8 +- src/scenes/AlertCurOverview/index.js | 123 ++++++ src/scenes/AlertCurOverview/styles.js | 5 + src/scenes/DAEItem/Carte.js | 385 ++++++++++++++++++ src/scenes/DAEItem/Infos.js | 385 ++++++++++++++++++ src/scenes/DAEItem/index.js | 130 ++++++ src/scenes/DAEList/Carte.js | 227 +++++++++++ src/scenes/DAEList/DefibRow.js | 166 ++++++++ src/scenes/DAEList/Liste.js | 197 +++++++++ src/scenes/DAEList/index.js | 61 +++ src/scenes/DAEList/useNearbyDefibs.js | 72 ++++ src/scenes/Notifications/Item.js | 3 +- src/scenes/SendAlertConfirm/useOnSubmit.js | 16 +- src/stores/defibs.js | 114 ++++++ src/stores/index.js | 7 + src/utils/dae/getDefibAvailability.js | 174 ++++++++ src/utils/dae/getDefibAvailability.test.js | 60 +++ src/utils/dae/subjectSuggestsDefib.js | 76 ++++ src/utils/dae/subjectSuggestsDefib.test.js | 40 ++ src/utils/geo/corridor.js | 79 ++++ src/utils/geo/corridor.test.js | 48 +++ yarn.lock | 40 ++ 54 files changed, 4825 insertions(+), 70 deletions(-) create mode 100644 jest.config.js create mode 100644 plans/PLAN_DAE-claude.md create mode 100644 plans/PLAN_DAE-gpt.md create mode 100644 plans/PLAN_DAE-merged.md create mode 100644 src/assets/img/marker-grey.png create mode 100644 src/containers/DaeSuggestModal/index.js create mode 100644 src/db/ensureEmbeddedDb.js create mode 100644 src/db/ensureEmbeddedDb.test.js delete mode 100644 src/db/openDb.op-sqlite.js create mode 100644 src/db/openDbExpoSqlite.js create mode 100644 src/db/openDbOpSqlite.js create mode 100644 src/db/openDbOpSqlite.test.js create mode 100644 src/db/validateDbSchema.js create mode 100644 src/db/validateDbSchema.test.js create mode 100644 src/lib/h3/index.js create mode 100644 src/scenes/DAEItem/Carte.js create mode 100644 src/scenes/DAEItem/Infos.js create mode 100644 src/scenes/DAEItem/index.js create mode 100644 src/scenes/DAEList/Carte.js create mode 100644 src/scenes/DAEList/DefibRow.js create mode 100644 src/scenes/DAEList/Liste.js create mode 100644 src/scenes/DAEList/index.js create mode 100644 src/scenes/DAEList/useNearbyDefibs.js create mode 100644 src/stores/defibs.js create mode 100644 src/utils/dae/getDefibAvailability.js create mode 100644 src/utils/dae/getDefibAvailability.test.js create mode 100644 src/utils/dae/subjectSuggestsDefib.js create mode 100644 src/utils/dae/subjectSuggestsDefib.test.js create mode 100644 src/utils/geo/corridor.js create mode 100644 src/utils/geo/corridor.test.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e221c41..0053600 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,21 @@ "Bash(node csv-to-sqlite.mjs --input ../.data/geodae.csv --output ../src/assets/db/geodae.db)", "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(*\\) FROM defibs; SELECT * FROM defibs LIMIT 3; SELECT count\\(DISTINCT h3\\) FROM defibs;\")", "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT id, nom, latitude, longitude FROM defibs WHERE h3 = ''881fb542d3fffff'' LIMIT 5;\")", - "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT h3, count\\(*\\) as cnt FROM defibs GROUP BY h3 ORDER BY cnt DESC LIMIT 5;\")" + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT h3, count\\(*\\) as cnt FROM defibs GROUP BY h3 ORDER BY cnt DESC LIMIT 5;\")", + "Bash(pnpm --version)", + "Bash(find /home/jo/lab/alerte-secours/apps/as-app/src/scenes/AlertCur* -type f -name \"*.js\" -o -name \"*.jsx\")", + "Bash(find /home/jo/lab/alerte-secours/apps/as-app/src/scenes/SendAlert* -type f -name \"*.js\" -o -name \"*.jsx\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires != '''' ORDER BY horaires LIMIT 80;\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires <> '''' ORDER BY horaires LIMIT 80;\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(*\\) FROM defibs WHERE horaires <> ''''; SELECT count\\(*\\) FROM defibs WHERE horaires = '''';\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT count\\(DISTINCT horaires\\) FROM defibs WHERE horaires <> '''';\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, count\\(*\\) as cnt FROM defibs WHERE horaires <> '''' GROUP BY horaires ORDER BY cnt DESC LIMIT 30;\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires <> '''' ORDER BY horaires LIMIT 80 OFFSET 80;\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''Lun%'' AND horaires <> ''Lun-Ven heures ouvrables'' AND horaires <> ''Lun-Sam heures ouvrables'' ORDER BY horaires LIMIT 50;\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''%événements%'' OR horaires LIKE ''%evene%'' ORDER BY horaires LIMIT 20;\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, count\\(*\\) as cnt FROM defibs WHERE horaires <> '''' GROUP BY horaires ORDER BY cnt DESC LIMIT 50;\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT DISTINCT horaires FROM defibs WHERE horaires LIKE ''%heures de nuit%'' LIMIT 10;\")", + "Bash(sqlite3 /home/jo/lab/alerte-secours/apps/as-app/src/assets/db/geodae.db \"SELECT horaires, horaires_std FROM defibs WHERE horaires = ''Lun-Ven heures ouvrables'' LIMIT 1;\")" ] } } diff --git a/.eslintrc.js b/.eslintrc.js index 5f3bdea..06fe1dc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,6 +4,7 @@ module.exports = { root: true, env: { "react-native/react-native": true, + jest: true, }, extends: [ "plugin:prettier/recommended", @@ -37,6 +38,12 @@ module.exports = { }, "import/ignore": ["react-native"], "import/resolver": { + // Ensure ESLint can resolve regular JS packages under Yarn PnP as well. + // Without this, some deps (ex: expo-sqlite) may be incorrectly flagged + // by import/no-unresolved even though they're present. + node: { + extensions: [".js", ".jsx", ".ts", ".tsx", ".json"], + }, typescript: {}, }, }, diff --git a/android/app/build.gradle b/android/app/build.gradle index 44858e3..e1092a3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -147,6 +147,13 @@ android { } } packagingOptions { + // Resolve duplicate native libs shipped by multiple dependencies (e.g. op-sqlite + react-android). + // Needed for debug flavors too (mergeNativeLibs). + pickFirsts += [ + 'lib/**/libjsi.so', + 'lib/**/libreactnative.so', + ] + jniLibs { useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false) } diff --git a/cline_docs/progress.md b/cline_docs/progress.md index 8993fc3..6887cbc 100644 --- a/cline_docs/progress.md +++ b/cline_docs/progress.md @@ -2,6 +2,35 @@ ## Recently Completed Features +### DAE v1 (Tasks 1–9) — 2026-03-06 +1. Embedded DAE DB + safe open path: + - ✅ Embedded DB asset: `src/assets/db/geodae.db` + - ✅ Safe open path + repository: `src/db/openDb.js`, `src/db/defibsRepo.js` + +2. Utilities + tests: + - ✅ Corridor/geo utils: `src/utils/geo/corridor.js` + - ✅ DAE helpers: `src/utils/dae/getDefibAvailability.js`, `src/utils/dae/subjectSuggestsDefib.js` + - ✅ Jest config: `jest.config.js` + +3. Store: + - ✅ Defibrillators store: `src/stores/defibs.js` + +4. Screens + navigation: + - ✅ DAE list + item screens: `src/scenes/DAEList/index.js`, `src/scenes/DAEItem/index.js` + - ✅ Navigation wiring: `src/navigation/Drawer.js`, `src/navigation/RootStack.js` + +5. Alert integration: + - ✅ Alert overview + map hooks: `src/scenes/AlertCurOverview/index.js`, `src/scenes/AlertCurMap/useFeatures.js`, `src/scenes/AlertCurMap/useOnPress.js` + +6. Persistent suggestion modal: + - ✅ `src/containers/DaeSuggestModal/index.js` mounted in `src/layout/LayoutProviders.js` + +7. New asset: + - ✅ Marker icon: `src/assets/img/marker-grey.png` + +8. Verification: + - ✅ `yarn lint` and `yarn test` passing + ### Push Notification Improvements 1. Background Notification Fixes: - ✅ Added required Android permissions diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..6e4d789 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,12 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + testMatch: ["/src/**/*.test.js"], + testPathIgnorePatterns: ["/node_modules/", "/e2e/"], + transformIgnorePatterns: [ + "node_modules/(?!(@react-native|react-native|expo)/)", + ], + testEnvironment: "node", + moduleNameMapper: { + "^~/(.*)$": "/src/$1", + }, +}; diff --git a/package.json b/package.json index f560008..6835013 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@mapbox/polyline": "^1.2.1", "@maplibre/maplibre-react-native": "10.0.0-alpha.23", "@notifee/react-native": "^9.1.8", + "@op-engineering/op-sqlite": "^15.2.5", "@react-native-async-storage/async-storage": "2.1.2", "@react-native-community/netinfo": "^11.4.1", "@react-native-community/slider": "4.5.6", @@ -129,6 +130,7 @@ "expo-contacts": "~14.2.5", "expo-dev-client": "~5.2.4", "expo-device": "~7.1.4", + "expo-file-system": "~18.1.11", "expo-gradle-ext-vars": "^0.1.1", "expo-linear-gradient": "~14.1.5", "expo-linking": "~7.1.7", @@ -138,6 +140,7 @@ "expo-secure-store": "~14.2.4", "expo-sensors": "~14.1.4", "expo-splash-screen": "~0.30.10", + "expo-sqlite": "^55.0.10", "expo-status-bar": "~2.2.3", "expo-system-ui": "~5.0.11", "expo-task-manager": "~13.1.6", @@ -148,6 +151,7 @@ "google-libphonenumber": "^3.2.32", "graphql": "^16.10.0", "graphql-ws": "^6.0.4", + "h3-js": "^4.4.0", "hash.js": "^1.1.7", "i18next": "^23.2.10", "immer": "^10.0.2", @@ -285,4 +289,4 @@ } }, "packageManager": "yarn@4.5.3" -} \ No newline at end of file +} diff --git a/plans/PLAN_DAE-claude.md b/plans/PLAN_DAE-claude.md new file mode 100644 index 0000000..cff7ebe --- /dev/null +++ b/plans/PLAN_DAE-claude.md @@ -0,0 +1,313 @@ +# DAE (Défibrillateur) Feature - Implementation Plan + +## Common Context Prefix (include at the top of every task prompt) + +``` +## Project Context - DAE Feature Integration + +You are working on the React Native app "Alerte Secours" at `/home/jo/lab/alerte-secours/apps/as-app/`. +Branch: `feat/dae` + +### Architecture Overview + +- **Framework:** React Native with Expo v53 +- **Navigation:** React Navigation v6 (Stack → Drawer → Tabs) + - `RootStack.js` (Stack) → `Drawer.js` (Drawer) → `Main/index.js` (Material Top Tabs) + - Each drawer screen can be a standalone scene or use a Bottom Tab Navigator +- **State Management:** Zustand v5 with custom atomic wrapper (`~/lib/atomic-zustand`) + - Stores defined in `~/stores/index.js`, each atom in `~/stores/{name}.js` + - Usage: `const { foo } = useFooState(["foo"])` or `fooActions.doSomething()` +- **Maps:** MapLibre React Native (`@maplibre/maplibre-react-native`) + - Map components in `~/containers/Map/` (Camera, MapView, FeatureImages, ShapePoints, etc.) + - Route calculation via OSRM in `~/scenes/AlertCurMap/routing.js` + - Clustering via supercluster in `useFeatures` hooks +- **Styling:** React Native Paper v5 + custom `createStyles()`/`useStyles()` from `~/theme` +- **Icons:** `@expo/vector-icons` (MaterialCommunityIcons, MaterialIcons, Entypo) +- **Forms:** React Hook Form +- **GraphQL:** Apollo Client v3 +- **Module alias:** `~` maps to `src/` + +### Key Existing Patterns + +**Drawer Screen Registration** (`src/navigation/Drawer.js`): +- Screens are `` +- Hidden screens use `options={{ hidden: true }}` +- Menu item sections are organized by index ranges in `DrawerItemList.js` (indices 0-4 = "Alerter", 5-8 = "Mon compte", 9+ = "Infos pratiques") + +**Header Titles** (`src/navigation/RootStack.js`): +- `getHeaderTitle(route)` switch-case maps route names to display titles + +**Bottom Tab Navigator Pattern** (reference: `src/scenes/AlertAgg/index.js`): +- `createBottomTabNavigator()` with `Tab.Screen` entries +- `screenOptions={{ headerShown: false, tabBarLabelStyle: { fontSize: 13 }, lazy: true, unmountOnBlur: true }}` +- Tab icons use MaterialCommunityIcons + +**Action Buttons Pattern** (reference: `src/scenes/AlertCurOverview/index.js`): +- ` + + + + + ); +} diff --git a/src/containers/Map/AlertSymbolLayer.js b/src/containers/Map/AlertSymbolLayer.js index 885c83c..40c5a26 100644 --- a/src/containers/Map/AlertSymbolLayer.js +++ b/src/containers/Map/AlertSymbolLayer.js @@ -25,7 +25,12 @@ export default function AlertSymbolLayer({ level, isDisabled }) { return ( ({ clusterCount: { textField: "{point_count_abbreviated}", @@ -58,6 +63,15 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) { style={iconStyle} /> + {/* Defibrillators (DAE) – separate layer (non-clustered) */} + + {children} ); diff --git a/src/data/getNearbyDefibs.js b/src/data/getNearbyDefibs.js index 02332af..4558040 100644 --- a/src/data/getNearbyDefibs.js +++ b/src/data/getNearbyDefibs.js @@ -53,7 +53,16 @@ export default async function getNearbyDefibs({ }); } catch (err) { // Fallback to bbox if H3 fails (e.g. missing h3-js on a platform) - console.warn("H3 query failed, falling back to bbox:", err.message); + console.warn("[DAE_DB] H3 query failed, falling back to bbox raw:", err); + console.warn( + "[DAE_DB] H3 query failed, falling back to bbox message:", + err?.message, + ); + if (err?.stack) { + console.warn( + `[DAE_DB] H3 query failed, falling back to bbox stack:\n${err.stack}`, + ); + } return getNearbyDefibsBbox({ lat, lon, diff --git a/src/db/defibsRepo.js b/src/db/defibsRepo.js index 1a92f39..1a54237 100644 --- a/src/db/defibsRepo.js +++ b/src/db/defibsRepo.js @@ -1,13 +1,13 @@ // Defibrillator repository — nearby queries with H3 geo-indexing. -import { latLngToCell, gridDisk } from "h3-js"; +import { latLngToCell, gridDisk } from "~/lib/h3"; -import getDb from "./openDb"; +import { getDbSafe } from "./openDb"; import haversine from "~/utils/geo/haversine"; // H3 average edge lengths in meters per resolution (0..15). const H3_EDGE_M = [ - 1107712, 418676, 158244, 59810, 22606, 8544, 3229, 1220, 461, 174, 65, 24, - 9, 3, 1, 0.5, + 1107712, 418676, 158244, 59810, 22606, 8544, 3229, 1220, 461, 174, 65, 24, 9, + 3, 1, 0.5, ]; const H3_RES = 8; @@ -29,8 +29,7 @@ function bboxClause(lat, lon, radiusMeters) { // 1 degree longitude shrinks with cos(lat) const dLon = radiusMeters / (111_320 * Math.cos((lat * Math.PI) / 180)); return { - clause: - "latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?", + clause: "latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?", params: [lat - dLat, lat + dLat, lon - dLon, lon + dLon], }; } @@ -75,11 +74,22 @@ export async function getNearbyDefibs({ disponible24hOnly = false, progressive = false, }) { - const db = await getDb(); + const { db, error } = await getDbSafe(); + if (!db) { + throw error || new Error("DAE DB unavailable"); + } const maxK = kForRadius(radiusMeters); if (progressive) { - return progressiveSearch(db, lat, lon, radiusMeters, limit, disponible24hOnly, maxK); + return progressiveSearch( + db, + lat, + lon, + radiusMeters, + limit, + disponible24hOnly, + maxK, + ); } // One-shot: compute full disk and query @@ -89,7 +99,15 @@ export async function getNearbyDefibs({ } // Progressive expansion: start at k=1, expand until enough results or maxK. -async function progressiveSearch(db, lat, lon, radiusMeters, limit, dispo24h, maxK) { +async function progressiveSearch( + db, + lat, + lon, + radiusMeters, + limit, + dispo24h, + maxK, +) { let allCandidates = []; const seenIds = new Set(); @@ -106,7 +124,13 @@ async function progressiveSearch(db, lat, lon, radiusMeters, limit, dispo24h, ma // Early exit: if we already have more candidates than limit, rank and check if (allCandidates.length >= limit) { - const ranked = rankAndFilter(allCandidates, lat, lon, radiusMeters, limit); + const ranked = rankAndFilter( + allCandidates, + lat, + lon, + radiusMeters, + limit, + ); if (ranked.length >= limit) return ranked; } } @@ -180,7 +204,10 @@ export async function getNearbyDefibsBbox({ limit, disponible24hOnly = false, }) { - const db = await getDb(); + const { db, error } = await getDbSafe(); + if (!db) { + throw error || new Error("DAE DB unavailable"); + } const { clause, params } = bboxClause(lat, lon, radiusMeters); let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h diff --git a/src/db/ensureEmbeddedDb.js b/src/db/ensureEmbeddedDb.js new file mode 100644 index 0000000..cd3f947 --- /dev/null +++ b/src/db/ensureEmbeddedDb.js @@ -0,0 +1,123 @@ +// Ensure the embedded pre-populated geodae.db is available on-device. +// +// This copies the bundled asset into Expo's SQLite directory: +// FileSystem.documentDirectory + 'SQLite/' + DB_NAME +// +// Both backends (expo-sqlite and op-sqlite) can open the DB from that location. +// +// IMPORTANT: +// - All native requires must stay inside functions so this file can be loaded +// in Jest/node without crashing. + +const DEFAULT_DB_NAME = "geodae.db"; + +function stripFileScheme(uri) { + return typeof uri === "string" && uri.startsWith("file://") + ? uri.slice("file://".length) + : uri; +} + +/** + * @typedef {Object} EnsureEmbeddedDbResult + * @property {string} dbName + * @property {string} sqliteDirUri + * @property {string} dbUri + * @property {boolean} copied + */ + +/** + * Copy the embedded DB asset into the Expo SQLite directory (idempotent). + * + * @param {Object} [options] + * @param {string} [options.dbName] + * @param {any} [options.assetModule] - Optional override for testing. + * @param {boolean} [options.overwrite] + * @returns {Promise} + */ +async function ensureEmbeddedDb(options = {}) { + const { + dbName = DEFAULT_DB_NAME, + assetModule = null, + overwrite = false, + } = options; + + // Lazy require: keeps Jest/node stable. + // eslint-disable-next-line global-require + const FileSystemModule = require("expo-file-system"); + const FileSystem = FileSystemModule?.default ?? FileSystemModule; + // eslint-disable-next-line global-require + const ExpoAssetModule = require("expo-asset"); + const ExpoAsset = ExpoAssetModule?.default ?? ExpoAssetModule; + const { Asset } = ExpoAsset; + + if (!FileSystem?.documentDirectory) { + throw new Error( + "[DAE_DB] expo-file-system unavailable (documentDirectory missing) — cannot stage embedded DB", + ); + } + if (!Asset?.fromModule) { + throw new Error( + "[DAE_DB] expo-asset unavailable (Asset.fromModule missing) — cannot stage embedded DB", + ); + } + + const sqliteDirUri = `${FileSystem.documentDirectory}SQLite`; + const dbUri = `${sqliteDirUri}/${dbName}`; + + // Ensure SQLite directory exists. + const dirInfo = await FileSystem.getInfoAsync(sqliteDirUri); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(sqliteDirUri, { intermediates: true }); + } + + const fileInfo = await FileSystem.getInfoAsync(dbUri); + const shouldCopy = + overwrite || + !fileInfo.exists || + (typeof fileInfo.size === "number" && fileInfo.size === 0); + + if (shouldCopy) { + let moduleId = assetModule; + if (moduleId == null) { + try { + // Bundled asset (must exist in repo/build output). + // Path is relative to src/db/ + // eslint-disable-next-line global-require + moduleId = require("../assets/db/geodae.db"); + } catch (e) { + const err = new Error( + "[DAE_DB] Embedded DB asset not found at src/assets/db/geodae.db. " + + "Run `yarn dae:build` (or ensure the asset is committed) and rebuild the dev client.", + ); + err.cause = e; + throw err; + } + } + + const asset = Asset.fromModule(moduleId); + await asset.downloadAsync(); + if (!asset.localUri) { + throw new Error( + "[DAE_DB] DAE DB asset missing localUri after Asset.downloadAsync()", + ); + } + + // Defensive: expo-asset returns file:// URIs; copyAsync wants URIs. + await FileSystem.copyAsync({ from: asset.localUri, to: dbUri }); + console.warn( + "[DAE_DB] Staged embedded geodae.db into SQLite directory:", + stripFileScheme(dbUri), + ); + + return { dbName, sqliteDirUri, dbUri, copied: true }; + } + + return { dbName, sqliteDirUri, dbUri, copied: false }; +} + +module.exports = { + __esModule: true, + DEFAULT_DB_NAME, + ensureEmbeddedDb, + stripFileScheme, +}; diff --git a/src/db/ensureEmbeddedDb.test.js b/src/db/ensureEmbeddedDb.test.js new file mode 100644 index 0000000..e20c579 --- /dev/null +++ b/src/db/ensureEmbeddedDb.test.js @@ -0,0 +1,98 @@ +const { ensureEmbeddedDb } = require("./ensureEmbeddedDb"); + +describe("db/ensureEmbeddedDb", () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + }); + + test("copies asset into documentDirectory/SQLite when file is missing", async () => { + const calls = { + makeDirectoryAsync: [], + copyAsync: [], + getInfoAsync: [], + }; + + jest.doMock( + "expo-file-system", + () => ({ + documentDirectory: "file:///docs/", + getInfoAsync: jest.fn(async (uri) => { + calls.getInfoAsync.push(uri); + if (uri === "file:///docs/SQLite") return { exists: false }; + if (uri === "file:///docs/SQLite/geodae.db") return { exists: false }; + return { exists: false }; + }), + makeDirectoryAsync: jest.fn(async (uri) => { + calls.makeDirectoryAsync.push(uri); + }), + copyAsync: jest.fn(async (args) => { + calls.copyAsync.push(args); + }), + }), + { virtual: true }, + ); + + const downloadAsync = jest.fn(async () => undefined); + jest.doMock( + "expo-asset", + () => ({ + Asset: { + fromModule: jest.fn(() => ({ + downloadAsync, + localUri: "file:///bundle/geodae.db", + })), + }, + }), + { virtual: true }, + ); + + const res = await ensureEmbeddedDb({ assetModule: 123 }); + + expect(res.dbUri).toBe("file:///docs/SQLite/geodae.db"); + expect(res.copied).toBe(true); + expect(calls.makeDirectoryAsync).toEqual(["file:///docs/SQLite"]); + expect(calls.copyAsync).toEqual([ + { from: "file:///bundle/geodae.db", to: "file:///docs/SQLite/geodae.db" }, + ]); + expect(downloadAsync).toHaveBeenCalled(); + }); + + test("does not copy when destination already exists and is non-empty", async () => { + const calls = { copyAsync: [] }; + jest.doMock( + "expo-file-system", + () => ({ + documentDirectory: "file:///docs/", + getInfoAsync: jest.fn(async (uri) => { + if (uri === "file:///docs/SQLite") return { exists: true }; + if (uri === "file:///docs/SQLite/geodae.db") { + return { exists: true, size: 42 }; + } + return { exists: true }; + }), + makeDirectoryAsync: jest.fn(async () => undefined), + copyAsync: jest.fn(async (args) => { + calls.copyAsync.push(args); + }), + }), + { virtual: true }, + ); + jest.doMock( + "expo-asset", + () => ({ + Asset: { + fromModule: jest.fn(() => ({ + downloadAsync: jest.fn(async () => undefined), + localUri: "file:///bundle/geodae.db", + })), + }, + }), + { virtual: true }, + ); + + const res = await ensureEmbeddedDb({ assetModule: 123 }); + expect(res.copied).toBe(false); + expect(calls.copyAsync).toEqual([]); + }); +}); diff --git a/src/db/openDb.js b/src/db/openDb.js index 8b5191b..8468147 100644 --- a/src/db/openDb.js +++ b/src/db/openDb.js @@ -1,41 +1,337 @@ -// Open the pre-built geodae SQLite database (Expo variant). -// Requires: expo-sqlite, expo-file-system, expo-asset -import * as SQLite from "expo-sqlite"; -import * as FileSystem from "expo-file-system"; -import { Asset } from "expo-asset"; +// Open the pre-built geodae SQLite database. +// +// IMPORTANT: This module must not crash at load time when a native SQLite +// backend is missing (Hermes: "Cannot find native module 'ExpoSQLite'"). +// +// Strategy: +// 1) Prefer @op-engineering/op-sqlite (bare RN) via ./openDbOpSqlite +// 2) Fallback to expo-sqlite (Expo) ONLY if it can be required +// 3) If nothing works, callers should use getDbSafe() and handle { db: null } const DB_NAME = "geodae.db"; let _dbPromise = null; +let _backendPromise = null; +let _selectedBackendName = null; + +function describeModuleShape(mod) { + const t = typeof mod; + const keys = + mod && (t === "object" || t === "function") ? Object.keys(mod) : []; + return { type: t, keys }; +} + +function pickOpener(mod, name) { + // Deterministic picking to reduce CJS/ESM/Metro export-shape ambiguity. + // Priority is explicit and matches wrapper contract. + const opener = + mod?.openDbOpSqlite ?? + mod?.openDbExpoSqlite ?? + mod?.openDb ?? + mod?.default ?? + mod; + + if (typeof opener === "function") return opener; + + const { type, keys } = describeModuleShape(mod); + throw new TypeError( + [ + `Backend module did not export a callable opener (backend=${name}).`, + `module typeof=${type} keys=[${keys.join(", ")}].`, + `picked typeof=${typeof opener}.`, + ].join(" "), + ); +} export default function getDb() { if (!_dbPromise) { - _dbPromise = initDb(); + _dbPromise = getDbImpl(); } return _dbPromise; } -async function initDb() { - const sqliteDir = `${FileSystem.documentDirectory}SQLite`; - const dbPath = `${sqliteDir}/${DB_NAME}`; +/** + * Non-throwing DB opener. + * + * v1 requirement: DB open failures must not crash the app. Downstream UI can + * display an error/empty state and keep overlays disabled. + * + * @returns {Promise<{ db: import('expo-sqlite').SQLiteDatabase | null, error: Error | null }>} + */ +export async function getDbSafe() { + try { + const db = await getDb(); + return { db, error: null }; + } catch (error) { + // Actionable runtime logging — include backend attempts + underlying error/stack. + // Keep behavior unchanged: do not crash, keep returning { db: null, error }. + const prefix = "[DAE_DB] Failed to open embedded DAE DB"; - // Ensure the SQLite directory exists - const dirInfo = await FileSystem.getInfoAsync(sqliteDir); - if (!dirInfo.exists) { - await FileSystem.makeDirectoryAsync(sqliteDir, { intermediates: true }); + const logErrorDetails = (label, err) => { + if (!err) { + console.warn(`${prefix} ${label} `); + return; + } + + const msg = err?.message; + const stack = err?.stack; + + // Log the raw error object first (best for Hermes / native errors). + console.warn(`${prefix} ${label} raw:`, err); + console.warn(`${prefix} ${label} message:`, msg); + if (stack) console.warn(`${prefix} ${label} stack:\n${stack}`); + + const cause = err?.cause; + if (cause) { + console.warn(`${prefix} ${label} cause raw:`, cause); + console.warn(`${prefix} ${label} cause message:`, cause?.message); + if (cause?.stack) { + console.warn(`${prefix} ${label} cause stack:\n${cause.stack}`); + } + } + }; + + // Primary error thrown by getDb()/selectBackend. + if (_selectedBackendName) { + console.warn(`${prefix} selected backend:`, _selectedBackendName); + } + logErrorDetails("(primary)", error); + + // Nested backend selection errors (attached by selectBackend()). + const backends = error?.backends; + if (Array.isArray(backends) && backends.length > 0) { + for (const entry of backends) { + const backend = entry?.backend ?? ""; + console.warn(`${prefix} backend attempted:`, backend); + logErrorDetails(`(backend=${backend})`, entry?.error); + } + } + + return { db: null, error }; } - - // Copy asset DB on first launch (or after app update clears documents) - const fileInfo = await FileSystem.getInfoAsync(dbPath); - if (!fileInfo.exists) { - const asset = Asset.fromModule(require("../assets/db/geodae.db")); - await asset.downloadAsync(); - await FileSystem.copyAsync({ from: asset.localUri, to: dbPath }); - } - - const db = await SQLite.openDatabaseAsync(DB_NAME); - // Read-only optimizations - await db.execAsync("PRAGMA journal_mode = WAL"); - await db.execAsync("PRAGMA cache_size = -8000"); // 8 MB - return db; +} + +async function getDbImpl() { + const backend = await selectBackend(); + return backend.getDb(); +} + +async function selectBackend() { + if (_backendPromise) return _backendPromise; + + _backendPromise = (async () => { + const errors = []; + + // 1) Prefer op-sqlite backend when available. + try { + let opBackendModule; + try { + console.warn( + "[DAE_DB] op-sqlite: requiring backend module ./openDbOpSqlite...", + ); + // eslint-disable-next-line global-require + opBackendModule = require("./openDbOpSqlite"); + + const opModuleType = typeof opBackendModule; + const opModuleKeys = + opBackendModule && + (typeof opBackendModule === "object" || + typeof opBackendModule === "function") + ? Object.keys(opBackendModule) + : []; + console.warn( + "[DAE_DB] op-sqlite: require ./openDbOpSqlite success", + `type=${opModuleType} keys=[${opModuleKeys.join(", ")}]`, + ); + } catch (requireError) { + console.warn( + "[DAE_DB] op-sqlite: require ./openDbOpSqlite FAILED:", + requireError?.message, + ); + const err = new Error("Failed to require ./openDbOpSqlite"); + // Preserve the underlying Metro/Hermes resolution failure. + err.cause = requireError; + throw err; + } + + if (opBackendModule == null) { + throw new TypeError( + "./openDbOpSqlite required successfully but returned null/undefined", + ); + } + + const openDbOp = pickOpener(opBackendModule, "op-sqlite"); + console.warn( + "[DAE_DB] op-sqlite: picked opener", + `typeof=${typeof openDbOp}`, + ); + const db = await openDbOp(); // validates open + schema + if (!db) throw new Error("op-sqlite backend returned a null DB instance"); + _selectedBackendName = "op-sqlite"; + return { name: "op-sqlite", getDb: () => db }; + } catch (error) { + errors.push({ backend: "op-sqlite", error }); + } + + // 2) Fallback to expo-sqlite backend ONLY if it can be required. + try { + const expoBackend = createExpoSqliteBackend(); + // Validate open; createExpoSqliteBackend() is already safe to call. + await expoBackend.getDb(); + _selectedBackendName = expoBackend?.name ?? "expo-sqlite"; + return expoBackend; + } catch (error) { + errors.push({ backend: "expo-sqlite", error }); + } + + const err = new Error( + "No SQLite backend available (tried: @op-engineering/op-sqlite, expo-sqlite)", + ); + // Attach details for debugging; callers should treat this as non-fatal. + // (Avoid AggregateError for broader Hermes compatibility.) + err.backends = errors; + throw err; + })(); + + return _backendPromise; +} + +function createExpoSqliteBackend() { + // All requires are inside the factory so a missing ExpoSQLite native module + // does not crash at module evaluation time. + + let openDbExpoSqlite; + let wrapperModule; + try { + // Expo SQLite wrapper uses static imports to make Metro/Hermes interop stable. + // eslint-disable-next-line global-require + wrapperModule = require("./openDbExpoSqlite"); + const expoModuleType = typeof wrapperModule; + const expoModuleKeys = + wrapperModule && + (typeof wrapperModule === "object" || typeof wrapperModule === "function") + ? Object.keys(wrapperModule) + : []; + console.warn( + "[DAE_DB] expo-sqlite: require ./openDbExpoSqlite success", + `type=${expoModuleType} keys=[${expoModuleKeys.join(", ")}]`, + ); + openDbExpoSqlite = pickOpener(wrapperModule, "expo-sqlite"); + } catch (requireError) { + const err = new Error("Failed to require ./openDbExpoSqlite"); + err.cause = requireError; + throw err; + } + + // Log what we actually picked (helps confirm Metro export shapes in the wild). + if (wrapperModule != null) { + console.warn( + "[DAE_DB] expo-sqlite: picked opener", + `typeof=${typeof openDbExpoSqlite}`, + ); + } + + let _expoDbPromise = null; + + function createLegacyAsyncFacade(legacyDb) { + const execSqlAsync = (sql, params = []) => + new Promise((resolve, reject) => { + const runner = + typeof legacyDb.readTransaction === "function" + ? legacyDb.readTransaction.bind(legacyDb) + : legacyDb.transaction.bind(legacyDb); + + runner((tx) => { + tx.executeSql( + sql, + params, + () => resolve(), + (_tx, err) => { + reject(err); + return true; + }, + ); + }); + }); + + const queryAllAsync = (sql, params = []) => + new Promise((resolve, reject) => { + const runner = + typeof legacyDb.readTransaction === "function" + ? legacyDb.readTransaction.bind(legacyDb) + : legacyDb.transaction.bind(legacyDb); + + runner((tx) => { + tx.executeSql( + sql, + params, + (_tx, result) => { + const rows = []; + const len = result?.rows?.length ?? 0; + for (let i = 0; i < len; i++) { + rows.push(result.rows.item(i)); + } + resolve(rows); + }, + (_tx, err) => { + reject(err); + return true; + }, + ); + }); + }); + + return { + // Methods used by this repo + execAsync(sql) { + return execSqlAsync(sql); + }, + getAllAsync(sql, params) { + return queryAllAsync(sql, params); + }, + async getFirstAsync(sql, params) { + const rows = await queryAllAsync(sql, params); + return rows[0] ?? null; + }, + // Keep a reference to the underlying legacy DB for debugging. + _legacyDb: legacyDb, + }; + } + + async function initDbExpo() { + // eslint-disable-next-line global-require + const { ensureEmbeddedDb } = require("./ensureEmbeddedDb"); + // Stage the DB into the Expo SQLite dir before opening. + await ensureEmbeddedDb({ dbName: DB_NAME }); + + let db; + // openDbExpoSqlite() can be async (openDatabaseAsync) or sync (openDatabase). + db = await openDbExpoSqlite(DB_NAME); + + // Expo Go / older expo-sqlite: provide an async facade compatible with + // the subset of methods used in this repo (execAsync + getAllAsync). + if (db && typeof db.execAsync !== "function") { + db = createLegacyAsyncFacade(db); + } + + // Read-only optimizations + await db.execAsync("PRAGMA journal_mode = WAL"); + await db.execAsync("PRAGMA cache_size = -8000"); // 8 MB + + // eslint-disable-next-line global-require + const { assertDbHasTable } = require("./validateDbSchema"); + await assertDbHasTable(db, "defibs"); + + return db; + } + + return { + name: "expo-sqlite", + getDb() { + if (!_expoDbPromise) { + _expoDbPromise = initDbExpo(); + } + return _expoDbPromise; + }, + }; } diff --git a/src/db/openDb.op-sqlite.js b/src/db/openDb.op-sqlite.js deleted file mode 100644 index 52a60d3..0000000 --- a/src/db/openDb.op-sqlite.js +++ /dev/null @@ -1,17 +0,0 @@ -// Open the pre-built geodae SQLite database (Bare RN variant). -// Requires: @op-engineering/op-sqlite -// Install: npm install @op-engineering/op-sqlite -// Place geodae.db in: -// Android: android/app/src/main/assets/geodae.db -// iOS: add geodae.db to Xcode project "Copy Bundle Resources" -import { open } from "@op-engineering/op-sqlite"; - -let _db = null; - -export default function getDb() { - if (!_db) { - _db = open({ name: "geodae.db", readOnly: true }); - _db.execute("PRAGMA cache_size = -8000"); // 8 MB - } - return _db; -} diff --git a/src/db/openDbExpoSqlite.js b/src/db/openDbExpoSqlite.js new file mode 100644 index 0000000..f816197 --- /dev/null +++ b/src/db/openDbExpoSqlite.js @@ -0,0 +1,87 @@ +// Expo SQLite wrapper (require-safe, Metro/Hermes friendly). +// +// Requirements from runtime backend selection: +// - Must NOT crash at module evaluation time when ExpoSQLite native module is missing. +// - Must be safe to load via `require('./openDbExpoSqlite')` under Metro/Hermes. +// - Must export a callable `openDbExpoSqlite()` function via CommonJS exports. +// +// IMPORTANT: Do NOT use top-level `import` from expo-sqlite here. + +function describeKeys(x) { + if (!x) return []; + const t = typeof x; + if (t !== "object" && t !== "function") return []; + try { + return Object.keys(x); + } catch { + return []; + } +} + +function requireExpoSqlite() { + // Lazily require so missing native module does not crash at module evaluation time. + // eslint-disable-next-line global-require + const mod = require("expo-sqlite"); + const candidate = mod?.default ?? mod; + + const openDatabaseAsync = + candidate?.openDatabaseAsync ?? + mod?.openDatabaseAsync ?? + mod?.default?.openDatabaseAsync; + const openDatabase = + candidate?.openDatabase ?? mod?.openDatabase ?? mod?.default?.openDatabase; + + return { + mod, + candidate, + openDatabaseAsync, + openDatabase, + }; +} + +/** + * Open an expo-sqlite database using whichever API exists. + * + * @param {string} dbName + * @returns {Promise} SQLiteDatabase (new API) or legacy Database (sync open) + */ +async function openDbExpoSqlite(dbName) { + const api = requireExpoSqlite(); + + if (typeof api.openDatabaseAsync === "function") { + return api.openDatabaseAsync(dbName); + } + if (typeof api.openDatabase === "function") { + // Legacy expo-sqlite API (sync open) + return api.openDatabase(dbName); + } + + const modKeys = describeKeys(api.mod); + const defaultKeys = describeKeys(api.mod?.default); + const candidateKeys = describeKeys(api.candidate); + + const err = new TypeError( + [ + "expo-sqlite require() did not expose openDatabaseAsync nor openDatabase.", + `module typeof=${typeof api.mod} keys=[${modKeys.join(", ")}].`, + `default typeof=${typeof api.mod?.default} keys=[${defaultKeys.join( + ", ", + )}].`, + `candidate typeof=${typeof api.candidate} keys=[${candidateKeys.join( + ", ", + )}].`, + ].join(" "), + ); + err.expoSqliteModuleKeys = modKeys; + err.expoSqliteDefaultKeys = defaultKeys; + err.expoSqliteCandidateKeys = candidateKeys; + throw err; +} + +// Explicit CommonJS export shape (so require() always returns a non-null object). +module.exports = { + __esModule: true, + openDbExpoSqlite, + openDb: openDbExpoSqlite, + default: openDbExpoSqlite, +}; diff --git a/src/db/openDbOpSqlite.js b/src/db/openDbOpSqlite.js new file mode 100644 index 0000000..4a7cbf7 --- /dev/null +++ b/src/db/openDbOpSqlite.js @@ -0,0 +1,225 @@ +// Open the pre-built geodae SQLite database (Bare RN variant). +// Requires: @op-engineering/op-sqlite +// Install: npm install @op-engineering/op-sqlite +// Place geodae.db in: +// Android: android/app/src/main/assets/geodae.db +// iOS: add geodae.db to Xcode project "Copy Bundle Resources" +// +// NOTE: This module is intentionally written in CommonJS to make Metro/Hermes +// `require('./openDbOpSqlite')` resolution stable across CJS/ESM interop. + +function requireOpSqliteOpen() { + // Lazy require to keep this module loadable in Jest/node. + // (op-sqlite ships a node/ dist that is ESM and not Jest-CJS friendly.) + // eslint-disable-next-line global-require + const mod = require("@op-engineering/op-sqlite"); + const open = mod?.open ?? mod?.default?.open; + if (typeof open !== "function") { + const keys = + mod && (typeof mod === "object" || typeof mod === "function") + ? Object.keys(mod) + : []; + throw new TypeError( + `[DAE_DB] op-sqlite require() did not expose an open() function (keys=[${keys.join( + ", ", + )}])`, + ); + } + return open; +} + +const DB_NAME = "geodae.db"; + +let _rawDb = null; +let _dbPromise = null; + +function describeDbShape(db) { + if (!db) return { type: typeof db, keys: [] }; + const t = typeof db; + if (t !== "object" && t !== "function") return { type: t, keys: [] }; + try { + return { type: t, keys: Object.keys(db) }; + } catch { + return { type: t, keys: [] }; + } +} + +/** + * Adapt an op-sqlite DB instance to the async API expected by repo code. + * + * Required interface (subset of expo-sqlite modern API): + * - execAsync(sql) + * - getAllAsync(sql, params) + * - getFirstAsync(sql, params) + * + * op-sqlite exposes: execute()/executeAsync() returning { rows: [...] }. + * + * @param {any} opDb + */ +function adaptDbToRepoInterface(opDb) { + if (!opDb) { + throw new TypeError( + "[DAE_DB] op-sqlite adapter: DB instance is null/undefined", + ); + } + + // Idempotency: if caller already passes an expo-sqlite-like DB, keep it. + if ( + typeof opDb.execAsync === "function" && + typeof opDb.getAllAsync === "function" && + typeof opDb.getFirstAsync === "function" + ) { + return opDb; + } + + const executeAsync = + (typeof opDb.executeAsync === "function" && opDb.executeAsync.bind(opDb)) || + (typeof opDb.execute === "function" && opDb.execute.bind(opDb)); + const executeSync = + typeof opDb.executeSync === "function" ? opDb.executeSync.bind(opDb) : null; + + if (!executeAsync && !executeSync) { + const shape = describeDbShape(opDb); + throw new TypeError( + [ + "[DAE_DB] op-sqlite adapter: cannot adapt DB.", + "Expected executeAsync()/execute() or executeSync() methods.", + `db typeof=${shape.type} keys=[${shape.keys.join(", ")}]`, + ].join(" "), + ); + } + + const runQueryAsync = async (sql, params) => { + try { + if (executeAsync) { + return params != null + ? await executeAsync(sql, params) + : await executeAsync(sql); + } + // Sync fallback (best effort): wrap in a Promise for repo compatibility. + return params != null ? executeSync(sql, params) : executeSync(sql); + } catch (e) { + // Make it actionable for end users/devs. + const err = new Error( + `[DAE_DB] Query failed (op-sqlite). ${e?.message ?? String(e)}`, + ); + err.cause = e; + err.sql = sql; + err.params = params; + throw err; + } + }; + + return { + async execAsync(sql) { + await runQueryAsync(sql); + }, + async getAllAsync(sql, params = []) { + const res = await runQueryAsync(sql, params); + const rows = res?.rows; + // op-sqlite returns rows as array of objects. + if (Array.isArray(rows)) return rows; + // Defensive: if a driver returns no rows field for non-SELECT. + return []; + }, + async getFirstAsync(sql, params = []) { + const rows = await this.getAllAsync(sql, params); + return rows[0] ?? null; + }, + // Keep a reference to the underlying DB for debugging / escape hatches. + _opDb: opDb, + }; +} + +async function openDbOpSqlite() { + if (_dbPromise) return _dbPromise; + + _dbPromise = (async () => { + // Stage the embedded DB in the Expo SQLite dir first. + // This prevents op-sqlite from creating/opening an empty DB. + let sqliteDirUri; + try { + // eslint-disable-next-line global-require + const { ensureEmbeddedDb } = require("./ensureEmbeddedDb"); + const { sqliteDirUri: dir } = await ensureEmbeddedDb({ dbName: DB_NAME }); + sqliteDirUri = dir; + } catch (e) { + const err = new Error( + "[DAE_DB] Failed to stage embedded DB before opening (op-sqlite)", + ); + err.cause = e; + throw err; + } + + // NOTE: op-sqlite open() params are not identical to expo-sqlite. + // Pass only supported keys to avoid native-side strict validation. + const open = requireOpSqliteOpen(); + _rawDb = open({ name: DB_NAME, location: sqliteDirUri }); + if (!_rawDb) { + throw new Error("op-sqlite open() returned a null DB instance"); + } + + // Read-only-ish optimizations. + // Prefer executeSync when available. + try { + if (typeof _rawDb.executeSync === "function") { + _rawDb.executeSync("PRAGMA cache_size = -8000"); // 8 MB + _rawDb.executeSync("PRAGMA query_only = ON"); + } else if (typeof _rawDb.execute === "function") { + // Fire-and-forget; adapter methods will still work regardless. + _rawDb.execute("PRAGMA cache_size = -8000"); + _rawDb.execute("PRAGMA query_only = ON"); + } + } catch { + // Non-fatal: keep DB usable even if pragmas fail. + } + + const db = adaptDbToRepoInterface(_rawDb); + + // Runtime guard: fail fast with a clear message if adapter didn't produce the expected API. + if ( + !db || + typeof db.execAsync !== "function" || + typeof db.getAllAsync !== "function" || + typeof db.getFirstAsync !== "function" + ) { + const shape = describeDbShape(db); + throw new TypeError( + [ + "[DAE_DB] op-sqlite adapter produced an invalid DB facade.", + `typeof=${shape.type} keys=[${shape.keys.join(", ")}]`, + ].join(" "), + ); + } + + // Validate schema early to avoid later "no such table" runtime errors. + // eslint-disable-next-line global-require + const { assertDbHasTable } = require("./validateDbSchema"); + await assertDbHasTable(db, "defibs"); + + // Helpful for debugging in the wild. + try { + if (typeof _rawDb.getDbPath === "function") { + console.warn("[DAE_DB] op-sqlite opened DB path:", _rawDb.getDbPath()); + } + } catch { + // Non-fatal. + } + + return db; + })(); + + return _dbPromise; +} + +// Exports (CJS + ESM-ish): +// Keep `require('./openDbOpSqlite')` returning a non-null *object* so Metro/Hermes +// cannot hand back a nullish / unexpected callable export shape. +module.exports = { + __esModule: true, + openDbOpSqlite, + openDb: openDbOpSqlite, + default: openDbOpSqlite, + // Named export for unit tests. + adaptDbToRepoInterface, +}; diff --git a/src/db/openDbOpSqlite.test.js b/src/db/openDbOpSqlite.test.js new file mode 100644 index 0000000..0b67f0e --- /dev/null +++ b/src/db/openDbOpSqlite.test.js @@ -0,0 +1,32 @@ +const { adaptDbToRepoInterface } = require("./openDbOpSqlite"); + +describe("db/openDbOpSqlite adapter", () => { + test("creates execAsync/getAllAsync/getFirstAsync from executeAsync", async () => { + const executeAsync = jest.fn(async (sql, params) => { + if (sql === "SELECT 1") return { rows: [{ a: 1 }] }; + if (sql === "SELECT empty") return { rows: [] }; + return { rows: [] }; + }); + + const db = adaptDbToRepoInterface({ executeAsync }); + + expect(typeof db.execAsync).toBe("function"); + expect(typeof db.getAllAsync).toBe("function"); + expect(typeof db.getFirstAsync).toBe("function"); + + await db.execAsync("PRAGMA cache_size = -8000"); + expect(executeAsync).toHaveBeenCalled(); + + const rows = await db.getAllAsync("SELECT 1", []); + expect(rows).toEqual([{ a: 1 }]); + + const first = await db.getFirstAsync("SELECT empty", []); + expect(first).toBe(null); + }); + + test("throws a clear error when no execute method exists", () => { + expect(() => adaptDbToRepoInterface({})).toThrow( + /op-sqlite adapter: cannot adapt DB/i, + ); + }); +}); diff --git a/src/db/validateDbSchema.js b/src/db/validateDbSchema.js new file mode 100644 index 0000000..d6de387 --- /dev/null +++ b/src/db/validateDbSchema.js @@ -0,0 +1,34 @@ +// Schema validation for the embedded geodae.db. + +/** + * Validate that the embedded DB looks like the pre-populated database. + * + * This is a cheap query and catches cases where we accidentally opened a new/ + * empty DB file (which then fails later with "no such table: defibs"). + * + * @param {Object} db + * @param {string} [tableName] + */ +async function assertDbHasTable(db, tableName = "defibs") { + if (!db || typeof db.getFirstAsync !== "function") { + throw new TypeError( + "[DAE_DB] Cannot validate schema: db.getFirstAsync() missing", + ); + } + + const row = await db.getFirstAsync( + "SELECT name FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;", + [tableName], + ); + + if (!row || row.name !== tableName) { + throw new Error( + `[DAE_DB] Embedded DB missing ${tableName} table (likely opened empty DB)`, + ); + } +} + +module.exports = { + __esModule: true, + assertDbHasTable, +}; diff --git a/src/db/validateDbSchema.test.js b/src/db/validateDbSchema.test.js new file mode 100644 index 0000000..bdc18c0 --- /dev/null +++ b/src/db/validateDbSchema.test.js @@ -0,0 +1,19 @@ +const { assertDbHasTable } = require("./validateDbSchema"); + +describe("db/validateDbSchema", () => { + test("passes when table exists", async () => { + const db = { + getFirstAsync: jest.fn(async () => ({ name: "defibs" })), + }; + await expect(assertDbHasTable(db, "defibs")).resolves.toBeUndefined(); + }); + + test("throws a clear error when table is missing", async () => { + const db = { + getFirstAsync: jest.fn(async () => null), + }; + await expect(assertDbHasTable(db, "defibs")).rejects.toThrow( + /missing defibs table/i, + ); + }); +}); diff --git a/src/layout/LayoutProviders.js b/src/layout/LayoutProviders.js index ed3d681..dbca9c5 100644 --- a/src/layout/LayoutProviders.js +++ b/src/layout/LayoutProviders.js @@ -15,6 +15,8 @@ import { Dark as NavigationDarkTheme, } from "~/theme/navigation"; +import DaeSuggestModal from "~/containers/DaeSuggestModal"; + // import { navActions } from "~/stores"; // const linking = { @@ -86,6 +88,9 @@ export default function LayoutProviders({ layoutKey, setLayoutKey, children }) { > {children} + + {/* Global persistent modal: mounted outside navigation tree, but can navigate via RootNav ref */} + ); diff --git a/src/lib/h3/index.js b/src/lib/h3/index.js new file mode 100644 index 0000000..100b5f4 --- /dev/null +++ b/src/lib/h3/index.js @@ -0,0 +1,63 @@ +// Hermes-safe H3 wrapper. +// +// Why this exists: +// - `h3-js`'s default entry (`dist/h3-js.js`) is a Node-oriented Emscripten build. +// - Metro (React Native) does not reliably honor the package.json `browser` field, +// so RN/Hermes may resolve the Node build, which relies on Node Buffer encodings +// (e.g. "utf-16le") and crashes under Hermes. +// +// This wrapper forces the browser bundle when running under Hermes. + +/* eslint-disable global-require */ + +function isHermes() { + // https://reactnative.dev/docs/hermes + return typeof global === "object" && !!global.HermesInternal; +} + +function supportsUtf16leTextDecoder() { + if (typeof global !== "object" || typeof global.TextDecoder !== "function") { + return false; + } + try { + // Hermes' built-in TextDecoder historically supports only utf-8. + // `h3-js` bundles try to instantiate a UTF-16LE decoder at module init. + // If unsupported, Hermes throws: RangeError: Unknown encoding: utf-16le + // Detect support and fall back to the non-TextDecoder path when needed. + // eslint-disable-next-line no-new + new global.TextDecoder("utf-16le"); + return true; + } catch { + return false; + } +} + +// Keep the choice static at module init so exports are stable. +let h3; + +if (isHermes()) { + // Force browser bundle (no Node fs/path/Buffer branches). + // Additionally, if Hermes' TextDecoder doesn't support utf-16le, temporarily + // hide it so h3-js uses its pure-JS decoding fallback instead. + const hasUtf16 = supportsUtf16leTextDecoder(); + const originalTextDecoder = global.TextDecoder; + if (!hasUtf16) { + global.TextDecoder = undefined; + } + try { + h3 = require("h3-js/dist/browser/h3-js"); + } finally { + if (!hasUtf16) { + global.TextDecoder = originalTextDecoder; + } + } +} else { + // Jest/node tests can keep using the default build. + h3 = require("h3-js"); +} + +export const latLngToCell = h3.latLngToCell; +export const gridDisk = h3.gridDisk; + +// Export the full namespace for any other future usage. +export default h3; diff --git a/src/navigation/Drawer.js b/src/navigation/Drawer.js index ba478f8..46306bf 100644 --- a/src/navigation/Drawer.js +++ b/src/navigation/Drawer.js @@ -23,6 +23,8 @@ import AlertAggListArchived from "~/scenes/AlertAggListArchived"; import About from "~/scenes/About"; import Contribute from "~/scenes/Contribute"; import Location from "~/scenes/Location"; +import DAEList from "~/scenes/DAEList"; +import DAEItem from "~/scenes/DAEItem"; import Developer from "~/scenes/Developer"; import HelpSignal from "~/scenes/HelpSignal"; @@ -366,6 +368,22 @@ export default React.memo(function DrawerNav() { }} listeners={{}} /> + ( + + ), + unmountOnBlur: true, + }} + listeners={{}} + /> + {devModeEnabled && ( !properties.isUserLocation) .sort(({ properties: x }, { properties: y }) => { + // DAE features should win (easy to tap) + if (x.isDefib && !y.isDefib) { + return -1; + } + if (!x.isDefib && y.isDefib) { + return 1; + } + // if both cluster priority is given to higher level if (x.cluster && y.cluster) { return x.x_max_level_num < y.x_max_level_num ? 1 : -1; diff --git a/src/scenes/AlertCurMap/useFeatures.js b/src/scenes/AlertCurMap/useFeatures.js index 0db219e..97da758 100644 --- a/src/scenes/AlertCurMap/useFeatures.js +++ b/src/scenes/AlertCurMap/useFeatures.js @@ -4,6 +4,8 @@ import Supercluster from "supercluster"; import useShallowMemo from "~/hooks/useShallowMemo"; import useShallowEffect from "~/hooks/useShallowEffect"; import { deepEqual } from "fast-equals"; +import { useDefibsState } from "~/stores"; +import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; export default function useFeatures({ clusterFeature, @@ -13,6 +15,11 @@ export default function useFeatures({ route, alertCoords, }) { + const { showDefibsOnAlertMap, corridorDefibs } = useDefibsState([ + "showDefibsOnAlertMap", + "corridorDefibs", + ]); + // Check if we have valid coordinates const hasUserCoords = userCoords && userCoords.longitude !== null && userCoords.latitude !== null; @@ -95,15 +102,57 @@ export default function useFeatures({ } }); + // Add defibs (DAE) as separate, non-clustered features + if (showDefibsOnAlertMap && Array.isArray(corridorDefibs)) { + corridorDefibs.forEach((defib) => { + const lon = defib.longitude; + const lat = defib.latitude; + if ( + lon === null || + lat === null || + lon === undefined || + lat === undefined + ) { + return; + } + const { status } = getDefibAvailability( + defib.horaires_std, + defib.disponible_24h, + ); + const icon = + status === "open" ? "green" : status === "closed" ? "red" : "grey"; + const id = `defib:${defib.id}`; + + features.push({ + type: "Feature", + id, + properties: { + id, + icon, + defib, + isDefib: true, + }, + geometry: { + type: "Point", + coordinates: [lon, lat], + }, + }); + }); + } + return { type: "FeatureCollection", features, }; - }, [list]); + }, [list, showDefibsOnAlertMap, corridorDefibs]); const superCluster = useShallowMemo(() => { const cluster = new Supercluster({ radius: 40, maxZoom: 16 }); - cluster.load(featureCollection.features); + // Do not cluster defibs in v1 + const clusterable = featureCollection.features.filter( + (f) => !f?.properties?.isDefib, + ); + cluster.load(clusterable); return cluster; }, [featureCollection.features]); // console.log({ superCluster: JSON.stringify(superCluster) }); @@ -123,6 +172,15 @@ export default function useFeatures({ const userCoordinates = [userCoords.longitude, userCoords.latitude]; const features = [...clusterFeature]; + // Ensure defibs are always present even if they are not part of the clustered set + if (showDefibsOnAlertMap && Array.isArray(featureCollection.features)) { + featureCollection.features.forEach((f) => { + if (f?.properties?.isDefib) { + features.push(f); + } + }); + } + // Only add route line if we have valid route data const isRouteEnding = route?.distance !== 0 || routeCoords?.length === 0; const hasValidAlertCoords = @@ -157,6 +215,8 @@ export default function useFeatures({ }, [ setShape, clusterFeature, + featureCollection.features, + showDefibsOnAlertMap, userCoords, hasUserCoords, routeCoords, diff --git a/src/scenes/AlertCurMap/useOnPress.js b/src/scenes/AlertCurMap/useOnPress.js index e93d4f0..2a2c9d4 100644 --- a/src/scenes/AlertCurMap/useOnPress.js +++ b/src/scenes/AlertCurMap/useOnPress.js @@ -3,7 +3,7 @@ import { useNavigation } from "@react-navigation/native"; import { ANIMATION_DURATION } from "~/containers/Map/constants"; -import { alertActions } from "~/stores"; +import { alertActions, defibsActions } from "~/stores"; import { createLogger } from "~/lib/logger"; import { FEATURE_SCOPES, UI_SCOPES } from "~/lib/logger/scopes"; @@ -29,6 +29,12 @@ export default function useOnPress({ const [feature] = features; const { properties } = feature; + if (properties?.isDefib && properties?.defib) { + defibsActions.setSelectedDefib(properties.defib); + navigation.navigate("DAEItem"); + return; + } + if (properties.cluster) { // center and expand to cluster's points const { current: camera } = cameraRef; diff --git a/src/scenes/AlertCurOverview/index.js b/src/scenes/AlertCurOverview/index.js index be5b2f2..96e75a5 100644 --- a/src/scenes/AlertCurOverview/index.js +++ b/src/scenes/AlertCurOverview/index.js @@ -6,14 +6,17 @@ import { MaterialCommunityIcons } from "@expo/vector-icons"; import { deepEqual } from "fast-equals"; import withConnectivity from "~/hoc/withConnectivity"; +import { useToast } from "~/lib/toast-notifications"; import { useAlertState, useSessionState, alertActions, useAggregatedMessagesState, + defibsActions, } from "~/stores"; import { getCurrentLocation } from "~/location"; +import { getStoredLocation } from "~/location/storage"; import alertBigButtonBgMap from "~/assets/img/alert-big-button-bg-map.png"; import alertBigButtonBgMapGrey from "~/assets/img/alert-big-button-bg-map-grey.png"; @@ -79,6 +82,99 @@ export default withConnectivity( const isSent = userId === sessionUserId; const navigation = useNavigation(); + const toast = useToast(); + + const [loadingDaeCorridor, setLoadingDaeCorridor] = useState(false); + + const showDefibsOnAlertMap = useCallback(async () => { + if (loadingDaeCorridor) { + return; + } + setLoadingDaeCorridor(true); + try { + const alertLonLat = alert?.location?.coordinates; + const hasAlertLonLat = + Array.isArray(alertLonLat) && + alertLonLat.length === 2 && + alertLonLat[0] !== null && + alertLonLat[1] !== null; + + if (!hasAlertLonLat) { + toast.show("Position de l'alerte indisponible", { + placement: "top", + duration: 4000, + hideOnPress: true, + }); + return; + } + + // 1) Current coords if possible + let coords = await getCurrentLocation(); + + // 2) Fallback to last-known coords if needed + const hasCoords = + coords && coords.latitude !== null && coords.longitude !== null; + if (!hasCoords) { + const lastKnown = await getStoredLocation(); + coords = lastKnown?.coords || coords; + } + + const hasFinalCoords = + coords && coords.latitude !== null && coords.longitude !== null; + if (!hasFinalCoords) { + toast.show( + "Localisation indisponible : activez la géolocalisation pour afficher les défibrillateurs.", + { + placement: "top", + duration: 6000, + hideOnPress: true, + }, + ); + return; + } + + const userLonLat = [coords.longitude, coords.latitude]; + + const { error } = await defibsActions.loadCorridor({ + userLonLat, + alertLonLat, + }); + + if (error) { + defibsActions.setShowDefibsOnAlertMap(false); + toast.show( + "Impossible de charger les défibrillateurs (base hors-ligne indisponible).", + { + placement: "top", + duration: 6000, + hideOnPress: true, + }, + ); + return; + } + + defibsActions.setShowDefibsOnAlertMap(true); + + navigation.navigate("Main", { + screen: "AlertCur", + params: { + screen: "AlertCurTab", + params: { + screen: "AlertCurMap", + }, + }, + }); + } catch (error) { + defibsActions.setShowDefibsOnAlertMap(false); + toast.show("Erreur lors du chargement des défibrillateurs", { + placement: "top", + duration: 6000, + hideOnPress: true, + }); + } finally { + setLoadingDaeCorridor(false); + } + }, [alert, loadingDaeCorridor, navigation, toast]); const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION); const notifyAround = useCallback(async () => { @@ -398,6 +494,33 @@ export default withConnectivity( )} + {isOpen && alert.location?.coordinates && ( + + + + )} + {!isSent && alert.location?.coordinates && ( )} diff --git a/src/scenes/AlertCurOverview/styles.js b/src/scenes/AlertCurOverview/styles.js index 9a07c39..3ae0cd5 100644 --- a/src/scenes/AlertCurOverview/styles.js +++ b/src/scenes/AlertCurOverview/styles.js @@ -70,6 +70,11 @@ export default createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({ actionComingHelpButton: {}, actionComingHelpText: {}, actionComingHelpIcon: {}, + actionShowDefibsButton: { + backgroundColor: colors.blue, + }, + actionShowDefibsText: {}, + actionShowDefibsIcon: {}, actionSmsButton: {}, actionSmsText: {}, actionSmsIcon: {}, diff --git a/src/scenes/DAEItem/Carte.js b/src/scenes/DAEItem/Carte.js new file mode 100644 index 0000000..486d12f --- /dev/null +++ b/src/scenes/DAEItem/Carte.js @@ -0,0 +1,385 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { View, StyleSheet } from "react-native"; +import Maplibre from "@maplibre/maplibre-react-native"; +import polyline from "@mapbox/polyline"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { Button } from "react-native-paper"; + +import MapView from "~/containers/Map/MapView"; +import Camera from "~/containers/Map/Camera"; +import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker"; +import { DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants"; + +import Text from "~/components/Text"; +import Loader from "~/components/Loader"; +import { useTheme } from "~/theme"; +import { useDefibsState, useNetworkState } from "~/stores"; +import useLocation from "~/hooks/useLocation"; +import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; +import { osmProfileUrl } from "~/scenes/AlertCurMap/routing"; + +const STATUS_COLORS = { + open: "#4CAF50", + closed: "#F44336", + unknown: "#9E9E9E", +}; + +function formatDuration(seconds) { + if (!seconds || seconds <= 0) return ""; + const mins = Math.round(seconds / 60); + if (mins < 60) return `${mins} min`; + const h = Math.floor(mins / 60); + const m = mins % 60; + return m > 0 ? `${h}h${m}` : `${h}h`; +} + +function formatDistance(meters) { + if (!meters || meters <= 0) return ""; + if (meters < 1000) return `${Math.round(meters)} m`; + return `${(meters / 1000).toFixed(1)} km`; +} + +export default React.memo(function DAEItemCarte() { + const { colors } = useTheme(); + const { selectedDefib: defib } = useDefibsState(["selectedDefib"]); + const { hasInternetConnection } = useNetworkState(["hasInternetConnection"]); + const { coords, isLastKnown, lastKnownTimestamp } = useLocation(); + + const mapRef = useRef(); + const cameraRef = useRef(); + const [cameraKey, setCameraKey] = useState(1); + const abortControllerRef = useRef(null); + + const refreshCamera = useCallback(() => { + setCameraKey(`${Date.now()}`); + }, []); + + const hasUserCoords = + coords && coords.latitude !== null && coords.longitude !== null; + const hasDefibCoords = defib && defib.latitude && defib.longitude; + + const [routeCoords, setRouteCoords] = useState(null); + const [routeInfo, setRouteInfo] = useState(null); + const [routeError, setRouteError] = useState(null); + const [loadingRoute, setLoadingRoute] = useState(false); + + const profile = "foot"; // walking itinerary to defib + + // Compute route + useEffect(() => { + if (!hasUserCoords || !hasDefibCoords || !hasInternetConnection) { + return; + } + + // Abort any previous request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + const fetchRoute = async () => { + setLoadingRoute(true); + setRouteError(null); + try { + const origin = `${coords.longitude},${coords.latitude}`; + const target = `${defib.longitude},${defib.latitude}`; + const osrmUrl = osmProfileUrl[profile] || osmProfileUrl.foot; + const url = `${osrmUrl}/route/v1/${profile}/${origin};${target}?overview=full&steps=true`; + + const res = await fetch(url, { signal: controller.signal }); + const result = await res.json(); + + if (result.routes && result.routes.length > 0) { + const route = result.routes[0]; + const decoded = polyline + .decode(route.geometry) + .map((p) => p.reverse()); + setRouteCoords(decoded); + setRouteInfo({ + distance: route.distance, + duration: route.duration, + }); + } + } catch (err) { + if (err.name !== "AbortError") { + console.warn("Route calculation failed:", err.message); + setRouteError(err); + } + } finally { + setLoadingRoute(false); + } + }; + + fetchRoute(); + + return () => { + controller.abort(); + }; + }, [ + hasUserCoords, + hasDefibCoords, + hasInternetConnection, + coords, + defib, + profile, + ]); + + // Defib marker GeoJSON + const defibGeoJSON = useMemo(() => { + if (!hasDefibCoords) return null; + const { status } = getDefibAvailability( + defib.horaires_std, + defib.disponible_24h, + ); + return { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { + type: "Point", + coordinates: [defib.longitude, defib.latitude], + }, + properties: { + id: defib.id, + nom: defib.nom || "Défibrillateur", + color: STATUS_COLORS[status], + }, + }, + ], + }; + }, [defib, hasDefibCoords]); + + // Route line GeoJSON + const routeGeoJSON = useMemo(() => { + if (!routeCoords || routeCoords.length < 2) return null; + return { + type: "Feature", + geometry: { + type: "LineString", + coordinates: routeCoords, + }, + }; + }, [routeCoords]); + + // Camera bounds to show both user + defib + const bounds = useMemo(() => { + if (!hasUserCoords || !hasDefibCoords) return null; + const lats = [coords.latitude, defib.latitude]; + const lons = [coords.longitude, defib.longitude]; + return { + ne: [Math.max(...lons), Math.max(...lats)], + sw: [Math.min(...lons), Math.min(...lats)], + }; + }, [hasUserCoords, hasDefibCoords, coords, defib]); + + if (!defib) return null; + + return ( + + {/* Offline banner */} + {!hasInternetConnection && ( + + + + Hors ligne — l'itinéraire n'est pas disponible + + + )} + + {/* Route info bar */} + {routeInfo && ( + + + + {formatDistance(routeInfo.distance)} + {routeInfo.duration + ? ` · ${formatDuration(routeInfo.duration)}` + : ""} + + {loadingRoute && ( + + Mise à jour… + + )} + + )} + + + + + {/* Route line */} + {routeGeoJSON && ( + + + + )} + + {/* Defib marker */} + {defibGeoJSON && ( + + + + + )} + + {/* User location */} + {isLastKnown && hasUserCoords ? ( + + ) : ( + + )} + + + {/* Route error */} + {routeError && !loadingRoute && ( + + + Impossible de calculer l'itinéraire + + + )} + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + offlineBanner: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 10, + gap: 8, + }, + offlineBannerText: { + fontSize: 13, + flex: 1, + }, + routeInfoBar: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + gap: 8, + }, + routeInfoText: { + fontSize: 15, + fontWeight: "600", + flex: 1, + }, + routeInfoLoading: { + fontSize: 12, + }, + routeErrorOverlay: { + position: "absolute", + bottom: 16, + left: 16, + right: 16, + alignItems: "center", + }, + routeErrorText: { + fontSize: 13, + textAlign: "center", + }, +}); diff --git a/src/scenes/DAEItem/Infos.js b/src/scenes/DAEItem/Infos.js new file mode 100644 index 0000000..7ef9ea5 --- /dev/null +++ b/src/scenes/DAEItem/Infos.js @@ -0,0 +1,385 @@ +import React, { useCallback } from "react"; +import { View, ScrollView, StyleSheet } from "react-native"; +import { Button } from "react-native-paper"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { useNavigation } from "@react-navigation/native"; + +import Text from "~/components/Text"; +import { createStyles, useTheme } from "~/theme"; +import { useDefibsState } from "~/stores"; +import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; + +const STATUS_COLORS = { + open: "#4CAF50", + closed: "#F44336", + unknown: "#9E9E9E", +}; + +const STATUS_ICONS = { + open: "check-circle", + closed: "close-circle", + unknown: "help-circle", +}; + +const DAY_LABELS = { + 1: "Lundi", + 2: "Mardi", + 3: "Mercredi", + 4: "Jeudi", + 5: "Vendredi", + 6: "Samedi", + 7: "Dimanche", +}; + +function formatDistance(meters) { + if (meters == null) return "Distance inconnue"; + if (meters < 1000) return `${Math.round(meters)} m`; + return `${(meters / 1000).toFixed(1)} km`; +} + +function InfoRow({ icon, label, value, valueStyle }) { + const { colors } = useTheme(); + if (!value) return null; + return ( + + + + + {label} + + {value} + + + ); +} + +function ScheduleSection({ defib }) { + const { colors } = useTheme(); + const h = defib.horaires_std; + + // If we have structured schedule info, render it + if (h && typeof h === "object") { + const parts = []; + + if (h.is24h) { + parts.push( + + Ouvert 24h/24 + , + ); + } + + if (h.businessHours) { + parts.push( + + Heures ouvrables (Lun-Ven 08h-18h) + , + ); + } + + if (h.nightHours) { + parts.push( + + Heures de nuit (20h-08h) + , + ); + } + + if (h.events) { + parts.push( + + Selon événements + , + ); + } + + if (Array.isArray(h.days) && h.days.length > 0) { + const dayStr = h.days + .sort((a, b) => a - b) + .map((d) => DAY_LABELS[d] || `Jour ${d}`) + .join(", "); + parts.push( + + Jours : {dayStr} + , + ); + } + + if (Array.isArray(h.slots) && h.slots.length > 0) { + const slotsStr = h.slots + .map((s) => `${s.open || "?"} – ${s.close || "?"}`) + .join(", "); + parts.push( + + Créneaux : {slotsStr} + , + ); + } + + if (h.notes) { + parts.push( + + {h.notes} + , + ); + } + + if (parts.length > 0) { + return ( + + + + Horaires détaillés + + {parts} + + ); + } + } + + // Fallback to raw horaires string + if (defib.horaires) { + return ( + + + + Horaires + + + {defib.horaires} + + + ); + } + + return null; +} + +export default React.memo(function DAEItemInfos() { + const { colors } = useTheme(); + const navigation = useNavigation(); + const { selectedDefib: defib } = useDefibsState(["selectedDefib"]); + + const { status, label: availabilityLabel } = getDefibAvailability( + defib?.horaires_std, + defib?.disponible_24h, + ); + + const statusColor = STATUS_COLORS[status]; + + const goToCarte = useCallback(() => { + navigation.navigate("DAEItemCarte"); + }, [navigation]); + + if (!defib) return null; + + return ( + + {/* Header with availability */} + + + + + {status === "open" + ? "Disponible" + : status === "closed" + ? "Indisponible" + : "Disponibilité inconnue"} + + + {availabilityLabel} + + + + + {/* Basic info */} + + + + + + {/* Schedule section */} + + + {/* Itinéraire button */} + + + + + {/* Back to list */} + + + + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + padding: 16, + paddingBottom: 32, + }, + availabilityCard: { + flexDirection: "row", + alignItems: "center", + padding: 16, + borderRadius: 12, + marginBottom: 20, + }, + availabilityInfo: { + marginLeft: 12, + flex: 1, + }, + availabilityStatus: { + fontSize: 18, + fontWeight: "700", + }, + availabilityLabel: { + fontSize: 13, + marginTop: 2, + }, + infoRow: { + flexDirection: "row", + alignItems: "flex-start", + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + infoIcon: { + marginRight: 12, + marginTop: 2, + }, + infoContent: { + flex: 1, + }, + infoLabel: { + fontSize: 12, + marginBottom: 2, + }, + infoValue: { + fontSize: 15, + }, + scheduleContainer: { + marginTop: 20, + }, + sectionHeader: { + flexDirection: "row", + alignItems: "center", + marginBottom: 8, + }, + sectionTitle: { + fontSize: 16, + fontWeight: "600", + }, + scheduleParts: { + paddingLeft: 32, + gap: 4, + }, + scheduleItem: { + fontSize: 14, + lineHeight: 20, + }, + scheduleRaw: { + paddingLeft: 32, + fontSize: 14, + lineHeight: 20, + }, + itineraireContainer: { + marginTop: 24, + alignItems: "center", + }, + itineraireButton: { + minWidth: 200, + borderRadius: 24, + }, + itineraireButtonContent: { + paddingVertical: 6, + }, + backContainer: { + marginTop: 16, + alignItems: "center", + }, + backButton: { + minWidth: 200, + }, +}); diff --git a/src/scenes/DAEItem/index.js b/src/scenes/DAEItem/index.js new file mode 100644 index 0000000..a2c001a --- /dev/null +++ b/src/scenes/DAEItem/index.js @@ -0,0 +1,130 @@ +import React from "react"; +import { View, StyleSheet } from "react-native"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { useNavigation } from "@react-navigation/native"; +import { Button } from "react-native-paper"; + +import { fontFamily, useTheme } from "~/theme"; +import { useDefibsState } from "~/stores"; +import Text from "~/components/Text"; + +import DAEItemInfos from "./Infos"; +import DAEItemCarte from "./Carte"; + +const Tab = createBottomTabNavigator(); + +function EmptyState() { + const { colors } = useTheme(); + const navigation = useNavigation(); + return ( + + + Aucun défibrillateur sélectionné + + Sélectionnez un défibrillateur depuis la liste pour voir ses détails. + + + + ); +} + +export default React.memo(function DAEItem() { + const { colors } = useTheme(); + const { selectedDefib } = useDefibsState(["selectedDefib"]); + + if (!selectedDefib) { + return ; + } + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +}); + +const styles = StyleSheet.create({ + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 32, + }, + emptyIcon: { + marginBottom: 16, + }, + emptyTitle: { + fontSize: 18, + fontWeight: "600", + textAlign: "center", + marginBottom: 8, + }, + emptyText: { + fontSize: 14, + textAlign: "center", + lineHeight: 20, + marginBottom: 16, + }, + backButton: { + marginTop: 8, + }, +}); diff --git a/src/scenes/DAEList/Carte.js b/src/scenes/DAEList/Carte.js new file mode 100644 index 0000000..b34ba22 --- /dev/null +++ b/src/scenes/DAEList/Carte.js @@ -0,0 +1,227 @@ +import React, { + useCallback, + useMemo, + useRef, + useState, + useEffect, +} from "react"; +import { View, StyleSheet } from "react-native"; +import Maplibre from "@maplibre/maplibre-react-native"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { useNavigation } from "@react-navigation/native"; + +import MapView from "~/containers/Map/MapView"; +import Camera from "~/containers/Map/Camera"; +import LastKnownLocationMarker from "~/containers/Map/LastKnownLocationMarker"; +import { BoundType, DEFAULT_ZOOM_LEVEL } from "~/containers/Map/constants"; + +import Text from "~/components/Text"; +import Loader from "~/components/Loader"; +import { useTheme } from "~/theme"; +import { defibsActions } from "~/stores"; +import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; + +import useNearbyDefibs from "./useNearbyDefibs"; + +const STATUS_COLORS = { + open: "#4CAF50", + closed: "#F44336", + unknown: "#9E9E9E", +}; + +function defibsToGeoJSON(defibs) { + return { + type: "FeatureCollection", + features: defibs.map((d) => { + const { status } = getDefibAvailability(d.horaires_std, d.disponible_24h); + return { + type: "Feature", + id: d.id, + geometry: { + type: "Point", + coordinates: [d.longitude, d.latitude], + }, + properties: { + id: d.id, + nom: d.nom || "Défibrillateur", + status, + color: STATUS_COLORS[status], + }, + }; + }), + }; +} + +function EmptyNoLocation() { + const { colors } = useTheme(); + return ( + + + Localisation indisponible + + Activez la géolocalisation pour afficher les défibrillateurs sur la + carte. + + + ); +} + +export default React.memo(function DAEListCarte() { + const { colors } = useTheme(); + const navigation = useNavigation(); + const { + defibs, + loading, + noLocation, + hasLocation, + isLastKnown, + lastKnownTimestamp, + coords, + } = useNearbyDefibs(); + + const mapRef = useRef(); + const cameraRef = useRef(); + const [cameraKey, setCameraKey] = useState(1); + + const refreshCamera = useCallback(() => { + setCameraKey(`${Date.now()}`); + }, []); + + const hasCoords = + coords && coords.latitude !== null && coords.longitude !== null; + + // Camera state — simple follow user + const [followUserLocation] = useState(true); + const [followUserMode] = useState(Maplibre.UserTrackingMode.Follow); + const [zoomLevel] = useState(DEFAULT_ZOOM_LEVEL); + + const geoJSON = useMemo(() => defibsToGeoJSON(defibs), [defibs]); + + const onMarkerPress = useCallback( + (e) => { + const feature = e?.features?.[0]; + if (!feature) return; + + const defibId = feature.properties?.id; + const defib = defibs.find((d) => d.id === defibId); + if (defib) { + defibsActions.setSelectedDefib(defib); + navigation.navigate("DAEItem"); + } + }, + [defibs, navigation], + ); + + if (noLocation && !hasLocation) { + return ; + } + + if (loading && defibs.length === 0 && !hasCoords) { + return ; + } + + return ( + + + + + {geoJSON.features.length > 0 && ( + + + + + )} + + {isLastKnown && hasCoords ? ( + + ) : ( + + )} + + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 32, + }, + emptyIcon: { + marginBottom: 16, + }, + emptyTitle: { + fontSize: 18, + fontWeight: "600", + textAlign: "center", + marginBottom: 8, + }, + emptyText: { + fontSize: 14, + textAlign: "center", + lineHeight: 20, + }, +}); diff --git a/src/scenes/DAEList/DefibRow.js b/src/scenes/DAEList/DefibRow.js new file mode 100644 index 0000000..0d07a17 --- /dev/null +++ b/src/scenes/DAEList/DefibRow.js @@ -0,0 +1,166 @@ +import React, { useCallback } from "react"; +import { View, StyleSheet } from "react-native"; +import { TouchableRipple } from "react-native-paper"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; +import { useNavigation } from "@react-navigation/native"; + +import Text from "~/components/Text"; +import { useTheme } from "~/theme"; +import { defibsActions } from "~/stores"; +import { getDefibAvailability } from "~/utils/dae/getDefibAvailability"; + +function formatDistance(meters) { + if (meters == null) return ""; + if (meters < 1000) return `${Math.round(meters)} m`; + return `${(meters / 1000).toFixed(1)} km`; +} + +const STATUS_COLORS = { + open: "#4CAF50", + closed: "#F44336", + unknown: "#9E9E9E", +}; + +const STATUS_ICONS = { + open: "check-circle", + closed: "close-circle", + unknown: "help-circle", +}; + +function DefibRow({ defib }) { + const { colors } = useTheme(); + const navigation = useNavigation(); + + const { status, label } = getDefibAvailability( + defib.horaires_std, + defib.disponible_24h, + ); + + const statusColor = STATUS_COLORS[status]; + + const onPress = useCallback(() => { + defibsActions.setSelectedDefib(defib); + navigation.navigate("DAEItem"); + }, [defib, navigation]); + + return ( + + + + + + + + {defib.nom || "Défibrillateur"} + + + {defib.adresse || "Adresse non renseignée"} + + + + + {label} + + + + + + + + {formatDistance(defib.distanceMeters)} + + + + + ); +} + +export default React.memo(DefibRow); + +const styles = StyleSheet.create({ + row: { + borderBottomWidth: StyleSheet.hairlineWidth, + paddingVertical: 12, + paddingHorizontal: 16, + }, + rowInner: { + flexDirection: "row", + alignItems: "center", + }, + iconContainer: { + marginRight: 12, + justifyContent: "center", + alignItems: "center", + }, + content: { + flex: 1, + marginRight: 8, + }, + name: { + fontSize: 15, + fontWeight: "600", + }, + address: { + fontSize: 13, + marginTop: 2, + }, + meta: { + flexDirection: "row", + alignItems: "center", + marginTop: 4, + }, + statusBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + }, + statusText: { + fontSize: 11, + fontWeight: "600", + }, + distanceContainer: { + alignItems: "center", + justifyContent: "center", + minWidth: 50, + }, + distance: { + fontSize: 12, + marginTop: 2, + textAlign: "center", + }, +}); diff --git a/src/scenes/DAEList/Liste.js b/src/scenes/DAEList/Liste.js new file mode 100644 index 0000000..f6f102d --- /dev/null +++ b/src/scenes/DAEList/Liste.js @@ -0,0 +1,197 @@ +import React, { useCallback } from "react"; +import { View, FlatList, StyleSheet } from "react-native"; +import { Button } from "react-native-paper"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; + +import Text from "~/components/Text"; +import Loader from "~/components/Loader"; +import { useTheme } from "~/theme"; + +import useNearbyDefibs from "./useNearbyDefibs"; +import DefibRow from "./DefibRow"; + +function EmptyNoLocation() { + const { colors } = useTheme(); + return ( + + + Localisation indisponible + + Activez la géolocalisation pour trouver les défibrillateurs à proximité. + Vérifiez les paramètres de localisation de votre appareil. + + + ); +} + +function EmptyError({ error, onRetry }) { + const { colors } = useTheme(); + return ( + + + Erreur de chargement + + Impossible de charger les défibrillateurs.{"\n"} + {error?.message || "Veuillez réessayer."} + + {onRetry && ( + + )} + + ); +} + +function EmptyNoResults() { + const { colors } = useTheme(); + return ( + + + Aucun défibrillateur + + Aucun défibrillateur trouvé dans un rayon de 10 km autour de votre + position. + + + ); +} + +const keyExtractor = (item) => item.id; + +export default React.memo(function DAEListListe() { + const { colors } = useTheme(); + const { defibs, loading, error, noLocation, hasLocation, reload } = + useNearbyDefibs(); + + const renderItem = useCallback(({ item }) => , []); + + // No location available + if (noLocation && !hasLocation) { + return ; + } + + // Loading initial data + if (loading && defibs.length === 0) { + return ; + } + + // Error state (non-blocking if we have stale data) + if (error && defibs.length === 0) { + return ; + } + + // No results + if (!loading && defibs.length === 0 && hasLocation) { + return ; + } + + return ( + + {error && defibs.length > 0 && ( + + + + Erreur de mise à jour — données potentiellement obsolètes + + + )} + + + ); +}); + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + list: { + flexGrow: 1, + }, + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 32, + }, + emptyIcon: { + marginBottom: 16, + }, + emptyTitle: { + fontSize: 18, + fontWeight: "600", + textAlign: "center", + marginBottom: 8, + }, + emptyText: { + fontSize: 14, + textAlign: "center", + lineHeight: 20, + }, + retryButton: { + marginTop: 20, + }, + errorBanner: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 8, + gap: 8, + }, + errorBannerText: { + fontSize: 12, + flex: 1, + }, +}); diff --git a/src/scenes/DAEList/index.js b/src/scenes/DAEList/index.js new file mode 100644 index 0000000..39a935e --- /dev/null +++ b/src/scenes/DAEList/index.js @@ -0,0 +1,61 @@ +import React from "react"; +import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; +import { MaterialCommunityIcons } from "@expo/vector-icons"; + +import { fontFamily, useTheme } from "~/theme"; + +import DAEListListe from "./Liste"; +import DAEListCarte from "./Carte"; + +const Tab = createBottomTabNavigator(); + +export default React.memo(function DAEList() { + const { colors } = useTheme(); + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +}); diff --git a/src/scenes/DAEList/useNearbyDefibs.js b/src/scenes/DAEList/useNearbyDefibs.js new file mode 100644 index 0000000..f0176da --- /dev/null +++ b/src/scenes/DAEList/useNearbyDefibs.js @@ -0,0 +1,72 @@ +import { useEffect, useRef, useCallback, useState } from "react"; +import useLocation from "~/hooks/useLocation"; +import { defibsActions, useDefibsState } from "~/stores"; + +const RADIUS_METERS = 10_000; + +/** + * Shared hook: loads defibs near user and exposes location + loading state. + * The results live in the zustand store so both Liste and Carte tabs share them. + */ +export default function useNearbyDefibs() { + const { coords, isLastKnown, lastKnownTimestamp } = useLocation(); + const { nearUserDefibs, loadingNearUser, errorNearUser } = useDefibsState([ + "nearUserDefibs", + "loadingNearUser", + "errorNearUser", + ]); + + const hasLocation = + coords && coords.latitude !== null && coords.longitude !== null; + + // Track whether we've already triggered a load for these coords + const lastLoadedRef = useRef(null); + const [noLocation, setNoLocation] = useState(false); + + const loadDefibs = useCallback(async () => { + if (!hasLocation) { + return; + } + const key = `${coords.latitude.toFixed(4)},${coords.longitude.toFixed(4)}`; + if (lastLoadedRef.current === key) { + return; // skip duplicate loads for same position + } + lastLoadedRef.current = key; + await defibsActions.loadNearUser({ + userLonLat: [coords.longitude, coords.latitude], + radiusMeters: RADIUS_METERS, + }); + }, [hasLocation, coords]); + + useEffect(() => { + if (hasLocation) { + setNoLocation(false); + loadDefibs(); + } + }, [hasLocation, loadDefibs]); + + // After a timeout, if we still have no location, set the flag + useEffect(() => { + if (hasLocation) { + return; + } + const timer = setTimeout(() => { + if (!hasLocation) { + setNoLocation(true); + } + }, 8000); + return () => clearTimeout(timer); + }, [hasLocation]); + + return { + defibs: nearUserDefibs, + loading: loadingNearUser, + error: errorNearUser, + hasLocation, + noLocation, + isLastKnown, + lastKnownTimestamp, + coords, + reload: loadDefibs, + }; +} diff --git a/src/scenes/Notifications/Item.js b/src/scenes/Notifications/Item.js index 39d191c..28cc6ef 100644 --- a/src/scenes/Notifications/Item.js +++ b/src/scenes/Notifications/Item.js @@ -10,7 +10,8 @@ import { } from "react-native-gesture-handler"; import { createStyles, useTheme } from "~/theme"; import { useMutation } from "@apollo/client"; -import { format, fr } from "date-fns"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; import { Feather } from "@expo/vector-icons"; import { DELETE_NOTIFICATION, MARK_NOTIFICATION_AS_READ } from "./gql"; import { diff --git a/src/scenes/SendAlertConfirm/useOnSubmit.js b/src/scenes/SendAlertConfirm/useOnSubmit.js index 8cce637..e0d02fb 100644 --- a/src/scenes/SendAlertConfirm/useOnSubmit.js +++ b/src/scenes/SendAlertConfirm/useOnSubmit.js @@ -6,11 +6,18 @@ import uuidGenerator from "react-native-uuid"; import { phoneCallEmergency } from "~/lib/phone-call"; import network from "~/network"; -import { getSessionState, alertActions, useParamsState } from "~/stores"; +import { + getSessionState, + alertActions, + defibsActions, + useParamsState, +} from "~/stores"; import { getCurrentLocation } from "~/location"; import useSendAlertSMSToEmergency from "~/hooks/useSendAlertSMSToEmergency"; +import subjectSuggestsDefib from "~/utils/dae/subjectSuggestsDefib"; + import { SEND_ALERT_MUTATION } from "./gql"; export default function useOnSubmit() { @@ -125,6 +132,13 @@ async function onSubmit(args, context) { }); alertActions.setNavAlertCur({ alert }); + + // Task 9 (DAE v1): keyword detection based on subject only. + // Must be independent of network; we trigger purely from the subject and persist state in store. + if (subjectSuggestsDefib(subject)) { + defibsActions.setShowDaeSuggestModal(true); + } + navigation.navigate("Main", { screen: "AlertCur", params: { diff --git a/src/stores/defibs.js b/src/stores/defibs.js new file mode 100644 index 0000000..3af6b2f --- /dev/null +++ b/src/stores/defibs.js @@ -0,0 +1,114 @@ +import { createAtom } from "~/lib/atomic-zustand"; + +import getNearbyDefibs from "~/data/getNearbyDefibs"; +import { + computeCorridorQueryRadiusMeters, + filterDefibsInCorridor, +} from "~/utils/geo/corridor"; + +const DEFAULT_NEAR_USER_RADIUS_M = 10_000; +const DEFAULT_CORRIDOR_M = 10_000; +const DEFAULT_LIMIT = 200; + +export default createAtom(({ merge, reset }) => { + const actions = { + reset, + + setShowDefibsOnAlertMap: (showDefibsOnAlertMap) => { + merge({ showDefibsOnAlertMap }); + }, + + setSelectedDefib: (selectedDefib) => { + merge({ selectedDefib }); + }, + + setShowDaeSuggestModal: (showDaeSuggestModal) => { + merge({ showDaeSuggestModal }); + }, + + loadNearUser: async ({ + userLonLat, + radiusMeters = DEFAULT_NEAR_USER_RADIUS_M, + }) => { + merge({ loadingNearUser: true, errorNearUser: null }); + try { + const [lon, lat] = userLonLat; + const nearUserDefibs = await getNearbyDefibs({ + lat, + lon, + radiusMeters, + limit: DEFAULT_LIMIT, + progressive: true, + }); + merge({ nearUserDefibs, loadingNearUser: false }); + return { defibs: nearUserDefibs, error: null }; + } catch (error) { + merge({ + nearUserDefibs: [], + loadingNearUser: false, + errorNearUser: error, + }); + return { defibs: [], error }; + } + }, + + loadCorridor: async ({ + userLonLat, + alertLonLat, + corridorMeters = DEFAULT_CORRIDOR_M, + }) => { + merge({ loadingCorridor: true, errorCorridor: null }); + try { + const radiusMeters = computeCorridorQueryRadiusMeters({ + userLonLat, + alertLonLat, + corridorMeters, + }); + + const midLon = (userLonLat[0] + alertLonLat[0]) / 2; + const midLat = (userLonLat[1] + alertLonLat[1]) / 2; + + const candidates = await getNearbyDefibs({ + lat: midLat, + lon: midLon, + radiusMeters, + limit: DEFAULT_LIMIT, + progressive: true, + }); + + const corridorDefibs = filterDefibsInCorridor({ + defibs: candidates, + userLonLat, + alertLonLat, + corridorMeters, + }).slice(0, DEFAULT_LIMIT); + + merge({ corridorDefibs, loadingCorridor: false }); + return { defibs: corridorDefibs, error: null }; + } catch (error) { + merge({ + corridorDefibs: [], + loadingCorridor: false, + errorCorridor: error, + }); + return { defibs: [], error }; + } + }, + }; + + return { + default: { + nearUserDefibs: [], + corridorDefibs: [], + showDefibsOnAlertMap: false, + selectedDefib: null, + showDaeSuggestModal: false, + + loadingNearUser: false, + loadingCorridor: false, + errorNearUser: null, + errorCorridor: null, + }, + actions, + }; +}); diff --git a/src/stores/index.js b/src/stores/index.js index d1c4999..32115ba 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -13,6 +13,7 @@ import params from "./params"; import notifications from "./notifications"; import permissionWizard from "./permissionWizard"; import aggregatedMessages from "./aggregatedMessages"; +import defibs from "./defibs"; const store = createStore({ tree, @@ -28,6 +29,7 @@ const store = createStore({ permissionWizard, notifications, aggregatedMessages, + defibs, }); // console.log("store", JSON.stringify(Object.keys(store), null, 2)); @@ -100,4 +102,9 @@ export const { getAggregatedMessagesState, subscribeAggregatedMessagesState, aggregatedMessagesActions, + + useDefibsState, + getDefibsState, + subscribeDefibsState, + defibsActions, } = store; diff --git a/src/utils/dae/getDefibAvailability.js b/src/utils/dae/getDefibAvailability.js new file mode 100644 index 0000000..c57ce93 --- /dev/null +++ b/src/utils/dae/getDefibAvailability.js @@ -0,0 +1,174 @@ +/** + * @typedef {{ + * days: number[]|null, + * slots: {open: string, close: string}[]|null, + * is24h?: boolean, + * businessHours?: boolean, + * nightHours?: boolean, + * events?: boolean, + * notes?: string, + * }} HorairesStd + */ + +/** + * @typedef {{ status: "open"|"closed"|"unknown", label: string }} DefibAvailability + */ + +function pad2(n) { + return String(n).padStart(2, "0"); +} + +function minutesSinceMidnight(date) { + return date.getHours() * 60 + date.getMinutes(); +} + +function parseHHMM(str) { + if (typeof str !== "string") return null; + const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(str.trim()); + if (!m) return null; + return Number(m[1]) * 60 + Number(m[2]); +} + +// ISO 8601 day number: 1=Mon ... 7=Sun +function isoDayNumber(date) { + const js = date.getDay(); // 0=Sun..6=Sat + return js === 0 ? 7 : js; +} + +const DAY_LABELS = [null, "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]; + +function daysLabel(days) { + if (!Array.isArray(days) || days.length === 0) return ""; + const uniq = Array.from(new Set(days)).filter((d) => d >= 1 && d <= 7); + uniq.sort((a, b) => a - b); + if (uniq.length === 1) return DAY_LABELS[uniq[0]]; + return `${DAY_LABELS[uniq[0]]}-${DAY_LABELS[uniq[uniq.length - 1]]}`; +} + +function formatTimeFromMinutes(mins) { + const h = Math.floor(mins / 60); + const m = mins % 60; + return `${pad2(h)}:${pad2(m)}`; +} + +function isWithinSlot(nowMin, openMin, closeMin) { + if (openMin == null || closeMin == null) return false; + if (openMin === closeMin) return true; + // Cross-midnight slot (e.g. 20:00-08:00) + if (closeMin < openMin) { + return nowMin >= openMin || nowMin < closeMin; + } + return nowMin >= openMin && nowMin < closeMin; +} + +/** + * Determine availability for a given defib schedule. + * Priority logic per PLAN_DAE-merged.md. + * + * @param {HorairesStd|null|undefined} horaires_std + * @param {number|boolean|null|undefined} disponible_24h + * @param {Date} [now] + * @returns {DefibAvailability} + */ +export function getDefibAvailability( + horaires_std, + disponible_24h, + now = new Date(), +) { + if (disponible_24h === 1 || disponible_24h === true) { + return { status: "open", label: "24h/24 7j/7" }; + } + + /** @type {HorairesStd} */ + const h = + horaires_std && typeof horaires_std === "object" ? horaires_std : null; + if (!h) { + return { status: "unknown", label: "Horaires non renseignés" }; + } + + const today = isoDayNumber(now); + const nowMin = minutesSinceMidnight(now); + + const days = Array.isArray(h.days) ? h.days : null; + const hasToday = Array.isArray(days) ? days.includes(today) : null; + + // 2. is24h + today + if (h.is24h === true && hasToday === true) { + return { status: "open", label: "24h/24" }; + } + + // 3. days known and today not included + if (Array.isArray(days) && hasToday === false) { + const label = daysLabel(days); + return { status: "closed", label: label || "Fermé aujourd'hui" }; + } + + // 4. explicit slots for today + if (hasToday === true && Array.isArray(h.slots) && h.slots.length > 0) { + let isOpen = false; + let nextBoundaryLabel = ""; + + for (const slot of h.slots) { + const openMin = parseHHMM(slot.open); + const closeMin = parseHHMM(slot.close); + if (openMin == null || closeMin == null) continue; + + if (isWithinSlot(nowMin, openMin, closeMin)) { + isOpen = true; + nextBoundaryLabel = `Jusqu'à ${formatTimeFromMinutes(closeMin)}`; + break; + } + } + + if (isOpen) { + return { status: "open", label: nextBoundaryLabel || "Ouvert" }; + } + + // Not within any slot: show next opening time if any (same-day). + const opens = h.slots + .map((s) => parseHHMM(s.open)) + .filter((m) => typeof m === "number") + .sort((a, b) => a - b); + const nextOpen = opens.find((m) => m > nowMin); + if (typeof nextOpen === "number") { + return { + status: "closed", + label: `Ouvre à ${formatTimeFromMinutes(nextOpen)}`, + }; + } + + return { status: "closed", label: "Fermé" }; + } + + // 5. business hours approximation (Mon-Fri 08:00-18:00) + if (h.businessHours === true) { + const isWeekday = today >= 1 && today <= 5; + const openMin = 8 * 60; + const closeMin = 18 * 60; + const isOpen = isWeekday && nowMin >= openMin && nowMin < closeMin; + return { + status: isOpen ? "open" : "closed", + label: isOpen ? "Heures ouvrables" : "Fermé (heures ouvrables)", + }; + } + + // 6. night hours approximation (20:00-08:00) + if (h.nightHours === true) { + const openMin = 20 * 60; + const closeMin = 8 * 60; + const isOpen = isWithinSlot(nowMin, openMin, closeMin); + return { + status: isOpen ? "open" : "closed", + label: isOpen ? "Heures de nuit" : "Fermé (heures de nuit)", + }; + } + + // 7. events + if (h.events === true) { + return { status: "unknown", label: "Selon événements" }; + } + + // 8. fallback + const notes = typeof h.notes === "string" ? h.notes.trim() : ""; + return { status: "unknown", label: notes || "Horaires non renseignés" }; +} diff --git a/src/utils/dae/getDefibAvailability.test.js b/src/utils/dae/getDefibAvailability.test.js new file mode 100644 index 0000000..5fb218e --- /dev/null +++ b/src/utils/dae/getDefibAvailability.test.js @@ -0,0 +1,60 @@ +import { getDefibAvailability } from "./getDefibAvailability"; + +function makeLocalDate(y, m, d, hh, mm) { + // Note: uses local time on purpose, because getDefibAvailability relies on + // Date#getDay() / Date#getHours() which are locale/timezone dependent. + return new Date(y, m - 1, d, hh, mm, 0, 0); +} + +describe("dae/getDefibAvailability", () => { + test("disponible_24h=1 always open", () => { + const res = getDefibAvailability(null, 1, makeLocalDate(2026, 3, 1, 3, 0)); + expect(res).toEqual({ status: "open", label: "24h/24 7j/7" }); + }); + + test("is24h + days includes today => open", () => { + // 2026-03-02 is Monday (ISO=1) + const now = makeLocalDate(2026, 3, 2, 12, 0); + const res = getDefibAvailability( + { days: [1], slots: null, is24h: true }, + 0, + now, + ); + expect(res.status).toBe("open"); + }); + + test("days excludes today => closed", () => { + // Monday + const now = makeLocalDate(2026, 3, 2, 12, 0); + const res = getDefibAvailability({ days: [2, 3], slots: null }, 0, now); + expect(res.status).toBe("closed"); + }); + + test("slots determine open/closed", () => { + // Monday 09:00 + const now = makeLocalDate(2026, 3, 2, 9, 0); + const res = getDefibAvailability( + { + days: [1], + slots: [{ open: "08:00", close: "10:00" }], + }, + 0, + now, + ); + expect(res.status).toBe("open"); + }); + + test("events => unknown", () => { + const now = makeLocalDate(2026, 3, 2, 9, 0); + const res = getDefibAvailability( + { + days: null, + slots: null, + events: true, + }, + 0, + now, + ); + expect(res).toEqual({ status: "unknown", label: "Selon événements" }); + }); +}); diff --git a/src/utils/dae/subjectSuggestsDefib.js b/src/utils/dae/subjectSuggestsDefib.js new file mode 100644 index 0000000..30cf5e5 --- /dev/null +++ b/src/utils/dae/subjectSuggestsDefib.js @@ -0,0 +1,76 @@ +/** + * v1 keyword detection for cardiac / defibrillator-related alert subjects. + * + * Requirements: + * - normalize: lowercase + remove diacritics + * - regex list (no fuzzy lib) + * - pure helper with unit tests + */ + +function normalizeSubjectText(subject) { + if (typeof subject !== "string") { + return ""; + } + + // NFD splits accents into combining marks, then we strip them. + // Using explicit unicode range for broad RN JS compatibility. + return ( + subject + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + // Common French ligatures that aren't removed by diacritics stripping + // (e.g. "cœur" => "coeur"). + .replace(/œ/g, "oe") + .replace(/æ/g, "ae") + ); +} + +// NOTE: operate on normalized (diacritics-stripped) text. +const DEFIB_SUGGESTION_REGEXES = [ + // Cardiac keywords + /\bcardiaqu\w*\b/, // cardiaque, cardiaques... + /\bcardiac\w*\b/, // cardiac, cardiacs... + /\bcardiqu\w*\b/, // cardique (common typo) + /\bcoeur\b/, // coeur (after normalization also matches cœur) + + // Malaise common typos + /\bmalaise\b/, + /\bmailaise\b/, + /\bmallaise\b/, + + // Unconsciousness + /\binconscient\w*\b/, // inconscient, inconsciente... + /\bevanoui\w*\b/, // evanoui, evanouie, evanouissement... + + // Arrest + /\barret\b/, // arret (after normalization also matches arrêt) + /\barret\s+cardiaqu\w*\b/, // arrêt cardiaque (strong signal) + + // Defibrillator + /\bdefibrillat\w*\b/, // defibrillateur, defibrillation... + + // CPR / resuscitation + /\breanimat\w*\b/, // reanimation, reanimer... + /\bmassage\s+cardiaqu\w*\b/, // massage cardiaque + + // Not breathing + /\bne\s+respire\s+plus\b/, + /\brespire\s+plus\b/, +]; + +export function subjectSuggestsDefib(subject) { + const text = normalizeSubjectText(subject); + if (!text) { + return false; + } + + return DEFIB_SUGGESTION_REGEXES.some((re) => re.test(text)); +} + +export const __private__ = { + normalizeSubjectText, + DEFIB_SUGGESTION_REGEXES, +}; + +export default subjectSuggestsDefib; diff --git a/src/utils/dae/subjectSuggestsDefib.test.js b/src/utils/dae/subjectSuggestsDefib.test.js new file mode 100644 index 0000000..5c6f1d7 --- /dev/null +++ b/src/utils/dae/subjectSuggestsDefib.test.js @@ -0,0 +1,40 @@ +import { subjectSuggestsDefib } from "./subjectSuggestsDefib"; + +describe("dae/subjectSuggestsDefib", () => { + test("returns false for non-string input", () => { + expect(subjectSuggestsDefib(null)).toBe(false); + expect(subjectSuggestsDefib(undefined)).toBe(false); + expect(subjectSuggestsDefib(123)).toBe(false); + }); + + test("matches cardiac keywords (case-insensitive)", () => { + expect(subjectSuggestsDefib("ARRET CARDIAQUE")).toBe(true); + expect(subjectSuggestsDefib("cardiaque")).toBe(true); + expect(subjectSuggestsDefib("cardiac arrest")).toBe(true); + expect(subjectSuggestsDefib("cardique")).toBe(true); + }); + + test("matches diacritics variants", () => { + expect(subjectSuggestsDefib("Arrêt cardiaque")).toBe(true); + expect(subjectSuggestsDefib("Cœur")).toBe(true); + expect(subjectSuggestsDefib("Évanoui")).toBe(true); + expect(subjectSuggestsDefib("Réanimation")).toBe(true); + expect(subjectSuggestsDefib("Défibrillateur")).toBe(true); + }); + + test("matches common typos", () => { + expect(subjectSuggestsDefib("mallaise")).toBe(true); + expect(subjectSuggestsDefib("mailaise")).toBe(true); + }); + + test("matches CPR / breathing phrases", () => { + expect(subjectSuggestsDefib("massage cardiaque en cours")).toBe(true); + expect(subjectSuggestsDefib("ne respire plus")).toBe(true); + expect(subjectSuggestsDefib("il respire plus")).toBe(true); + }); + + test("does not match unrelated subject", () => { + expect(subjectSuggestsDefib("mal au dos")).toBe(false); + expect(subjectSuggestsDefib("panne de voiture")).toBe(false); + }); +}); diff --git a/src/utils/geo/corridor.js b/src/utils/geo/corridor.js new file mode 100644 index 0000000..620d6a6 --- /dev/null +++ b/src/utils/geo/corridor.js @@ -0,0 +1,79 @@ +import { point, lineString } from "@turf/helpers"; +import nearestPointOnLine from "@turf/nearest-point-on-line"; +import distance from "@turf/distance"; + +const distanceOpts = { units: "meters", method: "geodesic" }; + +/** + * @typedef {[number, number]} LonLat + */ + +/** + * Normalize a {latitude, longitude} object into a Turf-friendly [lon, lat] tuple. + * + * @param {{ latitude: number, longitude: number }} coords + * @returns {LonLat} + */ +export function toLonLat({ latitude, longitude }) { + return [longitude, latitude]; +} + +/** + * Compute a radius (meters) for a single DB query around the segment midpoint. + * Strategy: radius = segmentLength/2 + corridorMeters. + * + * @param {Object} params + * @param {LonLat} params.userLonLat + * @param {LonLat} params.alertLonLat + * @param {number} params.corridorMeters + * @returns {number} + */ +export function computeCorridorQueryRadiusMeters({ + userLonLat, + alertLonLat, + corridorMeters, +}) { + const segmentMeters = distance( + point(userLonLat), + point(alertLonLat), + distanceOpts, + ); + return Math.max(0, segmentMeters / 2 + corridorMeters); +} + +/** + * Filter defibs to those within a corridor around the user→alert segment. + * Corridor definition: distance(point, lineSegment(user→alert)) <= corridorMeters. + * + * @template T + * @param {Object} params + * @param {T[]} params.defibs + * @param {LonLat} params.userLonLat + * @param {LonLat} params.alertLonLat + * @param {number} params.corridorMeters + * @returns {T[]} + */ +export function filterDefibsInCorridor({ + defibs, + userLonLat, + alertLonLat, + corridorMeters, +}) { + const line = lineString([userLonLat, alertLonLat]); + + const filtered = []; + for (const defib of defibs) { + const lon = defib.longitude; + const lat = defib.latitude; + if (typeof lon !== "number" || typeof lat !== "number") continue; + + const p = point([lon, lat]); + const snapped = nearestPointOnLine(line, p, distanceOpts); + const distToLine = snapped?.properties?.dist; + if (typeof distToLine === "number" && distToLine <= corridorMeters) { + filtered.push(defib); + } + } + + return filtered; +} diff --git a/src/utils/geo/corridor.test.js b/src/utils/geo/corridor.test.js new file mode 100644 index 0000000..2d88256 --- /dev/null +++ b/src/utils/geo/corridor.test.js @@ -0,0 +1,48 @@ +import { + toLonLat, + computeCorridorQueryRadiusMeters, + filterDefibsInCorridor, +} from "./corridor"; + +describe("geo/corridor", () => { + test("toLonLat returns [lon, lat]", () => { + expect(toLonLat({ latitude: 48.1, longitude: 2.2 })).toEqual([2.2, 48.1]); + }); + + test("computeCorridorQueryRadiusMeters matches segment/2 + corridor", () => { + const user = [0, 0]; + const alert = [0, 1]; + const corridorMeters = 10_000; + const radius = computeCorridorQueryRadiusMeters({ + userLonLat: user, + alertLonLat: alert, + corridorMeters, + }); + + // 1° latitude is ~111km. Half is ~55.5km, plus corridor. + expect(radius).toBeGreaterThan(60_000); + expect(radius).toBeLessThan(70_000); + }); + + test("filterDefibsInCorridor keeps points close to the segment", () => { + const userLonLat = [0, 0]; + const alertLonLat = [0, 1]; + const corridorMeters = 10_000; + + const defibs = [ + // on the line + { id: "on", latitude: 0.5, longitude: 0 }, + // ~0.1° lon at lat 0.5 is ~11km => outside 10km + { id: "off", latitude: 0.5, longitude: 0.1 }, + ]; + + const filtered = filterDefibsInCorridor({ + defibs, + userLonLat, + alertLonLat, + corridorMeters, + }); + + expect(filtered.map((d) => d.id).sort()).toEqual(["on"]); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1cfa6c3..2beb81f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5044,6 +5044,16 @@ __metadata: languageName: node linkType: hard +"@op-engineering/op-sqlite@npm:^15.2.5": + version: 15.2.5 + resolution: "@op-engineering/op-sqlite@npm:15.2.5" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/e37163e99b5959fdb93076a929f1b2b40db8bb981c996a6342262105a3f387a2cf01d95bd4944cfe4c59424603054fbeeda3179184619b417cd6094a3759b037 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -7013,6 +7023,7 @@ __metadata: "@mapbox/polyline": "npm:^1.2.1" "@maplibre/maplibre-react-native": "npm:10.0.0-alpha.23" "@notifee/react-native": "npm:^9.1.8" + "@op-engineering/op-sqlite": "npm:^15.2.5" "@react-native-async-storage/async-storage": "npm:2.1.2" "@react-native-community/cli": "npm:^18.0.0" "@react-native-community/netinfo": "npm:^11.4.1" @@ -7094,6 +7105,7 @@ __metadata: expo-secure-store: "npm:~14.2.4" expo-sensors: "npm:~14.1.4" expo-splash-screen: "npm:~0.30.10" + expo-sqlite: "npm:^55.0.10" expo-status-bar: "npm:~2.2.3" expo-system-ui: "npm:~5.0.11" expo-task-manager: "npm:~13.1.6" @@ -7104,6 +7116,7 @@ __metadata: google-libphonenumber: "npm:^3.2.32" graphql: "npm:^16.10.0" graphql-ws: "npm:^6.0.4" + h3-js: "npm:^4.4.0" hash.js: "npm:^1.1.7" husky: "npm:^9.0.11" i18next: "npm:^23.2.10" @@ -7544,6 +7557,13 @@ __metadata: languageName: node linkType: hard +"await-lock@npm:^2.2.2": + version: 2.2.2 + resolution: "await-lock@npm:2.2.2" + checksum: 10/feb11f36768a8545879ed2d214b46aae484e6564ffa466af9212d5782897203770795cae01f813de04a46f66c0b8ee6bc690a0c435b04e00cad5a18ef0842e25 + languageName: node + linkType: hard + "axe-core@npm:^4.6.2": version: 4.7.2 resolution: "axe-core@npm:4.7.2" @@ -10889,6 +10909,19 @@ __metadata: languageName: node linkType: hard +"expo-sqlite@npm:^55.0.10": + version: 55.0.10 + resolution: "expo-sqlite@npm:55.0.10" + dependencies: + await-lock: "npm:^2.2.2" + peerDependencies: + expo: "*" + react: "*" + react-native: "*" + checksum: 10/abdc55a33d58bf357d895864756f6196c1951dae9013d9ceb2ac2b2051686c916ef431474c9004369bb8e315ef3ce030a8030abe193c3d436a56f4a40ae0584d + languageName: node + linkType: hard + "expo-status-bar@npm:~2.2.3": version: 2.2.3 resolution: "expo-status-bar@npm:2.2.3" @@ -11960,6 +11993,13 @@ __metadata: languageName: node linkType: hard +"h3-js@npm:^4.4.0": + version: 4.4.0 + resolution: "h3-js@npm:4.4.0" + checksum: 10/6db6888f143ed6a1e3ca10506f15c35679afd181e24b71bcdc90259206e3f02637bab38e2a35382d51f17151ea193dfab69c01ff3e31bf0e86abfb1957692576 + languageName: node + linkType: hard + "handlebars@npm:^4.7.7": version: 4.7.8 resolution: "handlebars@npm:4.7.8"