Compare commits

..

1 commit

Author SHA1 Message Date
d280edaefd Actualiser README.md 2026-03-09 17:57:36 +00:00
77 changed files with 30 additions and 8353 deletions

View file

@ -1,26 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cat /home/jo/lab/alerte-secours/apps/as-app/src/biz/*.js)",
"Bash(npm install)",
"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(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;\")"
]
}
}

View file

@ -4,7 +4,6 @@ module.exports = {
root: true,
env: {
"react-native/react-native": true,
jest: true,
},
extends: [
"plugin:prettier/recommended",
@ -38,12 +37,6 @@ 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: {},
},
},

12
.gitignore vendored
View file

@ -100,15 +100,3 @@ android/app/google-services.json
!android/app/google-services.example.json
screenshot-*.png
/.data
# Geodae preprocessing
scripts/dae/node_modules/
scripts/dae/.yarn/*
!scripts/dae/.yarn/patches
!scripts/dae/.yarn/plugins
!scripts/dae/.yarn/releases
!scripts/dae/.yarn/sdks
!scripts/dae/.yarn/versions
src/assets/db/*.db

View file

@ -1,82 +1 @@
# Alerte Secours - Le Réflexe qui Sauve
[![Liberapay](https://img.shields.io/liberapay/receives/alerte-secours.svg?logo=liberapay)](https://liberapay.com/alerte-secours)
[![Buy Me a Coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg?logo=buy-me-a-coffee)](https://buymeacoffee.com/alertesecours)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/alerte-secours?style=social)](https://github.com/sponsors/alerte-secours)
Une application mobile pour la gestion des alertes et des fonctionnalités liées aux urgences, supportant les plateformes iOS et Android.
**Site Web Officiel :** [alerte-secours.fr](https://alerte-secours.fr)
## Aperçu du Projet
Alerte Secours est une application mobile construite avec React Native qui gère les alertes et les fonctionnalités liées aux urgences. L'application supporte les plateformes iOS et Android et inclut des fonctionnalités telles que :
- Création et gestion d'alertes avec mises à jour en temps réel
- Fonctionnalités basées sur la localisation avec intégration cartographique
- Système de chat/messagerie avec salles de discussion spécifiques aux alertes
- Authentification via vérification SMS
- Liens profonds pour le partage d'alertes
- Notifications push
## Documentation Développeur
Pour les développeurs souhaitant contribuer au projet ou déployer l'application, consultez la [documentation technique complète](DEVELOPER.md) qui contient :
- Aperçu du projet et fonctionnalités
- Stack technique détaillé
- Guide de démarrage rapide
- Instructions d'installation (Android/iOS)
- Structure du projet
- Guide de dépannage
- Instructions de build et déploiement
## Licence
Alerte Secours est sous licence **DevTheFuture Ethical Use License (DEF License)**. Points clés :
### Usage à but non lucratif
- Licence perpétuelle, libre de redevances et non exclusive pour usage à but non lucratif
- Permet l'utilisation, la modification et la distribution à des fins non lucratives
### Usage commercial
- Nécessite l'obtention d'une licence payante
- Conditions déterminées par le Concédant (DevTheFuture.org)
### Restrictions sur les données personnelles
- Ne doit pas être utilisé pour monétiser, vendre ou exploiter les données personnelles
- Les données personnelles ne peuvent pas être utilisées pour le marketing, la publicité ou l'influence politique
- L'agrégation de données n'est autorisée que si c'est une fonctionnalité explicite divulguée aux utilisateurs
### Restriction concurrentielle
- Les concurrents sont interdits d'utiliser le logiciel sans consentement explicite
Pour le texte complet de la licence, voir [LICENSE.md](LICENSE.md).
## 💙 Soutenir le projet
Alerte-Secours est une application mobile citoyenne, librement accessible, sans publicité ni exploitation de données.
Si vous souhaitez contribuer à son développement, sa maintenance et son indépendance :
- 🟡 **[Liberapay Soutien régulier](https://liberapay.com/alerte-secours)**
Pour un soutien **récurrent et engagé**. Chaque don contribue à assurer la stabilité du service sur le long terme.
- ☕ **[Buy Me a Coffee Don ponctuel](https://buymeacoffee.com/alertesecours)**
Pour un **coup de pouce ponctuel**, un café virtuel pour encourager le travail accompli !
- 🧑‍💻 **[GitHub Sponsors](https://github.com/sponsors/alerte-secours)**
Pour les développeurs et utilisateurs de GitHub : soutenez le projet directement via votre compte.
## Contribuer
Directives pour contribuer au projet :
1. Suivez le style de code et les conventions utilisées dans le projet
2. Écrivez des tests pour les nouvelles fonctionnalités
3. Mettez à jour la documentation si nécessaire
4. Utilisez les configurations ESLint et Prettier
## Support
Pour obtenir de l'aide, veuillez ouvrir un ticket sur notre tracker d'issues ou consulter la documentation dans le répertoire `/docs`.
Merged into monorepo at [alerte-secours](https://git.devthefuture.org/alerte-secours/alerte-secours)

View file

@ -147,13 +147,6 @@ android {
}
}
packagingOptions {
// Resolve duplicate native libs shipped by multiple dependencies (e.g. op-sqlite + react-android).
// Needed for debug flavors too (merge<Variant>NativeLibs).
pickFirsts += [
'lib/**/libjsi.so',
'lib/**/libreactnative.so',
]
jniLibs {
useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false)
}

View file

@ -2,55 +2,6 @@
## Recently Completed Features
### DAE v1 (Tasks 19) — 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
9. Runtime hardening follow-up fixes — 2026-03-07:
- ✅ Hermes fix for H3 import:
- `src/lib/h3/index.js`
- `src/db/defibsRepo.js`
- ✅ SQLite backend selection + wrappers (incl. op-sqlite adapter):
- `src/db/openDb.js`
- `src/db/openDbOpSqlite.js`
- `src/db/openDbExpoSqlite.js`
- ✅ Embedded DB staging + schema validation:
- `src/db/ensureEmbeddedDb.js`
- `src/db/validateDbSchema.js`
- ✅ Android duplicate native libs packaging fix for op-sqlite:
- `android/app/build.gradle`
- ✅ Added dependency: `expo-file-system` (`package.json`)
- ✅ Tests added:
- `src/db/openDbOpSqlite.test.js`
- `src/db/ensureEmbeddedDb.test.js`
- `src/db/validateDbSchema.test.js`
- ✅ Status: confirmed on Android emulator dev client that DAE list loads (no `no such table: defibs`).
### Push Notification Improvements
1. Background Notification Fixes:
- ✅ Added required Android permissions

View file

@ -1,12 +0,0 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
testMatch: ["<rootDir>/src/**/*.test.js"],
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/e2e/"],
transformIgnorePatterns: [
"node_modules/(?!(@react-native|react-native|expo)/)",
],
testEnvironment: "node",
moduleNameMapper: {
"^~/(.*)$": "<rootDir>/src/$1",
},
};

View file

@ -15,7 +15,7 @@ const config = {
/node_modules\/.*\/android\/build\/intermediates\/(library_jni|merged_jni_libs)\/.*/,
]),
sourceExts: [...sentryConfig.resolver.sourceExts, "cjs"],
assetExts: [...defaultConfig.resolver.assetExts, "ttf", "db"],
assetExts: [...defaultConfig.resolver.assetExts, "ttf"],
},
server: {
enhanceMiddleware: (middleware) => {

View file

@ -51,11 +51,7 @@
"open:deeplink:ios": "yarn open:deeplink --ios",
"open:deeplink": "npx uri-scheme open --android",
"screenshot:ios": "scripts/screenshot-ios.sh",
"screenshot:android": "scripts/screenshot-android.sh",
"dae:download": "yarn --cwd scripts/dae download",
"dae:json-to-csv": "yarn --cwd scripts/dae json-to-csv",
"dae:csv-to-db": "yarn --cwd scripts/dae csv-to-db",
"dae:build": "yarn --cwd scripts/dae build"
"screenshot:android": "scripts/screenshot-android.sh"
},
"customExpoVersioning": {
"versionCode": 241,
@ -89,7 +85,6 @@
"@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",
@ -131,7 +126,6 @@
"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",
@ -141,7 +135,6 @@
"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",
@ -152,7 +145,6 @@
"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",
@ -290,4 +282,4 @@
}
},
"packageManager": "yarn@4.5.3"
}
}

View file

@ -1,313 +0,0 @@
# 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 `<Drawer.Screen name="..." component={...} options={{drawerLabel, drawerIcon, ...}} />`
- 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`):
- `<Button mode="contained" icon={() => <MaterialCommunityIcons ... />} style={[styles.actionButton, ...]} onPress={handler}>`
**Alert Map Navigation Pattern** (reference: `src/scenes/AlertCurMap/index.js`):
- Uses route calculation via OSRM, user location tracking, polyline rendering
- Camera management via `useMapInit()`, features via `useFeatures()`
- Control buttons, routing steps drawer, profile selection (car/bike/pedestrian)
**Navigation to nested screens:**
```js
navigation.navigate("Main", {
screen: "AlertCur",
params: { screen: "AlertCurTab", params: { screen: "AlertCurMap" } },
});
```
### DAE Data Layer (already implemented)
- `src/data/getNearbyDefibs.js` - Main API: `getNearbyDefibs({ lat, lon, radiusMeters, limit, disponible24hOnly })`
- `src/db/defibsRepo.js` - SQLite queries using H3 spatial index
- `src/db/openDb.js` - SQLite database initialization
- `src/assets/db/geodae.db` - Pre-built SQLite database with defibrillator data
**DefibResult type:**
```js
{ id, latitude, longitude, nom, adresse, horaires, acces, disponible_24h, distanceMeters }
```
### Files You Should Reference for Patterns
- Drawer registration: `src/navigation/Drawer.js`
- Menu sections: `src/navigation/DrawerNav/DrawerItemList.js`
- Header titles: `src/navigation/RootStack.js` (getHeaderTitle)
- Bottom tabs: `src/scenes/AlertAgg/index.js`
- List view: `src/scenes/AlertAggList/index.js`
- Map view: `src/scenes/AlertAggMap/index.js`
- Alert navigation map: `src/scenes/AlertCurMap/index.js`
- Alert situation view: `src/scenes/AlertCurOverview/index.js`
- Alert posting: `src/scenes/SendAlertConfirm/useOnSubmit.js`
- Store definition: `src/stores/index.js`
- Store atom example: `src/stores/alert.js`
- Modal pattern: uses `Portal` + `Modal` from `react-native-paper`
```
---
## Task 1: Create the DAE Zustand Store
**Goal:** Create a Zustand store atom for DAE state management.
**Create `src/stores/dae.js`:**
- State:
- `daeList: []` — array of DefibResult objects currently loaded
- `daeDisplayEnabled: false` — whether DAE markers should show on alert map
- `daeAlertFilter: null` — when set to `{ userCoords, alertCoords }`, filters DAE within 10km of the axis between user and alert
- `selectedDae: null` — currently selected DAE item (for DAEItem view)
- Actions:
- `setDaeList(list)`
- `setDaeDisplayEnabled(enabled)`
- `setDaeAlertFilter(filter)`
- `setSelectedDae(dae)`
- `reset()`
**Update `src/stores/index.js`:**
- Import the new `dae` atom
- Add it to `createStore()` call
- Export `useDaeState`, `getDaeState`, `subscribeDaeState`, `daeActions`
**Reference:** Follow the exact pattern of `src/stores/alert.js` for atom structure.
---
## Task 2: Create DAEItem Scene (Single DAE Detail View)
**Goal:** Create the DAEItem view that shows details of a single defibrillator with bottom tabs (Infos + Map).
**Create `src/scenes/DAEItem/index.js`:**
- Bottom Tab Navigator with 2 tabs:
- `DAEItemInfos` (left, default) — Info tab
- `DAEItemMap` (right) — Map tab with navigation to DAE
- Follow the `AlertAgg/index.js` tab pattern exactly
- Tab icons: `information-outline` for Infos, `map-marker-outline` for Map
**Create `src/scenes/DAEItemInfos/index.js`:**
- Display selected DAE info from `useDaeState(["selectedDae"])`
- Show: nom, adresse, horaires, acces, disponible_24h, distance
- Use a ScrollView with info lines
- Style following the app's pattern (createStyles, useStyles)
- Include a "Itinéraire" button that switches to the Map tab
**Create `src/scenes/DAEItemMap/index.js`:**
- Map with route to the DAE location (the DAE is the destination)
- Mimic `src/scenes/AlertCurMap/index.js` implementation:
- Same OSRM route calculation
- Same camera management, polyline rendering
- Same control buttons, routing steps
- Same profile selection (car/bike/pedestrian)
- The destination is `{ latitude: dae.latitude, longitude: dae.longitude }` instead of alert location
- Use `useDaeState(["selectedDae"])` to get the DAE coordinates
- Reuse as much as possible from the existing map containers (`~/containers/Map/*`)
---
## Task 3: Create DAEList Scene (List + Map of Nearby DAEs)
**Goal:** Create the DAEList view with bottom tabs showing a list and map of nearby defibrillators.
**Create `src/scenes/DAEList/index.js`:**
- Bottom Tab Navigator with 2 tabs:
- `DAEListList` (left, default) — List of DAEs
- `DAEListMap` (right) — Map of DAEs
- Follow `AlertAgg/index.js` pattern
- Tab icons: `format-list-bulleted` for List, `map-marker-outline` for Map
**Create `src/scenes/DAEListList/index.js`:**
- Load DAEs using `getNearbyDefibs()` with user location from `useLocationState`
- Parameters: `radiusMeters: 10000` (10km), `limit: 100`
- Filter out DAEs not available for current day and hour:
- Parse the `horaires` field to determine availability
- Keep DAEs where `disponible_24h === 1` (always available)
- For others, parse opening hours and check against current day/time
- If `horaires` is empty or unparseable, keep the DAE (err on the side of showing)
- Sort by `distanceMeters` ascending (nearest first)
- Display as a ScrollView/FlatList with rows showing:
- DAE name (nom), address (adresse), distance (formatted), availability indicator
- On press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
**Create `src/scenes/DAEListMap/index.js`:**
- Map showing all nearby DAEs as markers
- Mimic `src/scenes/AlertAggMap/index.js` pattern for marker clustering and display
- Use DAE-specific marker icon (defibrillator icon)
- On marker press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
- Camera bounds should fit all visible DAE markers
---
## Task 4: Register DAEList and DAEItem in Navigation
**Goal:** Wire up DAEList and DAEItem screens into the app navigation.
**Update `src/navigation/Drawer.js`:**
- Import DAEList and DAEItem scenes
- Add `<Drawer.Screen name="DAEList">` with:
- `drawerLabel: "Défibrillateurs"`
- `drawerIcon`: use `MaterialCommunityIcons` with name `"heart-pulse"` (or `"medical-bag"`)
- `unmountOnBlur: true`
- Add `<Drawer.Screen name="DAEItem">` with:
- `hidden: true` (not shown in drawer menu, navigated to programmatically)
- `unmountOnBlur: true`
- Place `DAEList` in the "Infos pratiques" section (after existing items, before About or Sheets)
**Update `src/navigation/DrawerNav/DrawerItemList.js`:**
- Adjust the index constants (`index1`, `index2`) if needed to account for the new drawer screen
- Ensure DAEList appears in the "Infos pratiques" section
**Update `src/navigation/RootStack.js`:**
- Add cases to `getHeaderTitle()`:
- `case "DAEList": return "Défibrillateurs";`
- `case "DAEItem": return "Défibrillateur";`
---
## Task 5: Add "Afficher les défibrillateurs" Button in AlertCurOverview
**Goal:** Add a button in the Alert Situation view to enable DAE display and navigate to the alert map.
**Update `src/scenes/AlertCurOverview/index.js`:**
- Import `daeActions` from `~/stores`
- Import `getNearbyDefibs` from `~/data/getNearbyDefibs`
- Import `useLocationState` from `~/stores` (or use `getCurrentLocation`)
- Add a new action button "Afficher les défibrillateurs" in the `containerActions` section:
- Position it after the "Je viens vous aider" / "Coming help" button area (visible for both sender and receiver, when alert is open)
- Icon: `MaterialCommunityIcons` name `"heart-pulse"`
- On press:
1. Get user coords (from store or `getCurrentLocation()`)
2. Get alert coords from `alert.location.coordinates`
3. Call `daeActions.setDaeDisplayEnabled(true)`
4. Call `daeActions.setDaeAlertFilter({ userCoords: { latitude, longitude }, alertCoords: { latitude: alertLat, longitude: alertLon } })`
5. Load DAEs: call `getNearbyDefibs()` with a radius covering the 10km corridor along the axis between user and alert positions, store results via `daeActions.setDaeList(results)`
6. Navigate to AlertCurMap: `navigation.navigate("Main", { screen: "AlertCur", params: { screen: "AlertCurTab", params: { screen: "AlertCurMap" } } })`
- Show this button for all users (sender and receiver) when the alert is open
- Follow the exact same Button pattern as the other action buttons in this file
---
## Task 6: Add DAE Markers to AlertCurMap
**Goal:** Display defibrillator markers on the alert navigation map when enabled.
**Update `src/scenes/AlertCurMap/index.js`:**
- Import `useDaeState` from `~/stores`
- Read `daeDisplayEnabled` and `daeList` from the DAE store
- When `daeDisplayEnabled` is true, render DAE markers on the map:
- Use `Maplibre.PointAnnotation` or `Maplibre.MarkerView` for each DAE
- Use a distinct icon/color for DAE markers (different from alert markers) — green with heart-pulse or defibrillator symbol
- Show DAE name in a callout/tooltip on marker press
- On marker press → set `daeActions.setSelectedDae(dae)` and navigate to `DAEItem`
**Alternative approach (if using the existing FeatureImages/clustering pattern):**
- Create DAE-specific features array from `daeList`
- Convert each DAE to a GeoJSON Feature with `Point` geometry
- Add a separate ShapeSource + SymbolLayer for DAE features
- Use a defibrillator icon image (add to `FeatureImages` or create a DAE-specific one)
**Important:** DAE markers must coexist with the existing alert marker and route display without interfering.
---
## Task 7: Cardiac Keyword Detection Modal on Alert Posting
**Goal:** When posting an alert, detect cardiac-related keywords in the subject and show a modal suggesting to search for a defibrillator. The modal must work offline and persist even during navigation.
**Create `src/components/DAEModal/index.js`:**
- A modal component using `Portal` + `Modal` from `react-native-paper`
- Props: `visible`, `onDismiss`, `onSearchDAE`
- Content:
- Title: "Défibrillateur" or relevant header
- Brief message about defibrillators nearby
- Two buttons:
- "Chercher un défibrillateur" (primary, calls `onSearchDAE`)
- "Non merci" (secondary, calls `onDismiss`)
- The modal must render at a high level in the component tree so it persists across navigations
**Create `src/utils/string/detectCardiacKeywords.js`:**
- Export a function `detectCardiacKeywords(text)` that returns `true` if the text contains cardiac-related keywords
- Keywords to match (case-insensitive, with common typos):
- "cardiaque", "cardiac", "cardique" (typo)
- "coeur", "cœur"
- "malaise", "mailaise" (typo), "mallaise" (typo)
- "inconscient", "inconsciente"
- "évanoui", "évanouie", "evanouie", "evanoui", "évanouis" (typo variant)
- "arrêt", "arret" (in context of "arrêt cardiaque")
- "défibrillateur", "defibrillateur"
- "réanimation", "reanimation"
- "massage cardiaque"
- "ne respire plus", "respire plus"
- Use a regex approach for fuzzy matching
**Update `src/scenes/SendAlertConfirm/useOnSubmit.js`:**
- The keyword detection needs to happen at submission time
- However, the modal must show *during* the posting flow (even offline)
- Approach:
- Instead of modifying `useOnSubmit` directly (since the modal must persist across navigation), manage the modal state via the DAE store or a dedicated state
- Add `showDaeModal: false` to the DAE store, and `daeActions.setShowDaeModal(bool)`
- In `useOnSubmit.js`, after creating `alertSendAlertInput` but before or right after the mutation call, check `detectCardiacKeywords(subject)`. If true, call `daeActions.setShowDaeModal(true)`
- The modal keeps showing even as navigation happens (because it's rendered at app root level via Portal)
**Mount the DAEModal at app level:**
- Update `src/app/AppRoot.js` or `src/layout/Layout.js` to include the `DAEModal` component
- The modal reads `showDaeModal` from `useDaeState`
- "Chercher un défibrillateur" → `daeActions.setShowDaeModal(false)` + navigate to `DAEList`
- "Non merci" → `daeActions.setShowDaeModal(false)`
---
## Task Dependency Order
```
Task 1 (Store) → no dependencies, do first
Task 2 (DAEItem) → depends on Task 1
Task 3 (DAEList) → depends on Task 1
Task 4 (Navigation) → depends on Tasks 2, 3
Task 5 (Button in AlertCurOverview) → depends on Tasks 1, 4
Task 6 (DAE Markers on AlertCurMap) → depends on Tasks 1, 2, 4
Task 7 (Cardiac Modal) → depends on Tasks 1, 3, 4
```
Parallelizable: Tasks 2 and 3 can run in parallel after Task 1.
Task 4 should be done after Tasks 2 and 3.
Tasks 5, 6, 7 can run in parallel after Task 4.

View file

@ -1,395 +0,0 @@
# DAE / Defibrillator integration plan (agent-splittable)
## Common plan prefix (include this at the top of every coding-agent prompt)
### Product goal
Integrate defibrillator (DAE) discovery into the app using the embedded SQLite DB query helper [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37). Provide:
- A new left-drawer link to a new view `DAEList`.
- In an active alert `Situation` view, a button `Afficher les défibrillateurs` that:
1) enables DAE display around the **10km corridor around the segment** between user location and alert location,
2) then navigates to the alert map.
- In alert map, render DAE markers; tapping a DAE opens `DAEItem`.
- `DAEList` screen with bottom navigation: `Liste` (default) and `Carte`, showing defibs nearest→farthest around the user, **within 10km**.
- `DAEItem` screen with bottom navigation: `Infos` (default) and a map/itinerary to reach the selected DAE (mimic alert routing).
- During alert posting, if a cardiac-related keyword is detected in the alert subject, show a **persistent modal** (must remain on top even after redirect to the alert view, and must work offline) with two actions:
- `Chercher un défibrillateur` → go to `DAEList`
- `Non merci` → dismiss
### v1 decisions already made
1) Availability filter: **only** use `disponible_24h === 1` for now; no parsing of `horaires` string yet (later iteration).
2) Corridor filter: **within 10km of the user↔alert segment** (not union of circles).
3) Location permission denied: use last-known location; if none, show an explanatory empty state (no hard block).
### Known architecture + relevant anchors in codebase
- Defib query wrapper: [`src/data/getNearbyDefibs.js`](src/data/getNearbyDefibs.js:1) exports [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37) which calls repo H3 query and falls back to bbox on error.
- Repo query + distance rank: [`src/db/defibsRepo.js`](src/db/defibsRepo.js:1) provides [`getNearbyDefibs()`](src/db/defibsRepo.js:62) and [`getNearbyDefibsBbox()`](src/db/defibsRepo.js:159).
- Embedded DB bootstrap: [`getDb()`](src/db/openDb.js:11) expects a bundled asset `require('../assets/db/geodae.db')` in [`src/db/openDb.js`](src/db/openDb.js:31). **Note:** current repo listing shows `src/assets/db/` empty, so packaging must be validated.
- Drawer navigation screens declared in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85).
- Root stack only defines `Main` and `ConnectivityError` in [`src/navigation/RootStack.js`](src/navigation/RootStack.js:142). Drawer contains “hidden” stack-like screens (e.g. `SendAlertConfirm`) already.
- Alert current tabs: `Situation`, `Messages`, `Carte` in [`src/scenes/AlertCur/Tabs.js`](src/scenes/AlertCur/Tabs.js:25).
- Alert map uses MapLibre and has route computation (OSRM) already in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:170).
- Map features clustering uses Supercluster in [`useFeatures()`](src/scenes/AlertCurMap/useFeatures.js:8) and map press routing in [`useOnPress()`](src/scenes/AlertCurMap/useOnPress.js:17).
- Persistent top-layer UI is implemented elsewhere via `react-native-paper` [`Portal`](src/containers/SmsDisclaimerModel/index.js:37) + [`Modal`](src/containers/SmsDisclaimerModel/index.js:38).
- Alert posting flow navigates to `AlertCurOverview` in [`onSubmit()`](src/scenes/SendAlertConfirm/useOnSubmit.js:32) after `alertActions.setNavAlertCur()` in [`src/scenes/SendAlertConfirm/useOnSubmit.js`](src/scenes/SendAlertConfirm/useOnSubmit.js:127).
### Data model (canonical Defib object for UI)
Base DB row shape (from repo select) is:
- `id: string`
- `latitude: number`
- `longitude: number`
- `nom: string`
- `adresse: string`
- `horaires: string` (unused in v1)
- `acces: string`
- `disponible_24h: 0|1`
- plus computed `distanceMeters: number`
Represent coordinates consistently as:
- Map coordinates arrays `[lon, lat]` to match existing map usage (see `alert.location.coordinates` in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:157)).
### Filtering logic requirements
**Near-user list (DAEList):**
- Input: user location (current if available, else last-known)
- Filter: distance ≤ 10_000m
- Sort: nearest→farthest
- Availability: if `disponible_24h === 1` keep; else exclude in v1 (until horaires parsing)
**Alert-axis corridor overlay (Alert map + Situation button):**
- Input: user location + alert location
- Filter: points within `corridorMeters = 10_000` of the line segment user→alert
- Also restrict to a sensible max radius around user to limit query size (see “Query strategy” below)
Corridor math recommendation:
- Use existing Turf dependency already present in map stack: `@turf/helpers` [`lineString()`](src/scenes/AlertCurMap/index.js:24), `@turf/nearest-point-on-line` [`nearestPointOnLine()`](src/scenes/AlertCurMap/index.js:25), and a distance function (`geolib` or Turf `distance`).
### Query strategy (performance + offline)
Use the existing local SQLite query API (no network) via [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37).
- For near-user list: query radiusMeters = `10_000`, limit = e.g. 200500 (tune later).
- For corridor overlay:
- First query a radius around the **midpoint** or around **user** large enough to include the whole segment + corridor.
- Practical v1 approach: compute `segmentLengthMeters` and query radius = `segmentLengthMeters/2 + corridorMeters` around the midpoint.
- Then apply corridor filter in JS and cap results to a max marker count (e.g. 200) to keep map responsive.
### Navigation and UX conventions
- Drawer items come from `<Drawer.Screen>` options in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:100) and are rendered by [`menuItem()`](src/navigation/DrawerNav/menuItem.js:4). Hidden routes should set `options.hidden = true`.
- Bottom tab patterns exist (see alert tabs in [`src/scenes/AlertCur/Tabs.js`](src/scenes/AlertCur/Tabs.js:25)).
- For “persistent modal on top even after redirect”, implement modal at a global provider level (within [`LayoutProviders`](src/layout/LayoutProviders.js:30) tree) using `Portal` so it survives navigation.
---
## Split tasks (agent-ready prompts)
Each task below is designed to be handed to a coding agent. Include the **Common plan prefix** above in every prompt.
### Task 1 — Validate embedded DB asset packaging and repo schema assumptions
**Objective:** Ensure the bundled SQLite `geodae.db` is present and accessible on-device, and confirm schema columns used by [`defibsRepo`](src/db/defibsRepo.js:120) exist.
**Implementation notes:**
- [`initDb()`](src/db/openDb.js:18) copies `require('../assets/db/geodae.db')` to documents.
- Current workspace shows `src/assets/db/` empty; find where DB is stored or add it.
**Acceptance criteria:**
- App can open DB without throwing at [`require('../assets/db/geodae.db')`](src/db/openDb.js:31).
- Query `SELECT ... FROM defibs` works with columns: `id, latitude, longitude, nom, adresse, horaires, acces, disponible_24h` and `h3`.
**Likely files touched:**
- [`src/db/openDb.js`](src/db/openDb.js:1)
- DB asset under [`src/assets/db/`](src/assets/db:1)
- Possibly Expo config / bundling rules (if needed)
---
### Task 2 — Define and implement defib filtering utilities (10km near-user + 10km corridor)
**Objective:** Create pure utility functions to:
- compute query radius for corridor overlay
- filter a list of defibs to those inside corridor
- normalize coordinates and compute distances
**Implementation notes:**
- Prefer reusing Turf already used in map stack (see imports in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:24)).
- Keep utilities side-effect free and unit-testable.
**Suggested exports:**
- `computeCorridorQueryRadiusMeters({ userLonLat, alertLonLat, corridorMeters })`
- `filterDefibsInCorridor({ defibs, userLonLat, alertLonLat, corridorMeters })`
- `toLonLat({ latitude, longitude })`
**Acceptance criteria:**
- Given synthetic points, corridor filter behaves as “distance to segment ≤ 10km”.
**Likely files touched:**
- New: [`src/utils/geo/defibsCorridor.js`](src/utils/geo/defibsCorridor.js:1)
- Possibly reuse existing [`haversine`](src/db/defibsRepo.js:5) logic as reference
---
### Task 3 — Add a Defibs store (zustand atom) to manage caching + overlay enablement + modal state
**Objective:** Add centralized state to avoid repeated DB queries and to coordinate UI across screens.
**State concerns:**
- cached near-user defibs list
- cached corridor defibs for current alert id
- flag `showDefibsOnAlertMap` (or `defibsOverlayEnabledByAlertId`)
- selected defib id for `DAEItem`
- “DAE suggestion modal” visibility (global)
**Integration points:**
- Mirror patterns used by alert store in [`createAtom()`](src/stores/alert.js:6).
**Acceptance criteria:**
- Other tasks can simply call actions like `defibsActions.loadNearUser()` and `defibsActions.enableCorridorOverlay(alertId)`.
**Likely files touched:**
- New: [`src/stores/defibs.js`](src/stores/defibs.js:1)
- Update exports/hooks in [`src/stores/index.js`](src/stores/index.js:1)
---
### Task 4 — Add navigation routes: `DAEList` in drawer + `DAEItem` as hidden route
**Objective:** Add new screens to navigation so they can be opened from:
- left drawer (DAEList)
- map marker press (DAEItem)
- modal CTA (DAEList)
**Implementation notes:**
- Drawer routes are defined in [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85).
- If `DAEItem` is a detail view, set `options.hidden = true` (see existing hidden screens around [`SendAlertConfirm`](src/navigation/Drawer.js:490)).
- Decide where to place the DAE link in drawer sections:
- Sections are sliced by indices in [`src/navigation/DrawerNav/DrawerItemList.js`](src/navigation/DrawerNav/DrawerItemList.js:4). Adding a new Drawer.Screen will shift indices; adjust `index1/index2` or reorder screens.
**Acceptance criteria:**
- A new drawer link navigates to `DAEList`.
- `DAEItem` route can be navigated to programmatically.
**Likely files touched:**
- [`src/navigation/Drawer.js`](src/navigation/Drawer.js:85)
- [`src/navigation/DrawerNav/DrawerItemList.js`](src/navigation/DrawerNav/DrawerItemList.js:1)
---
### Task 5 — Situation button on active alert: enable DAE overlay and navigate to alert map
**Objective:** In `Situation` view for current alert, add button `Afficher les défibrillateurs`.
**Behavior:**
1) Determine user coords (prefer current; fall back to last-known as in alert maps `useLocation` usage in [`src/scenes/AlertCurMap/index.js`](src/scenes/AlertCurMap/index.js:83)).
2) Query defibs from DB with a computed radius around midpoint.
3) Filter to corridor (10km).
4) Store result + enable overlay flag.
5) Navigate to map tab: `Main → AlertCur → AlertCurTab → AlertCurMap` (same pattern used in [`AlertCurOverview`](src/scenes/AlertCurOverview/index.js:276)).
**Acceptance criteria:**
- Button exists only when alert has coordinates.
- After tap, map opens and defib markers appear.
**Likely files touched:**
- [`src/scenes/AlertCurOverview/index.js`](src/scenes/AlertCurOverview/index.js:61)
- Store from Task 3
- Utilities from Task 2
---
### Task 6 — Alert map: render DAE markers and open `DAEItem` on tap
**Objective:** Display defib markers as a separate feature layer on alert map.
**Implementation notes:**
- Existing feature system creates a GeoJSON FeatureCollection for alerts in [`useFeatures()`](src/scenes/AlertCurMap/useFeatures.js:42).
- Add DAE features when overlay is enabled.
- Add a new marker icon to map images by extending [`FeatureImages`](src/containers/Map/FeatureImages.js:13).
- Update press handler in [`useOnPress()`](src/scenes/AlertCurMap/useOnPress.js:17) to recognize `properties.defib` and navigate to `DAEItem`.
**Acceptance criteria:**
- Markers appear/disappear based on overlay flag.
- Tapping a marker navigates to `DAEItem`.
- Clustering behavior is acceptable (either include in cluster or keep separate; v1 can skip clustering by rendering as a separate layer).
**Likely files touched:**
- [`src/scenes/AlertCurMap/useFeatures.js`](src/scenes/AlertCurMap/useFeatures.js:8)
- [`src/scenes/AlertCurMap/useOnPress.js`](src/scenes/AlertCurMap/useOnPress.js:17)
- [`src/containers/Map/FeatureImages.js`](src/containers/Map/FeatureImages.js:1)
---
### Task 7 — Implement `DAEList` screen with bottom tabs: List (default) + Map
**Objective:** Create `DAEList` scene with bottom navigation and two tabs:
- `Liste`: list view nearest→farthest
- `Carte`: map view of same defibs
**Behavior:**
- Load near-user defibs within 10km using [`getNearbyDefibs()`](src/data/getNearbyDefibs.js:37).
- Filter by availability: keep `disponible_24h === 1` only.
- If no location available, show empty state with explanation.
- Tapping list item navigates to `DAEItem`.
**Implementation notes:**
- Follow bottom tab pattern from [`createBottomTabNavigator()`](src/scenes/AlertCur/Tabs.js:3).
**Acceptance criteria:**
- Drawer link opens `DAEList`.
- List is sorted by `distanceMeters`.
- Map tab renders markers.
**Likely files touched:**
- New: [`src/scenes/DAEList/index.js`](src/scenes/DAEList/index.js:1)
- New: [`src/scenes/DAEList/Tabs.js`](src/scenes/DAEList/Tabs.js:1)
- Possibly shared list row component
---
### Task 8 — Implement `DAEItem` screen with bottom tabs: Infos (default) + Go-to map (itinerary)
**Objective:** Create `DAEItem` detail view for a selected defib.
**Tabs:**
- `Infos`: name, address, access, availability badge
- `Carte`: show route from user to DAE, mimicking alert routing implementation in [`AlertCurMap`](src/scenes/AlertCurMap/index.js:170)
**Implementation notes:**
- Reuse route calculation patterns (OSRM URL building, polyline decode, step list components) from alert map.
- Route target is defib coordinates instead of alert coordinates.
**Acceptance criteria:**
- From any entrypoint (alert map marker or list), `DAEItem` shows correct defib.
- Itinerary works when online; offline behavior is a clear message (route unavailable offline).
**Likely files touched:**
- New: [`src/scenes/DAEItem/index.js`](src/scenes/DAEItem/index.js:1)
- New: [`src/scenes/DAEItem/Tabs.js`](src/scenes/DAEItem/Tabs.js:1)
- New: [`src/scenes/DAEItem/Map.js`](src/scenes/DAEItem/Map.js:1)
---
### Task 9 — Keyword detection while posting an alert + persistent DAE suggestion modal
**Objective:** Detect cardiac-related terms during alert posting and display a global modal that remains visible even after navigation.
**Detection requirements:**
- Keywords include: `cardiaque`, `cardiac` (typos), `coeur`, `malaise`, `inconscient`, `évanoui` etc.
- Should run locally/offline.
**Implementation approach (recommended):**
- Use fuzzy matching with `Fuse` (already used in [`findAlertTitle()`](src/finders/alertTitle.js:50)) or implement a lightweight normalization + substring/levenshtein.
- Trigger detection in the confirm submit flow, before/around navigation in [`onSubmit()`](src/scenes/SendAlertConfirm/useOnSubmit.js:32).
- Render modal at app root using `react-native-paper` [`Portal`](src/containers/SmsDisclaimerModel/index.js:37) inside [`LayoutProviders`](src/layout/LayoutProviders.js:51) so it persists across navigation.
**Modal UI:**
- Title/text: explain quickly why looking for a DAE matters.
- Two buttons:
- `Chercher un défibrillateur` → navigate to `DAEList`
- `Non merci` → dismiss
**Acceptance criteria:**
- Modal shows for matching terms even with no internet.
- Modal stays visible after redirect to current alert view.
**Likely files touched:**
- [`src/scenes/SendAlertConfirm/useOnSubmit.js`](src/scenes/SendAlertConfirm/useOnSubmit.js:1)
- New: [`src/containers/DAESuggestModal/index.js`](src/containers/DAESuggestModal/index.js:1)
- [`src/layout/LayoutProviders.js`](src/layout/LayoutProviders.js:30)
- Store from Task 3
---
### Task 10 — Verification checklist (manual) + minimal automated coverage
**Objective:** Provide a deterministic checklist and (where feasible) simple automated tests.
**Manual verification checklist:**
1) **Drawer**: DAE link visible, opens list.
2) **DAEList**:
- permission granted → list populated, sorted
- permission denied + last-known available → list uses last-known
- permission denied + no last-known → empty state
3) **Alert Situation button**: enables overlay and opens alert map.
4) **Alert map**: DAE markers render; tap → DAEItem.
5) **DAEItem routing**: online route works; offline shows message.
6) **Keyword modal**:
- trigger term in subject → modal shows
- redirect to alert occurs and modal remains on top
- CTA navigates to DAEList
**Acceptance criteria:**
- Checklist documented and reproducible.
**Likely files touched:**
- New: [`plans/DAE-manual-test-checklist.md`](plans/DAE-manual-test-checklist.md:1)
- Optional: e2e tests under [`e2e/`](e2e:1)
---
## Mermaid overview
```mermaid
flowchart TD
A[User posts alert] --> B[Keyword detection]
B -->|match| M[Show persistent DAE modal]
B -->|no match| C[Navigate to AlertCur Situation]
M --> C
M -->|Chercher un defibrillateur| L[DAEList]
M -->|Non merci| C
C --> S[Button Afficher les defibrillateurs]
S --> E[Enable overlay in store]
E --> MAP[AlertCurMap]
MAP -->|tap DAE marker| ITEM[DAEItem]
L -->|tap list item| ITEM
```

View file

@ -1,370 +0,0 @@
## Common plan prefix (include at the top of every coding-agent prompt)
### Product goal (v1)
Integrate defibrillator (DAE) discovery into the app using the embedded SQLite DB query helper `getNearbyDefibs()`.
Deliver:
1) A new left-drawer link to a new view `DAEList`.
2) In an active alert `Situation` view, a button `Afficher les défibrillateurs` that:
- enables DAE display inside a 10km corridor around the segment between user location and alert location
- navigates to the alert map tab
3) On alert map, render DAE markers; tapping a DAE opens `DAEItem`.
4) `DAEList` screen with bottom navigation: `Liste` (default) and `Carte`, showing defibs nearest→farthest around the user, within 10km.
5) `DAEItem` screen with bottom navigation: `Infos` (default) and `Carte` (map + itinerary to the selected DAE; reuse alert routing patterns).
6) During alert posting, if cardiac-related keywords are detected in the alert subject, show a persistent modal (must remain visible even after navigation, and must work offline) with:
- `Chercher un défibrillateur` → go to `DAEList`
- `Non merci` → dismiss
### v1 decisions (explicit)
1) Availability display: use `horaires_std` (structured JSON) to show open/closed/unknown status and schedule details.
- `horaires_std` shape: `{ days: number[]|null, slots: {open,close}[]|null, is24h: boolean, businessHours: boolean, nightHours: boolean, events: boolean, notes: string }`
- `days`: ISO 8601 day numbers (1=Mon … 7=Sun), `null` if unknown.
- `slots`: `[{open:"HH:MM", close:"HH:MM"}]`, `null` if no specific times.
- Availability logic (pure function `getDefibAvailability(horaires_std, disponible_24h)``{ status: "open"|"closed"|"unknown", label: string }`):
- `disponible_24h === 1` → always `"open"`, label `"24h/24 7j/7"`
- `is24h && days includes today``"open"`
- `days` includes today + current time falls within a `slot``"open"`
- `days` includes today + no slots + `businessHours` → approximate Mon-Fri 8h-18h → `"open"` or `"closed"`
- `days` does not include today → `"closed"`
- `events === true``"unknown"`, label `"Selon événements"`
- Else → `"unknown"`
- No hard filter: show all defibs regardless of availability, but display status visually (green/red/grey dot or icon).
2) Corridor filter: points are kept if distance to the user→alert line segment is ≤ `corridorMeters = 10_000`.
3) Alert-map DAE markers: v1 uses a separate, non-clustered layer (do not mix into alert clusters).
4) Location permission denied: use last-known location; if none, show an explanatory empty state (no hard block).
5) DB open failure: the app must not crash; DAE UI shows an error empty state and overlay remains disabled.
6) Keyword detection: normalized-text + regex list (no fuzzy search library) in v1.
### Relevant anchors in codebase
- Defib query wrapper: `src/data/getNearbyDefibs.js` exports `getNearbyDefibs()`.
- Repo query: `src/db/defibsRepo.js` — SELECTs and JSON-parses `horaires_std`.
- Embedded DB bootstrap: `src/db/openDb.js` requires `../assets/db/geodae.db`.
- Drawer navigation: `src/navigation/Drawer.js`.
- Drawer sections: `src/navigation/DrawerNav/DrawerItemList.js`.
- Header titles: `src/navigation/RootStack.js`.
- Alert tabs: `src/scenes/AlertCur/Tabs.js`.
- Alert map feature pipeline: `src/scenes/AlertCurMap/useFeatures.js` + `src/scenes/AlertCurMap/useOnPress.js`.
- Persistent modal pattern: `src/containers/SmsDisclaimerModel/index.js` uses Paper `Portal` + `Modal`.
- Alert posting flow: `src/scenes/SendAlertConfirm/useOnSubmit.js`.
- Preprocessing pipeline: `scripts/dae/` (geodae-to-csv.js → csv-to-sqlite.mjs).
---
## Split tasks (agent-ready)
### Task 1 — Validate embedded DB asset packaging and schema assumptions
Objective: ensure the bundled SQLite `geodae.db` is present and accessible on-device, and confirm expected schema columns exist.
Notes:
- `src/db/openDb.js` calls `require('../assets/db/geodae.db')`.
- In the current workspace, `src/assets/db/` may be missing; find the DB source and ensure it is bundled for Expo.
Acceptance criteria:
- App can open the DB without throwing at the asset require.
- Query `SELECT ... FROM defibs` works with columns: `id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h` and `h3`.
- `horaires_std` is a JSON string parseable by `JSON.parse()` (already handled in `defibsRepo.js`).
- If DB open fails, DAE screens show a non-blocking error empty state and overlay stays disabled.
---
### Task 2 — Implement geo utilities for corridor filtering + availability helper (pure, unit-testable)
Objective: create pure utilities to compute the query radius for the corridor overlay, filter defibs to those inside the corridor, and determine real-time availability from `horaires_std`.
#### 2a — Geo / corridor utilities
Suggested exports (e.g. `src/utils/geo/corridor.js`):
- `toLonLat({ latitude, longitude })`
- `computeCorridorQueryRadiusMeters({ userLonLat, alertLonLat, corridorMeters })`
- `filterDefibsInCorridor({ defibs, userLonLat, alertLonLat, corridorMeters })`
Implementation guidance:
- Use the existing Turf usage patterns already present in the alert map stack.
- Corridor definition: keep point if distance to the line segment ≤ `corridorMeters`.
- Query strategy: query around midpoint with radius `segmentLengthMeters / 2 + corridorMeters`, then apply corridor filter in JS and cap markers (e.g. 200) to keep map responsive.
#### 2b — Availability helper
Suggested export (e.g. `src/utils/dae/getDefibAvailability.js`):
- `getDefibAvailability(horaires_std, disponible_24h, now?)``{ status: "open"|"closed"|"unknown", label: string }`
The `horaires_std` object has shape:
```
{ days: number[]|null, slots: {open,close}[]|null, is24h, businessHours, nightHours, events, notes }
```
Logic (in priority order):
1. `disponible_24h === 1``"open"`, label `"24h/24 7j/7"`
2. `is24h === true` and `days` includes current ISO day → `"open"`, label `"24h/24"`
3. `days !== null` and does not include current ISO day → `"closed"`, label day range from `days`
4. `days` includes today and `slots` is non-empty → check if current time falls within any slot → `"open"` or `"closed"` with next open/close time as label
5. `businessHours === true` (no explicit slots) → approximate Mon-Fri 08:00-18:00 → `"open"` or `"closed"`
6. `nightHours === true` → approximate 20:00-08:00 → `"open"` or `"closed"`
7. `events === true``"unknown"`, label `"Selon événements"`
8. Fallback → `"unknown"`, label from `notes` or `"Horaires non renseignés"`
The `now` parameter (defaults to `new Date()`) enables deterministic testing.
Acceptance criteria:
- Given synthetic points, corridor filter is correct and deterministic.
- Given synthetic `horaires_std` + fixed `now`, availability status is correct.
---
### Task 3 — Add a Defibs store (zustand atom) for caching + overlay + selection + modal state
Objective: add centralized state to avoid repeated DB queries and coordinate UI across screens.
State:
- `nearUserDefibs: []`
- `corridorDefibs: []` (or keyed by current alert id if needed)
- `showDefibsOnAlertMap: false`
- `selectedDefib: null`
- `showDaeSuggestModal: false`
Actions (suggested):
- `loadNearUser({ userLonLat })`
- `loadCorridor({ userLonLat, alertLonLat })`
- `setShowDefibsOnAlertMap(bool)`
- `setSelectedDefib(defib)`
- `setShowDaeSuggestModal(bool)`
- `reset()`
Acceptance criteria:
- Other tasks can call actions without duplicating query/filter logic.
---
### Task 4 — Create `DAEList` scene with bottom tabs: `Liste` + `Carte`
Objective: add `DAEList` screen with bottom navigation and two tabs.
UI specifics (from existing patterns):
- Follow the apps bottom-tab pattern.
- Tab icons:
- Liste: `format-list-bulleted`
- Carte: `map-marker-outline`
Behavior:
- Get user location (current if possible; else last-known).
- Query within 10km using `getNearbyDefibs({ radiusMeters: 10_000 })`.
- No hard availability filter: show all defibs. Each row shows real-time availability status via `getDefibAvailability(horaires_std, disponible_24h)`.
- Sort by `distanceMeters` ascending.
- Empty states:
- no location → explain how to enable permission
- DB error → explain that DAE database is unavailable
List row content:
- Name (`nom`), distance, address summary.
- Availability status indicator: green dot + "Ouvert" / red dot + "Fermé" / grey dot + "Inconnu" (or label from `getDefibAvailability`).
- If `horaires_std.notes` is non-empty, show it as a secondary line.
Carte tab:
- Map markers colored by availability status (green/red/grey).
Interaction:
- Tap list row or map marker → set `selectedDefib` in store and navigate to `DAEItem`.
Acceptance criteria:
- Drawer link opens `DAEList` and list is sorted nearest→farthest.
- Availability status is displayed per row/marker.
---
### Task 5 — Create `DAEItem` scene with bottom tabs: `Infos` + `Carte`
Objective: create DAE detail view for a selected defib.
Tab icons:
- Infos: `information-outline`
- Carte: `map-marker-outline`
Infos tab content:
- Name (`nom`), address (`adresse`), access (`acces`), distance.
- Availability section:
- Current status via `getDefibAvailability()` with colored indicator.
- Schedule details from `horaires_std`:
- If `is24h`: "Disponible 24h/24"
- If `days`: show day range (e.g. "Lun-Ven")
- If `slots`: show time slots (e.g. "08:00 - 18:00")
- If `businessHours`: "Heures ouvrables"
- If `nightHours`: "Heures de nuit"
- If `events`: "Selon événements"
- If `notes`: show notes text
- Fallback: show raw `horaires` string if `horaires_std` is null/empty.
- Add an `Itinéraire` button that switches to the `Carte` tab.
Carte tab:
- Map + itinerary to the defib coordinates.
- Reuse alert routing implementation patterns (OSRM fetch etc.).
- Offline behavior: show a clear message that routing is unavailable offline.
Acceptance criteria:
- From list or alert map marker, `DAEItem` shows the correct selected defib.
---
### Task 6 — Navigation wiring: `DAEList` in drawer + `DAEItem` hidden
Objective: register new screens and keep drawer sections consistent.
Implementation notes:
- Add `<Drawer.Screen name='DAEList'>` with label `Défibrillateurs`.
- Drawer icon: `MaterialCommunityIcons` name `heart-pulse`.
- Add `<Drawer.Screen name='DAEItem'>` hidden (not shown in menu) and only navigated programmatically.
- Adjust section indices in `DrawerItemList.js` if adding a screen shifts boundaries.
- Add header title cases for `DAEList` and `DAEItem`.
Acceptance criteria:
- Drawer link appears under the intended section and opens `DAEList`.
- `DAEItem` can be navigated to programmatically.
---
### Task 7 — Add Situation button: enable corridor overlay and navigate to alert map
Objective: in active alert Situation view, add `Afficher les défibrillateurs`.
UI specifics:
- Use the apps existing action button pattern.
- Icon: `MaterialCommunityIcons` name `heart-pulse`.
- Position: next to/after existing main actions (align with current layout conventions).
Behavior:
1) Get user coords.
2) Get alert coords.
3) Load corridor defibs using midpoint query radius + corridor filter.
4) Store results and set overlay enabled.
5) Navigate to map tab using existing nested navigation pattern.
Acceptance criteria:
- Tap leads to alert map and DAE markers appear.
---
### Task 8 — Alert map overlay: render DAE markers (separate layer) and open `DAEItem` on tap
Objective: add a DAE marker layer to the alert map.
Implementation notes:
- Extend the existing feature pipeline to include DAE features when overlay is enabled.
- Add DAE icon images to map images (3 variants: green/red/grey for open/closed/unknown).
- Compute availability via `getDefibAvailability()` to select marker color.
- Update press handling to detect DAE features and navigate to `DAEItem`.
- v1 does not cluster DAE markers.
Acceptance criteria:
- Markers appear/disappear based on overlay flag.
- Tapping a marker opens `DAEItem`.
---
### Task 9 — Keyword detection during alert posting + persistent DAE suggestion modal
Objective: detect cardiac-related terms in alert subject and display a global modal that remains visible even after navigation.
Detection (v1):
- Normalize text: lowercase + remove diacritics.
- Regex list covering common terms and typos, e.g.:
- cardiaque, cardiac, cardique
- coeur, cœur
- malaise, mailaise, mallaise
- inconscient
- evanoui, évanoui (variants)
- arret, arrêt (especially arrêt cardiaque)
- defibrillateur, défibrillateur
- reanimation, réanimation
- massage cardiaque
- ne respire plus
Modal:
- Use Paper `Portal` + `Modal` mounted high in the tree so it persists across navigations.
- CTA:
- `Chercher un défibrillateur` → dismiss + navigate to `DAEList`
- `Non merci` → dismiss
Acceptance criteria:
- Modal shows offline.
- Modal remains visible after redirect to current alert view.
---
### Task 10 — Verification checklist (manual)
Objective: provide a deterministic checklist.
Checklist:
1) Drawer: link visible, opens list.
2) DAEList:
- permission granted → list populated and sorted
- permission denied + last-known available → list uses last-known
- permission denied + no last-known → empty state
- DB missing/unavailable → non-blocking error empty state
- each row shows availability status (open/closed/unknown) with colored indicator
3) Situation button: enables overlay and opens alert map.
4) Alert map: DAE markers render with availability-colored icons; tap → DAEItem.
5) DAEItem:
- Infos tab shows schedule details from `horaires_std` (days, slots, 24h, notes).
- Availability status is prominently displayed.
- Routing: online route works; offline shows message.
6) Keyword modal:
- trigger terms → modal shows
- modal persists across navigation
- CTA navigates to DAEList
7) Availability logic:
- 24h/24 defib → shows "open" at any time
- Defib with Lun-Ven slots → shows "open" during those hours, "closed" on weekends
- Defib with `events` flag → shows "unknown" / "Selon événements"
---
## Mermaid overview
```mermaid
flowchart TD
A[User posts alert] --> B[Keyword detection]
B -->|match| M[Show persistent DAE modal]
B -->|no match| C[Navigate to AlertCur Situation]
M --> C
M -->|Chercher un defibrillateur| L[DAEList]
M -->|Non merci| C
C --> S[Button Afficher les defibrillateurs]
S --> E[Enable overlay in store]
E --> MAP[AlertCurMap]
MAP -->|tap DAE marker| ITEM[DAEItem]
L -->|tap list item| ITEM
```

View file

@ -1,2 +0,0 @@
geodae.json
geodae.csv

View file

@ -1,7 +0,0 @@
compressionLevel: mixed
enableGlobalCache: false
nodeLinker: node-modules
yarnPath: ../../.yarn/releases/yarn-4.5.3.cjs

View file

@ -1,207 +0,0 @@
#!/usr/bin/env node
// CSV-to-SQLite pipeline for defibrillator data with H3 geo-indexing.
// Usage: node csv-to-sqlite.mjs --input <path> --output <path> [--h3res 8] [--delimiter auto] [--batchSize 5000]
import { createReadStream, readFileSync, existsSync, unlinkSync } from "node:fs";
import { createHash } from "node:crypto";
import { parseArgs } from "node:util";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
import { parse } from "csv-parse";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3");
const h3 = require("h3-js");
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
const { values: args } = parseArgs({
options: {
input: { type: "string", short: "i" },
output: { type: "string", short: "o" },
h3res: { type: "string", default: "8" },
delimiter: { type: "string", default: "auto" },
batchSize: { type: "string", default: "5000" },
},
});
const INPUT = args.input;
const OUTPUT = args.output;
const H3_RES = parseInt(args.h3res, 10);
const BATCH_SIZE = parseInt(args.batchSize, 10);
if (!INPUT || !OUTPUT) {
console.error("Usage: node csv-to-sqlite.mjs --input <csv> --output <db> [--h3res 8] [--delimiter auto] [--batchSize 5000]");
process.exit(1);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCHEMA_PATH = resolve(__dirname, "lib", "schema.sql");
function detectDelimiter(filePath) {
// Read first line to detect delimiter
const chunk = readFileSync(filePath, { encoding: "utf-8", end: 4096 });
const firstLine = chunk.split(/\r?\n/)[0];
const commaCount = (firstLine.match(/,/g) || []).length;
const semicolonCount = (firstLine.match(/;/g) || []).length;
const detected = semicolonCount > commaCount ? ";" : ",";
console.log(`Delimiter auto-detected: "${detected}" (commas=${commaCount}, semicolons=${semicolonCount})`);
return detected;
}
function computeH3(lat, lon, res) {
return h3.latLngToCell(lat, lon, res);
}
function deterministicId(lat, lon, nom, adresse) {
const payload = `${lat}|${lon}|${nom}|${adresse}`;
return createHash("sha256").update(payload, "utf-8").digest("hex").slice(0, 16);
}
function cleanFloat(val) {
const n = parseFloat(val);
return Number.isFinite(n) ? n : null;
}
function cleanInt(val) {
const n = parseInt(val, 10);
return Number.isFinite(n) ? n : 0;
}
function cleanStr(val) {
return (val ?? "").trim();
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const delimiter =
args.delimiter === "auto" ? detectDelimiter(INPUT) : args.delimiter;
// Remove existing output to avoid UNIQUE constraint errors on re-run
if (existsSync(OUTPUT)) {
unlinkSync(OUTPUT);
console.log(`Removed existing DB: ${OUTPUT}`);
}
// Open database with fast-import PRAGMAs
const db = new Database(OUTPUT);
db.pragma("journal_mode = OFF");
db.pragma("synchronous = OFF");
db.pragma("temp_store = MEMORY");
db.pragma("cache_size = -64000"); // 64 MB
db.pragma("locking_mode = EXCLUSIVE");
// Create schema
const schema = readFileSync(SCHEMA_PATH, "utf-8");
db.exec(schema);
// Prepare insert statement
const insert = db.prepare(
`INSERT OR IGNORE INTO defibs (id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h, h3)
VALUES (@id, @latitude, @longitude, @nom, @adresse, @horaires, @horaires_std, @acces, @disponible_24h, @h3)`
);
const insertMany = db.transaction((rows) => {
for (const row of rows) {
insert.run(row);
}
});
// Streaming CSV parse
const parser = createReadStream(INPUT, { encoding: "utf-8" }).pipe(
parse({
delimiter,
columns: true,
skip_empty_lines: true,
trim: true,
relax_column_count: true,
bom: true,
quote: '"',
escape: '"',
})
);
let batch = [];
let total = 0;
let skipped = 0;
for await (const record of parser) {
const lat = cleanFloat(record.latitude);
const lon = cleanFloat(record.longitude);
// Skip rows with invalid coordinates
if (lat === null || lon === null || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
skipped++;
continue;
}
const nom = cleanStr(record.nom);
const adresse = cleanStr(record.adresse);
const horaires = cleanStr(record.horaires);
const acces = cleanStr(record.acces);
const disponible_24h = cleanInt(record.disponible_24h);
const horaires_std = cleanStr(record.horaires_std) || "{}";
const id = deterministicId(lat, lon, nom, adresse);
const h3Cell = computeH3(lat, lon, H3_RES);
batch.push({
id,
latitude: lat,
longitude: lon,
nom,
adresse,
horaires,
horaires_std,
acces,
disponible_24h,
h3: h3Cell,
});
if (batch.length >= BATCH_SIZE) {
insertMany(batch);
total += batch.length;
batch = [];
process.stdout.write(`\rInserted ${total} rows...`);
}
}
// Flush remaining
if (batch.length > 0) {
insertMany(batch);
total += batch.length;
}
console.log(`\nDone: ${total} rows inserted, ${skipped} skipped.`);
// Restore safe PRAGMAs for the shipped DB
db.pragma("journal_mode = DELETE");
db.pragma("synchronous = NORMAL");
// VACUUM to compact
db.exec("VACUUM");
// Final stats
const count = db.prepare("SELECT count(*) AS cnt FROM defibs").get();
const pageCount = db.pragma("page_count", { simple: true });
const pageSize = db.pragma("page_size", { simple: true });
const sizeBytes = pageCount * pageSize;
console.log(`DB rows: ${count.cnt}`);
console.log(`DB size: ${(sizeBytes / 1024 / 1024).toFixed(2)} MB`);
db.close();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -1,45 +0,0 @@
#!/usr/bin/env node
// Download the GeoDAE JSON file from data.gouv.fr
// Source: https://www.data.gouv.fr/datasets/geodae-base-nationale-des-defibrillateurs
// Resource ID: 86ea48a0-dd94-4a23-b71c-80d3041d7db2
import { createWriteStream } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { pipeline } from "node:stream/promises";
const __dirname = dirname(fileURLToPath(import.meta.url));
const RESOURCE_ID = "86ea48a0-dd94-4a23-b71c-80d3041d7db2";
const DOWNLOAD_URL = `https://www.data.gouv.fr/api/1/datasets/r/${RESOURCE_ID}`;
const OUTPUT = join(__dirname, "geodae.json");
async function download() {
console.log(`Downloading GeoDAE data from data.gouv.fr ...`);
console.log(`URL: ${DOWNLOAD_URL}`);
const response = await fetch(DOWNLOAD_URL, { redirect: "follow" });
if (!response.ok) {
throw new Error(
`Download failed: ${response.status} ${response.statusText}`
);
}
const contentLength = response.headers.get("content-length");
if (contentLength) {
console.log(
`File size: ${(parseInt(contentLength, 10) / 1024 / 1024).toFixed(1)} MB`
);
}
await pipeline(response.body, createWriteStream(OUTPUT));
console.log(`Saved to ${OUTPUT}`);
}
download().catch((err) => {
console.error(err.message);
process.exit(1);
});

View file

@ -1,451 +0,0 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { normalizeHoraires } from "./lib/normalize-horaires.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const INPUT = join(__dirname, "geodae.json");
const OUTPUT = join(__dirname, "geodae.csv");
// --- Helpers ---
function escapeCsv(value) {
if (value == null) return "";
// Replace newlines and tabs with spaces to keep one row per entry
const str = String(value)
.replace(/[\r\n\t]+/g, " ")
.trim();
if (str.includes('"') || str.includes(",")) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
const DAY_ABBREV = {
lundi: "Lun",
mardi: "Mar",
mercredi: "Mer",
jeudi: "Jeu",
vendredi: "Ven",
samedi: "Sam",
dimanche: "Dim",
};
const DAY_ORDER = [
"lundi",
"mardi",
"mercredi",
"jeudi",
"vendredi",
"samedi",
"dimanche",
];
const DAY_NAMES_PATTERN =
/lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche/i;
const DAY_NAMES_EN_PATTERN =
/\b(mon|tue|wed|thu|fri|sat|sun)\b|mo-|tu-|we-|th-|fr-|sa-|su-/i;
const HOUR_PATTERN = /\d+[h:]\d*|\d+ ?heures?\b/;
function formatDays(arr) {
if (!arr || arr.length === 0) return "";
if (arr.length === 1) {
const val = arr[0].toLowerCase().trim();
if (val === "7j/7") return "7j/7";
if (val === "non renseigné" || val === "non renseigne") return "";
if (DAY_ABBREV[val]) return DAY_ABBREV[val];
return arr[0].trim();
}
// Sort days by canonical order
const sorted = arr
.filter((d) => d != null)
.map((d) => d.toLowerCase().trim())
.filter((d) => DAY_ORDER.includes(d))
.sort((a, b) => DAY_ORDER.indexOf(a) - DAY_ORDER.indexOf(b));
if (sorted.length === 0) return arr.filter((d) => d != null).join(", ");
if (sorted.length === 7) return "7j/7";
// Detect consecutive range
const indices = sorted.map((d) => DAY_ORDER.indexOf(d));
const isConsecutive = indices.every(
(idx, i) => i === 0 || idx === indices[i - 1] + 1,
);
if (isConsecutive && sorted.length >= 2) {
return DAY_ABBREV[sorted[0]] + "-" + DAY_ABBREV[sorted[sorted.length - 1]];
}
return sorted.map((d) => DAY_ABBREV[d] || d).join(", ");
}
function formatHours(arr) {
if (!arr || arr.length === 0) return "";
const cleaned = arr
.filter((h) => h != null)
.map((h) => h.trim())
.filter(
(h) =>
h &&
h.toLowerCase() !== "non renseigné" &&
h.toLowerCase() !== "non renseigne",
);
return cleaned.join(" + ");
}
/**
* Determine if always available:
* - 7j/7 + 24h/24
* - OR public (Extérieur + libre access)
*/
function isAlwaysAvailable(p) {
const is247 = is7j7(p.c_disp_j) && is24h(p.c_disp_h);
const isExterior =
p.c_acc &&
(p.c_acc.trim().toLowerCase() === "extérieur" ||
p.c_acc.trim().toLowerCase() === "exterieur");
const isPublic = isExterior && p.c_acc_lib === true;
return is247 || isPublic;
}
function is7j7(arr) {
if (!arr) return false;
if (arr.some((d) => d && d.trim() === "7j/7")) return true;
const days = arr
.filter((d) => d != null)
.map((d) => d.toLowerCase().trim())
.filter((d) => DAY_ORDER.includes(d));
return days.length === 7;
}
function is24h(arr) {
if (!arr) return false;
return arr.some((h) => h && h.trim() === "24h/24");
}
/**
* Build a single horaires string, merging days/hours/complement smartly.
* Returns empty string if always available.
*
* Heuristic for complement deduplication:
* - If complement contains day names it already describes the full schedule use complement only
* - Else if complement contains hour patterns (refines "heures ouvrables") use days + complement
* - Else use days + hours + complement (it's purely additional info)
*/
function buildHoraires(p) {
const days = formatDays(p.c_disp_j);
const hours = formatHours(p.c_disp_h);
const complt = (p.c_disp_complt || "").replace(/[\r\n\t]+/g, " ").trim();
if (!complt) {
// No complement: just days + hours
if (days && hours) return days + " " + hours;
return days || hours || "";
}
// Has complement: decide how to merge
const hasDayNames =
DAY_NAMES_PATTERN.test(complt) || DAY_NAMES_EN_PATTERN.test(complt);
const hasHours = HOUR_PATTERN.test(complt);
if (hasDayNames && hasHours) {
// Complement is a detailed per-day schedule (e.g. "Lundi au jeudi : 8h30-18h ...")
// Use complement only — it's more specific than the base timetable
return complt;
}
if (hasHours) {
// Complement specifies actual hours (e.g. "8h-18h")
// It refines the vague "heures ouvrables" → use days + complement
if (days) return days + " " + complt;
return complt;
}
// Complement is purely additional info (e.g. "Ouvert le dimanche...", "fermeture 31/12")
const base = days && hours ? days + " " + hours : days || hours || "";
if (base) return base + " ; " + complt;
return complt;
}
function formatAddress(p) {
const parts = [];
let num = (p.c_adr_num || "").trim();
let street = (p.c_adr_voie || "")
.split("\t")[0] // strip tab-separated cp/city embedded in the field
.split("|")[0] // strip pipe-separated cp/city embedded in the field
.trim();
// Drop invalid numbers: placeholders, decimals, letters, etc.
// Valid street numbers: digits with optional dash/slash/space separators (e.g. "62", "62-64", "10 12")
if (!/^\d[\d\s\-/]*$/.test(num)) num = "";
const cp = (p.c_com_cp || "").trim();
// Drop num when it equals the postal code (data entry mistake)
if (num && num === cp) num = "";
// Strip parenthesized cp from city name, e.g. "GANAC (09000)" → "GANAC"
let city = (p.c_com_nom || "").trim();
if (cp && city) {
city = city
.replace(
new RegExp(
"\\s*\\(" + cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\)",
),
"",
)
.trim();
}
// Strip cp+city already embedded in street field
// e.g. "Mont Salomon 38200 Vienne" or "62117 rue de Lambres" when cp matches
if (cp && street.includes(cp)) {
const cpEscaped = cp.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Trailing: "street 38200 Vienne" → "street"
street = street
.replace(new RegExp("\\s+" + cpEscaped + "\\s+.*$"), "")
.trim();
// Leading: "62117 rue de Lambres" → "rue de Lambres"
street = street.replace(new RegExp("^" + cpEscaped + "\\s+"), "").trim();
}
if (num && street) {
// Avoid duplicated number when street already starts with the same number
// Handles plain "62 Rue…", ranges "62-64 Rue…", and slashes "62/64 Rue…"
const numEscaped = num.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const alreadyHasNum = new RegExp("^" + numEscaped + "(?!\\d)").test(street);
if (alreadyHasNum) {
parts.push(street);
} else {
parts.push(num + " " + street);
}
} else if (street) {
parts.push(street);
} else if (num) {
parts.push(num);
}
if (cp && city) {
parts.push(cp + " " + city);
} else if (city) {
parts.push(city);
}
return parts.join(", ");
}
function formatAccess(p) {
const parts = [];
// Indoor/Outdoor
if (p.c_acc) parts.push(p.c_acc.trim());
// Free access
if (p.c_acc_lib === true) parts.push("libre");
// Floor
const floor = (p.c_acc_etg || "").trim().toLowerCase();
if (
floor &&
floor !== "0" &&
floor !== "rdc" &&
floor !== "rez de chaussee" &&
floor !== "rez de chaussée"
) {
parts.push("étage " + p.c_acc_etg.trim());
}
// Complement
const complt = (p.c_acc_complt || "").trim();
if (complt) parts.push(complt);
return parts.join(", ");
}
function getName(p) {
const expt = (p.c_expt_rais || "").trim();
const nom = (p.c_nom || "").trim();
return expt || nom || "";
}
function normalize(str) {
if (!str) return "";
return str
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}
function passesFilter(p) {
// c_etat: accept "Actif" or null, reject "Non identifie"
const etat = normalize(p.c_etat);
if (etat && etat !== "actif") return false;
// c_etat_fonct: must be "En fonctionnement"
const fonct = normalize(p.c_etat_fonct);
if (fonct !== "en fonctionnement") return false;
// c_etat_valid: must be "validées"
const valid = normalize(p.c_etat_valid);
if (valid !== "validees") return false;
return true;
}
/**
* Check if coordinates fall in a plausible French territory.
*/
function isPlausibleFrance(lat, lon) {
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return false;
// Metropolitan France
if (lat >= 41 && lat <= 52 && lon >= -6 && lon <= 11) return true;
// La Réunion
if (lat >= -22 && lat <= -20 && lon >= 54 && lon <= 57) return true;
// Mayotte
if (lat >= -14 && lat <= -12 && lon >= 44 && lon <= 46) return true;
// Guadeloupe / Martinique / Saint-Martin / Saint-Barthélemy
if (lat >= 14 && lat <= 18 && lon >= -64 && lon <= -60) return true;
// Guyane
if (lat >= 2 && lat <= 6 && lon >= -55 && lon <= -51) return true;
// Nouvelle-Calédonie
if (lat >= -23 && lat <= -19 && lon >= 163 && lon <= 169) return true;
// Polynésie française
if (lat >= -28 && lat <= -7 && lon >= -155 && lon <= -130) return true;
// Saint-Pierre-et-Miquelon
if (lat >= 46 && lat <= 48 && lon >= -57 && lon <= -55) return true;
// Wallis-et-Futuna
if (lat >= -15 && lat <= -13 && lon >= -179 && lon <= -176) return true;
// TAAF (Kerguelen, Crozet, Amsterdam, etc.)
if (lat >= -50 && lat <= -37 && lon >= 50 && lon <= 78) return true;
// Clipperton
if (lat >= 10 && lat <= 11 && lon >= -110 && lon <= -108) return true;
return false;
}
/**
* Try to fix an out-of-range coordinate by dividing by powers of 10.
* Returns the fixed value if it falls in [minValid, maxValid], else null.
*/
function tryNormalizeCoord(val, limit) {
if (Math.abs(val) <= limit) return val;
let v = val;
while (Math.abs(v) > limit) {
v /= 10;
}
return v;
}
/**
* Attempt to produce valid WGS84 coordinates from potentially garbled input.
* Strategy:
* 1. Use properties directly if valid
* 2. Fall back to GeoJSON geometry (standard [lon, lat] then swapped)
* 3. Try power-of-10 normalization for misplaced decimals
*/
function fixCoordinates(lat, lon, geometry) {
// 1. Already valid WGS84 — trust the source as-is
if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat, lon };
// Out of WGS84 range — try to recover using fallbacks + plausibility check
// 2. Try GeoJSON geometry
if (geometry && geometry.coordinates) {
let coords = geometry.coordinates;
// Flatten nested arrays (MultiPoint, etc.)
while (Array.isArray(coords[0])) coords = coords[0];
if (coords.length === 2) {
const [gLon, gLat] = coords; // GeoJSON = [lon, lat]
if (isPlausibleFrance(gLat, gLon)) return { lat: gLat, lon: gLon };
// Try swapped (some entries have lat/lon inverted in geometry)
if (isPlausibleFrance(gLon, gLat)) return { lat: gLon, lon: gLat };
}
}
// 3. Try power-of-10 normalization for misplaced decimals
const fixedLat = tryNormalizeCoord(lat, 90);
const fixedLon = tryNormalizeCoord(lon, 180);
if (isPlausibleFrance(fixedLat, fixedLon))
return { lat: fixedLat, lon: fixedLon };
return null;
}
// --- Main ---
console.log("Reading geodae.json...");
const data = JSON.parse(readFileSync(INPUT, "utf-8"));
const features = data.features;
console.log(`Total features: ${features.length}`);
const CSV_HEADER = [
"latitude",
"longitude",
"nom",
"adresse",
"horaires",
"horaires_std",
"acces",
"disponible_24h",
];
const rows = [CSV_HEADER.join(",")];
let filtered = 0;
let kept = 0;
let alwaysCount = 0;
for (const feature of features) {
const p = feature.properties;
if (!passesFilter(p)) {
filtered++;
continue;
}
const rawLat = p.c_lat_coor1;
const rawLon = p.c_long_coor1;
if (rawLat == null || rawLon == null) {
filtered++;
continue;
}
const fixed = fixCoordinates(rawLat, rawLon, feature.geometry);
if (!fixed) {
filtered++;
continue;
}
const { lat, lon } = fixed;
const always = isAlwaysAvailable(p);
if (always) alwaysCount++;
const disponible24h = always ? 1 : 0;
// When always available, leave horaires empty
const horaires = always ? "" : buildHoraires(p);
// Normalize horaires into structured JSON
const horairesStd = normalizeHoraires(horaires, disponible24h);
const row = [
lat,
lon,
escapeCsv(getName(p)),
escapeCsv(formatAddress(p)),
escapeCsv(horaires),
escapeCsv(JSON.stringify(horairesStd)),
escapeCsv(formatAccess(p)),
disponible24h,
];
rows.push(row.join(","));
kept++;
}
writeFileSync(OUTPUT, rows.join("\n") + "\n", "utf-8");
console.log(`Kept: ${kept}, Filtered out: ${filtered}`);
console.log(`Always available (24h): ${alwaysCount}`);
console.log(`Written to ${OUTPUT}`);

View file

@ -1,228 +0,0 @@
// Deterministic normalizer for French opening hours (horaires) strings.
// Outputs a structured object that a simple JSON parser can consume without heuristics.
//
// Output shape:
// { days: number[]|null, slots: {open,close}[]|null, is24h, businessHours, nightHours, events, notes }
//
// days: ISO 8601 day numbers (1=Mon … 7=Sun), null if unknown
// slots: [{open:"HH:MM", close:"HH:MM"}], null if no specific times
// is24h: available 24 hours
// businessHours: "heures ouvrables" was specified
// nightHours: "heures de nuit" was specified
// events: availability depends on events
// notes: unparsed/remaining text (seasonal info, conditions, etc.)
const DAY_MAP = { lun: 1, mar: 2, mer: 3, jeu: 4, ven: 5, sam: 6, dim: 7 };
const ALL_DAYS = [1, 2, 3, 4, 5, 6, 7];
// --- Day prefix extraction ---
const SEVEN_DAYS_RE = /^7\s*j?\s*[/]\s*7\s*j?/i;
const DAY_RANGE_RE =
/^(lun|mar|mer|jeu|ven|sam|dim)\s*-\s*(lun|mar|mer|jeu|ven|sam|dim)/i;
const DAY_LIST_RE =
/^((lun|mar|mer|jeu|ven|sam|dim)(\s*,\s*(lun|mar|mer|jeu|ven|sam|dim))+)/i;
const DAY_SINGLE_RE = /^(lun|mar|mer|jeu|ven|sam|dim)\b/i;
function dayRange(startName, endName) {
const start = DAY_MAP[startName.toLowerCase()];
const end = DAY_MAP[endName.toLowerCase()];
const days = [];
let d = start;
do {
days.push(d);
if (d === end) break;
d = (d % 7) + 1;
} while (days.length <= 7);
return days;
}
function extractDayPrefix(text) {
const m7 = text.match(SEVEN_DAYS_RE);
if (m7) return { days: [...ALL_DAYS], end: m7[0].length };
const mRange = text.match(DAY_RANGE_RE);
if (mRange)
return {
days: dayRange(mRange[1], mRange[2]),
end: mRange[0].length,
};
const mList = text.match(DAY_LIST_RE);
if (mList) {
const names = mList[0].split(/\s*,\s*/);
return {
days: names.map((n) => DAY_MAP[n.trim().toLowerCase()]).filter(Boolean),
end: mList[0].length,
};
}
const mSingle = text.match(DAY_SINGLE_RE);
if (mSingle)
return { days: [DAY_MAP[mSingle[1].toLowerCase()]], end: mSingle[0].length };
return null;
}
// --- Redundant day info stripping ---
function stripRedundantDays(text) {
return (
text
// "7J/7", "7j/7", "7/7", "7j/7j"
.replace(/\b7\s*[jJ]?\s*[/]\s*7\s*[jJ]?\b/g, "")
// "L au V", "Ma à D" (short abbreviations)
.replace(
/\b(?:L|Ma|Me|J|V|S|D)\s+(?:au|à)\s+(?:L|Ma|Me|J|V|S|D)\b/gi,
""
)
// "du lundi au dimanche" (full names)
.replace(
/\b(?:du\s+)?(?:lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\s+(?:au|à)\s+(?:lundi|mardi|mercredi|jeudi|vendredi|samedi|dimanche)\b/gi,
""
)
// "L au V" using abbreviated day names from data: "L Ma Me J V S D"
.replace(
/\b[LMJVSD]\s+(?:au|à)\s+[LMJVSD]\b/gi,
""
)
.replace(/^[,;:\-\s]+/, "")
.trim()
);
}
// --- Time slot extraction ---
function fmtTime(h, m) {
const hh = parseInt(h, 10);
const mm = parseInt(m || "0", 10);
if (hh < 0 || hh > 24 || mm < 0 || mm > 59) return null;
return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
}
// Matches: 8h30/17h30, 8h-18h, 08:00-18:00, 8h à 18h, 8h a 18h
// IMPORTANT: no \s* between [:h] and (\d{0,2}) — minutes must be adjacent
// to the separator, otherwise "8h/12h 14h/17h" would merge into one match.
const TIME_RANGE_RE =
/(\d{1,2})\s*[:h](\d{0,2})\s*(?:[-/à]|\ba\b)\s*(\d{1,2})\s*[:h](\d{0,2})/g;
// Matches standalone: 8h30, 14h (minutes adjacent to h)
const TIME_POINT_RE = /(\d{1,2})\s*h(\d{0,2})/g;
function extractTimeSlots(text) {
const slots = [];
// Pass 1: explicit ranges (8h/18h, 8h-18h, 08:00-18:00)
const re1 = new RegExp(TIME_RANGE_RE.source, "g");
let match;
while ((match = re1.exec(text)) !== null) {
const open = fmtTime(match[1], match[2]);
const close = fmtTime(match[3], match[4]);
if (open && close) slots.push({ open, close });
}
if (slots.length > 0) return slots;
// Pass 2: pair standalone time points (7h 17h → {07:00, 17:00})
const re2 = new RegExp(TIME_POINT_RE.source, "g");
const points = [];
while ((match = re2.exec(text)) !== null) {
const t = fmtTime(match[1], match[2]);
if (t) points.push(t);
}
for (let i = 0; i + 1 < points.length; i += 2) {
slots.push({ open: points[i], close: points[i + 1] });
}
return slots;
}
function removeTimeTokens(text) {
return text
.replace(
/(\d{1,2})\s*[:h](\d{0,2})\s*(?:[-/à]|\ba\b)\s*(\d{1,2})\s*[:h](\d{0,2})/g,
""
)
.replace(/(\d{1,2})\s*h(\d{0,2})/g, "")
.trim();
}
// --- Main normalizer ---
export function normalizeHoraires(raw, disponible24h) {
const result = {
days: null,
slots: null,
is24h: disponible24h === 1,
businessHours: false,
nightHours: false,
events: false,
notes: "",
};
if (disponible24h === 1) {
result.days = [...ALL_DAYS];
}
if (!raw || raw.trim() === "") return result;
let text = raw.trim();
// 1. Extract day prefix
const dayPrefix = extractDayPrefix(text);
if (dayPrefix) {
if (!result.days) result.days = dayPrefix.days;
text = text.slice(dayPrefix.end).trim();
// Strip leading comma/semicolon + optional modifiers after day prefix
text = text.replace(/^[,;]\s*/, "");
}
// 2. "jours fériés" modifier (informational, strip it)
text = text.replace(/,?\s*jours?\s+f[ée]ri[ée]s?\s*/gi, "").trim();
// 3. 24h/24 detection
if (/24\s*h?\s*[/]\s*24\s*h?/i.test(text)) {
result.is24h = true;
text = text.replace(/24\s*h?\s*[/]\s*24\s*h?/gi, "").trim();
if (!result.days) result.days = [...ALL_DAYS];
}
// 4. "heures ouvrables"
if (/heures?\s+ouvrables?/i.test(text)) {
result.businessHours = true;
text = text.replace(/heures?\s+ouvrables?/gi, "").trim();
}
// 5. "heures de nuit"
if (/heures?\s+de\s+nuit/i.test(text)) {
result.nightHours = true;
text = text.replace(/heures?\s+de\s+nuit/gi, "").trim();
}
// 6. "événements"
if (/[ée]v[éè]nements?/i.test(text)) {
result.events = true;
text = text.replace(/[ée]v[éè]nements?/gi, "").trim();
}
// 7. Strip redundant day info (e.g., "7J/7", "L au V")
text = stripRedundantDays(text);
// 8. Extract time slots (max 4 to cover morning+afternoon+evening combos)
if (!result.is24h) {
const slots = extractTimeSlots(text);
if (slots.length > 0) {
result.slots = slots.slice(0, 4);
text = removeTimeTokens(text);
}
}
// 9. Clean remaining text → notes
text = text
.replace(/^[;,\-/+.\s]+/, "")
.replace(/[;,\-/+.\s]+$/, "")
.replace(/\s+/g, " ")
.trim();
if (text) result.notes = text;
return result;
}

View file

@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS defibs (
id TEXT PRIMARY KEY NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
nom TEXT NOT NULL DEFAULT '',
adresse TEXT NOT NULL DEFAULT '',
horaires TEXT NOT NULL DEFAULT '',
horaires_std TEXT NOT NULL DEFAULT '{}',
acces TEXT NOT NULL DEFAULT '',
disponible_24h INTEGER NOT NULL DEFAULT 0,
h3 TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_defibs_h3 ON defibs (h3);
CREATE INDEX IF NOT EXISTS idx_defibs_latlon ON defibs (latitude, longitude);
CREATE INDEX IF NOT EXISTS idx_defibs_dispo ON defibs (disponible_24h);

View file

@ -1,19 +0,0 @@
{
"name": "geodae-pipeline",
"version": "1.0.0",
"private": true,
"type": "module",
"packageManager": "yarn@4.5.3",
"scripts": {
"download": "node download-geodae.mjs",
"json-to-csv": "node geodae-to-csv.js",
"csv-to-db": "node csv-to-sqlite.mjs --input geodae.csv --output ../../src/assets/db/geodae.db",
"csv-to-db:semicolon": "node csv-to-sqlite.mjs --input geodae.csv --output ../../src/assets/db/geodae.db --delimiter ';'",
"build": "yarn download && yarn json-to-csv && yarn csv-to-db"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"csv-parse": "^5.6.0",
"h3-js": "^4.2.1"
}
}

View file

@ -1,889 +0,0 @@
# This file is generated by running "yarn install" inside your project.
# Manual changes might be lost - proceed with caution!
__metadata:
version: 8
cacheKey: 10
"@gar/promise-retry@npm:^1.0.0":
version: 1.0.2
resolution: "@gar/promise-retry@npm:1.0.2"
dependencies:
retry: "npm:^0.13.1"
checksum: 10/b91326999ce94677cbe91973079eabc689761a93a045f6a2d34d4070e9305b27f6c54e4021688c7080cb14caf89eafa0c0f300af741b94c20d18608bdb66ca46
languageName: node
linkType: hard
"@isaacs/fs-minipass@npm:^4.0.0":
version: 4.0.1
resolution: "@isaacs/fs-minipass@npm:4.0.1"
dependencies:
minipass: "npm:^7.0.4"
checksum: 10/4412e9e6713c89c1e66d80bb0bb5a2a93192f10477623a27d08f228ba0316bb880affabc5bfe7f838f58a34d26c2c190da726e576cdfc18c49a72e89adabdcf5
languageName: node
linkType: hard
"@npmcli/agent@npm:^4.0.0":
version: 4.0.0
resolution: "@npmcli/agent@npm:4.0.0"
dependencies:
agent-base: "npm:^7.1.0"
http-proxy-agent: "npm:^7.0.0"
https-proxy-agent: "npm:^7.0.1"
lru-cache: "npm:^11.2.1"
socks-proxy-agent: "npm:^8.0.3"
checksum: 10/1a81573becc60515031accc696e6405e9b894e65c12b98ef4aeee03b5617c41948633159dbf6caf5dde5b47367eeb749bdc7b7dfb21960930a9060a935c6f636
languageName: node
linkType: hard
"@npmcli/fs@npm:^5.0.0":
version: 5.0.0
resolution: "@npmcli/fs@npm:5.0.0"
dependencies:
semver: "npm:^7.3.5"
checksum: 10/4935c7719d17830d0f9fa46c50be17b2a3c945cec61760f6d0909bce47677c42e1810ca673305890f9e84f008ec4d8e841182f371e42100a8159d15f22249208
languageName: node
linkType: hard
"abbrev@npm:^4.0.0":
version: 4.0.0
resolution: "abbrev@npm:4.0.0"
checksum: 10/e2f0c6a6708ad738b3e8f50233f4800de31ad41a6cdc50e0cbe51b76fed69fd0213516d92c15ce1a9985fca71a14606a9be22bf00f8475a58987b9bfb671c582
languageName: node
linkType: hard
"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2":
version: 7.1.4
resolution: "agent-base@npm:7.1.4"
checksum: 10/79bef167247789f955aaba113bae74bf64aa1e1acca4b1d6bb444bdf91d82c3e07e9451ef6a6e2e35e8f71a6f97ce33e3d855a5328eb9fad1bc3cc4cfd031ed8
languageName: node
linkType: hard
"balanced-match@npm:^4.0.2":
version: 4.0.4
resolution: "balanced-match@npm:4.0.4"
checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0
languageName: node
linkType: hard
"base64-js@npm:^1.3.1":
version: 1.5.1
resolution: "base64-js@npm:1.5.1"
checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005
languageName: node
linkType: hard
"better-sqlite3@npm:^11.7.0":
version: 11.10.0
resolution: "better-sqlite3@npm:11.10.0"
dependencies:
bindings: "npm:^1.5.0"
node-gyp: "npm:latest"
prebuild-install: "npm:^7.1.1"
checksum: 10/5e4c7437c4fe6033335a79c82974d7ab29f33c51c36f48b73e87e087d21578468575de1c56a7badd4f76f17255e25abefddaeacf018e5eeb9e0cb8d6e3e4a5e1
languageName: node
linkType: hard
"bindings@npm:^1.5.0":
version: 1.5.0
resolution: "bindings@npm:1.5.0"
dependencies:
file-uri-to-path: "npm:1.0.0"
checksum: 10/593d5ae975ffba15fbbb4788fe5abd1e125afbab849ab967ab43691d27d6483751805d98cb92f7ac24a2439a8a8678cd0131c535d5d63de84e383b0ce2786133
languageName: node
linkType: hard
"bl@npm:^4.0.3":
version: 4.1.0
resolution: "bl@npm:4.1.0"
dependencies:
buffer: "npm:^5.5.0"
inherits: "npm:^2.0.4"
readable-stream: "npm:^3.4.0"
checksum: 10/b7904e66ed0bdfc813c06ea6c3e35eafecb104369dbf5356d0f416af90c1546de3b74e5b63506f0629acf5e16a6f87c3798f16233dcff086e9129383aa02ab55
languageName: node
linkType: hard
"brace-expansion@npm:^5.0.2":
version: 5.0.4
resolution: "brace-expansion@npm:5.0.4"
dependencies:
balanced-match: "npm:^4.0.2"
checksum: 10/cfd57e20d8ded9578149e47ae4d3fff2b2f78d06b54a32a73057bddff65c8e9b930613f0cbcfefedf12dd117151e19d4da16367d5127c54f3bff02d8a4479bb2
languageName: node
linkType: hard
"buffer@npm:^5.5.0":
version: 5.7.1
resolution: "buffer@npm:5.7.1"
dependencies:
base64-js: "npm:^1.3.1"
ieee754: "npm:^1.1.13"
checksum: 10/997434d3c6e3b39e0be479a80288875f71cd1c07d75a3855e6f08ef848a3c966023f79534e22e415ff3a5112708ce06127277ab20e527146d55c84566405c7c6
languageName: node
linkType: hard
"cacache@npm:^20.0.1":
version: 20.0.3
resolution: "cacache@npm:20.0.3"
dependencies:
"@npmcli/fs": "npm:^5.0.0"
fs-minipass: "npm:^3.0.0"
glob: "npm:^13.0.0"
lru-cache: "npm:^11.1.0"
minipass: "npm:^7.0.3"
minipass-collect: "npm:^2.0.1"
minipass-flush: "npm:^1.0.5"
minipass-pipeline: "npm:^1.2.4"
p-map: "npm:^7.0.2"
ssri: "npm:^13.0.0"
unique-filename: "npm:^5.0.0"
checksum: 10/388a0169970df9d051da30437f93f81b7e91efb570ad0ff2b8fde33279fbe726c1bc8e8e2b9c05053ffb4f563854c73db395e8712e3b62347a1bc4f7fb8899ff
languageName: node
linkType: hard
"chownr@npm:^1.1.1":
version: 1.1.4
resolution: "chownr@npm:1.1.4"
checksum: 10/115648f8eb38bac5e41c3857f3e663f9c39ed6480d1349977c4d96c95a47266fcacc5a5aabf3cb6c481e22d72f41992827db47301851766c4fd77ac21a4f081d
languageName: node
linkType: hard
"chownr@npm:^3.0.0":
version: 3.0.0
resolution: "chownr@npm:3.0.0"
checksum: 10/b63cb1f73d171d140a2ed8154ee6566c8ab775d3196b0e03a2a94b5f6a0ce7777ee5685ca56849403c8d17bd457a6540672f9a60696a6137c7a409097495b82c
languageName: node
linkType: hard
"csv-parse@npm:^5.6.0":
version: 5.6.0
resolution: "csv-parse@npm:5.6.0"
checksum: 10/4c82e11f50ae0ccbac2aed716ef2502d0468bf96552083561db789fc0258ee4bb0a30106fcfb2684f153cb4042f0413e0eac3645d5466874803b7ccdeba67ac8
languageName: node
linkType: hard
"debug@npm:4, debug@npm:^4.3.4":
version: 4.4.3
resolution: "debug@npm:4.4.3"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad
languageName: node
linkType: hard
"decompress-response@npm:^6.0.0":
version: 6.0.0
resolution: "decompress-response@npm:6.0.0"
dependencies:
mimic-response: "npm:^3.1.0"
checksum: 10/d377cf47e02d805e283866c3f50d3d21578b779731e8c5072d6ce8c13cc31493db1c2f6784da9d1d5250822120cefa44f1deab112d5981015f2e17444b763812
languageName: node
linkType: hard
"deep-extend@npm:^0.6.0":
version: 0.6.0
resolution: "deep-extend@npm:0.6.0"
checksum: 10/7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7
languageName: node
linkType: hard
"detect-libc@npm:^2.0.0":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766
languageName: node
linkType: hard
"end-of-stream@npm:^1.1.0, end-of-stream@npm:^1.4.1":
version: 1.4.5
resolution: "end-of-stream@npm:1.4.5"
dependencies:
once: "npm:^1.4.0"
checksum: 10/1e0cfa6e7f49887544e03314f9dfc56a8cb6dde910cbb445983ecc2ff426fc05946df9d75d8a21a3a64f2cecfe1bf88f773952029f46756b2ed64a24e95b1fb8
languageName: node
linkType: hard
"env-paths@npm:^2.2.0":
version: 2.2.1
resolution: "env-paths@npm:2.2.1"
checksum: 10/65b5df55a8bab92229ab2b40dad3b387fad24613263d103a97f91c9fe43ceb21965cd3392b1ccb5d77088021e525c4e0481adb309625d0cb94ade1d1fb8dc17e
languageName: node
linkType: hard
"expand-template@npm:^2.0.3":
version: 2.0.3
resolution: "expand-template@npm:2.0.3"
checksum: 10/588c19847216421ed92befb521767b7018dc88f88b0576df98cb242f20961425e96a92cbece525ef28cc5becceae5d544ae0f5b9b5e2aa05acb13716ca5b3099
languageName: node
linkType: hard
"exponential-backoff@npm:^3.1.1":
version: 3.1.3
resolution: "exponential-backoff@npm:3.1.3"
checksum: 10/ca25962b4bbab943b7c4ed0b5228e263833a5063c65e1cdeac4be9afad350aae5466e8e619b5051f4f8d37b2144a2d6e8fcc771b6cc82934f7dade2f964f652c
languageName: node
linkType: hard
"fdir@npm:^6.5.0":
version: 6.5.0
resolution: "fdir@npm:6.5.0"
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1
languageName: node
linkType: hard
"file-uri-to-path@npm:1.0.0":
version: 1.0.0
resolution: "file-uri-to-path@npm:1.0.0"
checksum: 10/b648580bdd893a008c92c7ecc96c3ee57a5e7b6c4c18a9a09b44fb5d36d79146f8e442578bc0e173dc027adf3987e254ba1dfd6e3ec998b7c282873010502144
languageName: node
linkType: hard
"fs-constants@npm:^1.0.0":
version: 1.0.0
resolution: "fs-constants@npm:1.0.0"
checksum: 10/18f5b718371816155849475ac36c7d0b24d39a11d91348cfcb308b4494824413e03572c403c86d3a260e049465518c4f0d5bd00f0371cdfcad6d4f30a85b350d
languageName: node
linkType: hard
"fs-minipass@npm:^3.0.0":
version: 3.0.3
resolution: "fs-minipass@npm:3.0.3"
dependencies:
minipass: "npm:^7.0.3"
checksum: 10/af143246cf6884fe26fa281621d45cfe111d34b30535a475bfa38dafe343dadb466c047a924ffc7d6b7b18265df4110224ce3803806dbb07173bf2087b648d7f
languageName: node
linkType: hard
"geodae-pipeline@workspace:.":
version: 0.0.0-use.local
resolution: "geodae-pipeline@workspace:."
dependencies:
better-sqlite3: "npm:^11.7.0"
csv-parse: "npm:^5.6.0"
h3-js: "npm:^4.2.1"
languageName: unknown
linkType: soft
"github-from-package@npm:0.0.0":
version: 0.0.0
resolution: "github-from-package@npm:0.0.0"
checksum: 10/2a091ba07fbce22205642543b4ea8aaf068397e1433c00ae0f9de36a3607baf5bcc14da97fbb798cfca6393b3c402031fca06d8b491a44206d6efef391c58537
languageName: node
linkType: hard
"glob@npm:^13.0.0":
version: 13.0.6
resolution: "glob@npm:13.0.6"
dependencies:
minimatch: "npm:^10.2.2"
minipass: "npm:^7.1.3"
path-scurry: "npm:^2.0.2"
checksum: 10/201ad69e5f0aa74e1d8c00a481581f8b8c804b6a4fbfabeeb8541f5d756932800331daeba99b58fb9e4cd67e12ba5a7eba5b82fb476691588418060b84353214
languageName: node
linkType: hard
"graceful-fs@npm:^4.2.6":
version: 4.2.11
resolution: "graceful-fs@npm:4.2.11"
checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2
languageName: node
linkType: hard
"h3-js@npm:^4.2.1":
version: 4.4.0
resolution: "h3-js@npm:4.4.0"
checksum: 10/6db6888f143ed6a1e3ca10506f15c35679afd181e24b71bcdc90259206e3f02637bab38e2a35382d51f17151ea193dfab69c01ff3e31bf0e86abfb1957692576
languageName: node
linkType: hard
"http-cache-semantics@npm:^4.1.1":
version: 4.2.0
resolution: "http-cache-semantics@npm:4.2.0"
checksum: 10/4efd2dfcfeea9d5e88c84af450b9980be8a43c2c8179508b1c57c7b4421c855f3e8efe92fa53e0b3f4a43c85824ada930eabbc306d1b3beab750b6dcc5187693
languageName: node
linkType: hard
"http-proxy-agent@npm:^7.0.0":
version: 7.0.2
resolution: "http-proxy-agent@npm:7.0.2"
dependencies:
agent-base: "npm:^7.1.0"
debug: "npm:^4.3.4"
checksum: 10/d062acfa0cb82beeb558f1043c6ba770ea892b5fb7b28654dbc70ea2aeea55226dd34c02a294f6c1ca179a5aa483c4ea641846821b182edbd9cc5d89b54c6848
languageName: node
linkType: hard
"https-proxy-agent@npm:^7.0.1":
version: 7.0.6
resolution: "https-proxy-agent@npm:7.0.6"
dependencies:
agent-base: "npm:^7.1.2"
debug: "npm:4"
checksum: 10/784b628cbd55b25542a9d85033bdfd03d4eda630fb8b3c9477959367f3be95dc476ed2ecbb9836c359c7c698027fc7b45723a302324433590f45d6c1706e8c13
languageName: node
linkType: hard
"iconv-lite@npm:^0.7.2":
version: 0.7.2
resolution: "iconv-lite@npm:0.7.2"
dependencies:
safer-buffer: "npm:>= 2.1.2 < 3.0.0"
checksum: 10/24c937b532f868e938386b62410b303b7c767ce3d08dc2829cbe59464d5a26ef86ae5ad1af6b34eec43ddfea39e7d101638644b0178d67262fa87015d59f983a
languageName: node
linkType: hard
"ieee754@npm:^1.1.13":
version: 1.2.1
resolution: "ieee754@npm:1.2.1"
checksum: 10/d9f2557a59036f16c282aaeb107832dc957a93d73397d89bbad4eb1130560560eb695060145e8e6b3b498b15ab95510226649a0b8f52ae06583575419fe10fc4
languageName: node
linkType: hard
"imurmurhash@npm:^0.1.4":
version: 0.1.4
resolution: "imurmurhash@npm:0.1.4"
checksum: 10/2d30b157a91fe1c1d7c6f653cbf263f039be6c5bfa959245a16d4ee191fc0f2af86c08545b6e6beeb041c56b574d2d5b9f95343d378ab49c0f37394d541e7fc8
languageName: node
linkType: hard
"inherits@npm:^2.0.3, inherits@npm:^2.0.4":
version: 2.0.4
resolution: "inherits@npm:2.0.4"
checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521
languageName: node
linkType: hard
"ini@npm:~1.3.0":
version: 1.3.8
resolution: "ini@npm:1.3.8"
checksum: 10/314ae176e8d4deb3def56106da8002b462221c174ddb7ce0c49ee72c8cd1f9044f7b10cc555a7d8850982c3b9ca96fc212122749f5234bc2b6fb05fb942ed566
languageName: node
linkType: hard
"ip-address@npm:^10.0.1":
version: 10.1.0
resolution: "ip-address@npm:10.1.0"
checksum: 10/a6979629d1ad9c1fb424bc25182203fad739b40225aebc55ec6243bbff5035faf7b9ed6efab3a097de6e713acbbfde944baacfa73e11852bb43989c45a68d79e
languageName: node
linkType: hard
"isexe@npm:^4.0.0":
version: 4.0.0
resolution: "isexe@npm:4.0.0"
checksum: 10/2ead327ef596042ef9c9ec5f236b316acfaedb87f4bb61b3c3d574fb2e9c8a04b67305e04733bde52c24d9622fdebd3270aadb632adfbf9cadef88fe30f479e5
languageName: node
linkType: hard
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1":
version: 11.2.6
resolution: "lru-cache@npm:11.2.6"
checksum: 10/91222bbd59f793a0a0ad57789388f06b34ac9bb1613433c1d1810457d09db5cd3ec8943227ce2e1f5d6a0a15d6f1a9f129cb2c49ae9b6b10e82d4965fddecbef
languageName: node
linkType: hard
"make-fetch-happen@npm:^15.0.0":
version: 15.0.4
resolution: "make-fetch-happen@npm:15.0.4"
dependencies:
"@gar/promise-retry": "npm:^1.0.0"
"@npmcli/agent": "npm:^4.0.0"
cacache: "npm:^20.0.1"
http-cache-semantics: "npm:^4.1.1"
minipass: "npm:^7.0.2"
minipass-fetch: "npm:^5.0.0"
minipass-flush: "npm:^1.0.5"
minipass-pipeline: "npm:^1.2.4"
negotiator: "npm:^1.0.0"
proc-log: "npm:^6.0.0"
ssri: "npm:^13.0.0"
checksum: 10/4aa75baab500eff4259f2e1a3e76cf01ab3a3cd750037e4bd7b5e22bc5a60f12cc766b3c45e6288accb5ab609e88de5019a8014e0f96f6594b7b03cb504f4b81
languageName: node
linkType: hard
"mimic-response@npm:^3.1.0":
version: 3.1.0
resolution: "mimic-response@npm:3.1.0"
checksum: 10/7e719047612411fe071332a7498cf0448bbe43c485c0d780046c76633a771b223ff49bd00267be122cedebb897037fdb527df72335d0d0f74724604ca70b37ad
languageName: node
linkType: hard
"minimatch@npm:^10.2.2":
version: 10.2.4
resolution: "minimatch@npm:10.2.4"
dependencies:
brace-expansion: "npm:^5.0.2"
checksum: 10/aea4874e521c55bb60744685bbffe3d152e5460f84efac3ea936e6bbe2ceba7deb93345fec3f9bb17f7b6946776073a64d40ae32bf5f298ad690308121068a1f
languageName: node
linkType: hard
"minimist@npm:^1.2.0, minimist@npm:^1.2.3":
version: 1.2.8
resolution: "minimist@npm:1.2.8"
checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f
languageName: node
linkType: hard
"minipass-collect@npm:^2.0.1":
version: 2.0.1
resolution: "minipass-collect@npm:2.0.1"
dependencies:
minipass: "npm:^7.0.3"
checksum: 10/b251bceea62090f67a6cced7a446a36f4cd61ee2d5cea9aee7fff79ba8030e416327a1c5aa2908dc22629d06214b46d88fdab8c51ac76bacbf5703851b5ad342
languageName: node
linkType: hard
"minipass-fetch@npm:^5.0.0":
version: 5.0.2
resolution: "minipass-fetch@npm:5.0.2"
dependencies:
iconv-lite: "npm:^0.7.2"
minipass: "npm:^7.0.3"
minipass-sized: "npm:^2.0.0"
minizlib: "npm:^3.0.1"
dependenciesMeta:
iconv-lite:
optional: true
checksum: 10/4f3f65ea5b20a3a287765ebf21cc73e62031f754944272df2a3039296cc75a8fc2dc50b8a3c4f39ce3ac6e5cc583e8dc664d12c6ab98e0883d263e49f344bc86
languageName: node
linkType: hard
"minipass-flush@npm:^1.0.5":
version: 1.0.5
resolution: "minipass-flush@npm:1.0.5"
dependencies:
minipass: "npm:^3.0.0"
checksum: 10/56269a0b22bad756a08a94b1ffc36b7c9c5de0735a4dd1ab2b06c066d795cfd1f0ac44a0fcae13eece5589b908ecddc867f04c745c7009be0b566421ea0944cf
languageName: node
linkType: hard
"minipass-pipeline@npm:^1.2.4":
version: 1.2.4
resolution: "minipass-pipeline@npm:1.2.4"
dependencies:
minipass: "npm:^3.0.0"
checksum: 10/b14240dac0d29823c3d5911c286069e36d0b81173d7bdf07a7e4a91ecdef92cdff4baaf31ea3746f1c61e0957f652e641223970870e2353593f382112257971b
languageName: node
linkType: hard
"minipass-sized@npm:^2.0.0":
version: 2.0.0
resolution: "minipass-sized@npm:2.0.0"
dependencies:
minipass: "npm:^7.1.2"
checksum: 10/3b89adf64ca705662f77481e278eff5ec0a57aeffb5feba7cc8843722b1e7770efc880f2a17d1d4877b2d7bf227873cd46afb4da44c0fd18088b601ea50f96bb
languageName: node
linkType: hard
"minipass@npm:^3.0.0":
version: 3.3.6
resolution: "minipass@npm:3.3.6"
dependencies:
yallist: "npm:^4.0.0"
checksum: 10/a5c6ef069f70d9a524d3428af39f2b117ff8cd84172e19b754e7264a33df460873e6eb3d6e55758531580970de50ae950c496256bb4ad3691a2974cddff189f0
languageName: node
linkType: hard
"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3":
version: 7.1.3
resolution: "minipass@npm:7.1.3"
checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6
languageName: node
linkType: hard
"minizlib@npm:^3.0.1, minizlib@npm:^3.1.0":
version: 3.1.0
resolution: "minizlib@npm:3.1.0"
dependencies:
minipass: "npm:^7.1.2"
checksum: 10/f47365cc2cb7f078cbe7e046eb52655e2e7e97f8c0a9a674f4da60d94fb0624edfcec9b5db32e8ba5a99a5f036f595680ae6fe02a262beaa73026e505cc52f99
languageName: node
linkType: hard
"mkdirp-classic@npm:^0.5.2, mkdirp-classic@npm:^0.5.3":
version: 0.5.3
resolution: "mkdirp-classic@npm:0.5.3"
checksum: 10/3f4e088208270bbcc148d53b73e9a5bd9eef05ad2cbf3b3d0ff8795278d50dd1d11a8ef1875ff5aea3fa888931f95bfcb2ad5b7c1061cfefd6284d199e6776ac
languageName: node
linkType: hard
"ms@npm:^2.1.3":
version: 2.1.3
resolution: "ms@npm:2.1.3"
checksum: 10/aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d
languageName: node
linkType: hard
"napi-build-utils@npm:^2.0.0":
version: 2.0.0
resolution: "napi-build-utils@npm:2.0.0"
checksum: 10/69adcdb828481737f1ec64440286013f6479d5b264e24d5439ba795f65293d0bb6d962035de07c65fae525ed7d2fcd0baab6891d8e3734ea792fec43918acf83
languageName: node
linkType: hard
"negotiator@npm:^1.0.0":
version: 1.0.0
resolution: "negotiator@npm:1.0.0"
checksum: 10/b5734e87295324fabf868e36fb97c84b7d7f3156ec5f4ee5bf6e488079c11054f818290fc33804cef7b1ee21f55eeb14caea83e7dafae6492a409b3e573153e5
languageName: node
linkType: hard
"node-abi@npm:^3.3.0":
version: 3.87.0
resolution: "node-abi@npm:3.87.0"
dependencies:
semver: "npm:^7.3.5"
checksum: 10/3c7beafed49d9486b4bd95166bcf182b26d4aafa63c8620d1b3bd70a740fc256e1789453cfd83653b6efa7d259f0b1a34eaa95a6c62e74974ad99129bc78842f
languageName: node
linkType: hard
"node-gyp@npm:latest":
version: 12.2.0
resolution: "node-gyp@npm:12.2.0"
dependencies:
env-paths: "npm:^2.2.0"
exponential-backoff: "npm:^3.1.1"
graceful-fs: "npm:^4.2.6"
make-fetch-happen: "npm:^15.0.0"
nopt: "npm:^9.0.0"
proc-log: "npm:^6.0.0"
semver: "npm:^7.3.5"
tar: "npm:^7.5.4"
tinyglobby: "npm:^0.2.12"
which: "npm:^6.0.0"
bin:
node-gyp: bin/node-gyp.js
checksum: 10/4ebab5b77585a637315e969c2274b5520562473fe75de850639a580c2599652fb9f33959ec782ea45a2e149d8f04b548030f472eeeb3dbdf19a7f2ccbc30b908
languageName: node
linkType: hard
"nopt@npm:^9.0.0":
version: 9.0.0
resolution: "nopt@npm:9.0.0"
dependencies:
abbrev: "npm:^4.0.0"
bin:
nopt: bin/nopt.js
checksum: 10/56a1ccd2ad711fb5115918e2c96828703cddbe12ba2c3bd00591758f6fa30e6f47dd905c59dbfcf9b773f3a293b45996609fb6789ae29d6bfcc3cf3a6f7d9fda
languageName: node
linkType: hard
"once@npm:^1.3.1, once@npm:^1.4.0":
version: 1.4.0
resolution: "once@npm:1.4.0"
dependencies:
wrappy: "npm:1"
checksum: 10/cd0a88501333edd640d95f0d2700fbde6bff20b3d4d9bdc521bdd31af0656b5706570d6c6afe532045a20bb8dc0849f8332d6f2a416e0ba6d3d3b98806c7db68
languageName: node
linkType: hard
"p-map@npm:^7.0.2":
version: 7.0.4
resolution: "p-map@npm:7.0.4"
checksum: 10/ef48c3b2e488f31c693c9fcc0df0ef76518cf6426a495cf9486ebbb0fd7f31aef7f90e96f72e0070c0ff6e3177c9318f644b512e2c29e3feee8d7153fcb6782e
languageName: node
linkType: hard
"path-scurry@npm:^2.0.2":
version: 2.0.2
resolution: "path-scurry@npm:2.0.2"
dependencies:
lru-cache: "npm:^11.0.0"
minipass: "npm:^7.1.2"
checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3
languageName: node
linkType: hard
"picomatch@npm:^4.0.3":
version: 4.0.3
resolution: "picomatch@npm:4.0.3"
checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5
languageName: node
linkType: hard
"prebuild-install@npm:^7.1.1":
version: 7.1.3
resolution: "prebuild-install@npm:7.1.3"
dependencies:
detect-libc: "npm:^2.0.0"
expand-template: "npm:^2.0.3"
github-from-package: "npm:0.0.0"
minimist: "npm:^1.2.3"
mkdirp-classic: "npm:^0.5.3"
napi-build-utils: "npm:^2.0.0"
node-abi: "npm:^3.3.0"
pump: "npm:^3.0.0"
rc: "npm:^1.2.7"
simple-get: "npm:^4.0.0"
tar-fs: "npm:^2.0.0"
tunnel-agent: "npm:^0.6.0"
bin:
prebuild-install: bin.js
checksum: 10/1b7e4c00d2750b532a4fc2a83ffb0c5fefa1b6f2ad071896ead15eeadc3255f5babd816949991af083cf7429e375ae8c7d1c51f73658559da36f948a020a3a11
languageName: node
linkType: hard
"proc-log@npm:^6.0.0":
version: 6.1.0
resolution: "proc-log@npm:6.1.0"
checksum: 10/9033f30f168ed5a0991b773d0c50ff88384c4738e9a0a67d341de36bf7293771eed648ab6a0562f62276da12fde91f3bbfc75ffff6e71ad49aafd74fc646be66
languageName: node
linkType: hard
"pump@npm:^3.0.0":
version: 3.0.4
resolution: "pump@npm:3.0.4"
dependencies:
end-of-stream: "npm:^1.1.0"
once: "npm:^1.3.1"
checksum: 10/d043c3e710c56ffd280711e98a94e863ab334f79ea43cee0fb70e1349b2355ffd2ff287c7522e4c960a247699d5b7825f00fa090b85d6179c973be13f78a6c49
languageName: node
linkType: hard
"rc@npm:^1.2.7":
version: 1.2.8
resolution: "rc@npm:1.2.8"
dependencies:
deep-extend: "npm:^0.6.0"
ini: "npm:~1.3.0"
minimist: "npm:^1.2.0"
strip-json-comments: "npm:~2.0.1"
bin:
rc: ./cli.js
checksum: 10/5c4d72ae7eec44357171585938c85ce066da8ca79146b5635baf3d55d74584c92575fa4e2c9eac03efbed3b46a0b2e7c30634c012b4b4fa40d654353d3c163eb
languageName: node
linkType: hard
"readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
inherits: "npm:^2.0.3"
string_decoder: "npm:^1.1.1"
util-deprecate: "npm:^1.0.1"
checksum: 10/d9e3e53193adcdb79d8f10f2a1f6989bd4389f5936c6f8b870e77570853561c362bee69feca2bbb7b32368ce96a85504aa4cedf7cf80f36e6a9de30d64244048
languageName: node
linkType: hard
"retry@npm:^0.13.1":
version: 0.13.1
resolution: "retry@npm:0.13.1"
checksum: 10/6125ec2e06d6e47e9201539c887defba4e47f63471db304c59e4b82fc63c8e89ca06a77e9d34939a9a42a76f00774b2f46c0d4a4cbb3e287268bd018ed69426d
languageName: node
linkType: hard
"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"
checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451
languageName: node
linkType: hard
"safer-buffer@npm:>= 2.1.2 < 3.0.0":
version: 2.1.2
resolution: "safer-buffer@npm:2.1.2"
checksum: 10/7eaf7a0cf37cc27b42fb3ef6a9b1df6e93a1c6d98c6c6702b02fe262d5fcbd89db63320793b99b21cb5348097d0a53de81bd5f4e8b86e20cc9412e3f1cfb4e83
languageName: node
linkType: hard
"semver@npm:^7.3.5":
version: 7.7.4
resolution: "semver@npm:7.7.4"
bin:
semver: bin/semver.js
checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75
languageName: node
linkType: hard
"simple-concat@npm:^1.0.0":
version: 1.0.1
resolution: "simple-concat@npm:1.0.1"
checksum: 10/4d211042cc3d73a718c21ac6c4e7d7a0363e184be6a5ad25c8a1502e49df6d0a0253979e3d50dbdd3f60ef6c6c58d756b5d66ac1e05cda9cacd2e9fc59e3876a
languageName: node
linkType: hard
"simple-get@npm:^4.0.0":
version: 4.0.1
resolution: "simple-get@npm:4.0.1"
dependencies:
decompress-response: "npm:^6.0.0"
once: "npm:^1.3.1"
simple-concat: "npm:^1.0.0"
checksum: 10/93f1b32319782f78f2f2234e9ce34891b7ab6b990d19d8afefaa44423f5235ce2676aae42d6743fecac6c8dfff4b808d4c24fe5265be813d04769917a9a44f36
languageName: node
linkType: hard
"smart-buffer@npm:^4.2.0":
version: 4.2.0
resolution: "smart-buffer@npm:4.2.0"
checksum: 10/927484aa0b1640fd9473cee3e0a0bcad6fce93fd7bbc18bac9ad0c33686f5d2e2c422fba24b5899c184524af01e11dd2bd051c2bf2b07e47aff8ca72cbfc60d2
languageName: node
linkType: hard
"socks-proxy-agent@npm:^8.0.3":
version: 8.0.5
resolution: "socks-proxy-agent@npm:8.0.5"
dependencies:
agent-base: "npm:^7.1.2"
debug: "npm:^4.3.4"
socks: "npm:^2.8.3"
checksum: 10/ee99e1dacab0985b52cbe5a75640be6e604135e9489ebdc3048635d186012fbaecc20fbbe04b177dee434c319ba20f09b3e7dfefb7d932466c0d707744eac05c
languageName: node
linkType: hard
"socks@npm:^2.8.3":
version: 2.8.7
resolution: "socks@npm:2.8.7"
dependencies:
ip-address: "npm:^10.0.1"
smart-buffer: "npm:^4.2.0"
checksum: 10/d19366c95908c19db154f329bbe94c2317d315dc933a7c2b5101e73f32a555c84fb199b62174e1490082a593a4933d8d5a9b297bde7d1419c14a11a965f51356
languageName: node
linkType: hard
"ssri@npm:^13.0.0":
version: 13.0.1
resolution: "ssri@npm:13.0.1"
dependencies:
minipass: "npm:^7.0.3"
checksum: 10/ae560d0378d074006a71b06af71bfbe84a3fe1ac6e16c1f07575f69e670d40170507fe52b21bcc23399429bc6a15f4bc3ea8d9bc88e9dfd7e87de564e6da6a72
languageName: node
linkType: hard
"string_decoder@npm:^1.1.1":
version: 1.3.0
resolution: "string_decoder@npm:1.3.0"
dependencies:
safe-buffer: "npm:~5.2.0"
checksum: 10/54d23f4a6acae0e93f999a585e673be9e561b65cd4cca37714af1e893ab8cd8dfa52a9e4f58f48f87b4a44918d3a9254326cb80ed194bf2e4c226e2b21767e56
languageName: node
linkType: hard
"strip-json-comments@npm:~2.0.1":
version: 2.0.1
resolution: "strip-json-comments@npm:2.0.1"
checksum: 10/1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1
languageName: node
linkType: hard
"tar-fs@npm:^2.0.0":
version: 2.1.4
resolution: "tar-fs@npm:2.1.4"
dependencies:
chownr: "npm:^1.1.1"
mkdirp-classic: "npm:^0.5.2"
pump: "npm:^3.0.0"
tar-stream: "npm:^2.1.4"
checksum: 10/bdf7e3cb039522e39c6dae3084b1bca8d7bcc1de1906eae4a1caea6a2250d22d26dcc234118bf879b345d91ebf250a744b196e379334a4abcbb109a78db7d3be
languageName: node
linkType: hard
"tar-stream@npm:^2.1.4":
version: 2.2.0
resolution: "tar-stream@npm:2.2.0"
dependencies:
bl: "npm:^4.0.3"
end-of-stream: "npm:^1.4.1"
fs-constants: "npm:^1.0.0"
inherits: "npm:^2.0.3"
readable-stream: "npm:^3.1.1"
checksum: 10/1a52a51d240c118cbcd30f7368ea5e5baef1eac3e6b793fb1a41e6cd7319296c79c0264ccc5859f5294aa80f8f00b9239d519e627b9aade80038de6f966fec6a
languageName: node
linkType: hard
"tar@npm:^7.5.4":
version: 7.5.10
resolution: "tar@npm:7.5.10"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10/98ba6421a250b233c36a54f7441647bdfee1ed0b916cd57850259a3602154d996f5b8422f67ef5c8ce77f582ed938054775c2873fc7c901e0c7530ed50febc40
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.12":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
dependencies:
fdir: "npm:^6.5.0"
picomatch: "npm:^4.0.3"
checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2
languageName: node
linkType: hard
"tunnel-agent@npm:^0.6.0":
version: 0.6.0
resolution: "tunnel-agent@npm:0.6.0"
dependencies:
safe-buffer: "npm:^5.0.1"
checksum: 10/7f0d9ed5c22404072b2ae8edc45c071772affd2ed14a74f03b4e71b4dd1a14c3714d85aed64abcaaee5fec2efc79002ba81155c708f4df65821b444abb0cfade
languageName: node
linkType: hard
"unique-filename@npm:^5.0.0":
version: 5.0.0
resolution: "unique-filename@npm:5.0.0"
dependencies:
unique-slug: "npm:^6.0.0"
checksum: 10/a5f67085caef74bdd2a6869a200ed5d68d171f5cc38435a836b5fd12cce4e4eb55e6a190298035c325053a5687ed7a3c96f0a91e82215fd14729769d9ac57d9b
languageName: node
linkType: hard
"unique-slug@npm:^6.0.0":
version: 6.0.0
resolution: "unique-slug@npm:6.0.0"
dependencies:
imurmurhash: "npm:^0.1.4"
checksum: 10/b78ed9d5b01ff465f80975f17387750ed3639909ac487fa82c4ae4326759f6de87c2131c0c39eca4c68cf06c537a8d104fba1dfc8a30308f99bc505345e1eba3
languageName: node
linkType: hard
"util-deprecate@npm:^1.0.1":
version: 1.0.2
resolution: "util-deprecate@npm:1.0.2"
checksum: 10/474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2
languageName: node
linkType: hard
"which@npm:^6.0.0":
version: 6.0.1
resolution: "which@npm:6.0.1"
dependencies:
isexe: "npm:^4.0.0"
bin:
node-which: bin/which.js
checksum: 10/dbea77c7d3058bf6c78bf9659d2dce4d2b57d39a15b826b2af6ac2e5a219b99dc8a831b79fdbc453c0598adb4f3f84cf9c2491fd52beb9f5d2dececcad117f68
languageName: node
linkType: hard
"wrappy@npm:1":
version: 1.0.2
resolution: "wrappy@npm:1.0.2"
checksum: 10/159da4805f7e84a3d003d8841557196034155008f817172d4e986bd591f74aa82aa7db55929a54222309e01079a65a92a9e6414da5a6aa4b01ee44a511ac3ee5
languageName: node
linkType: hard
"yallist@npm:^4.0.0":
version: 4.0.0
resolution: "yallist@npm:4.0.0"
checksum: 10/4cb02b42b8a93b5cf50caf5d8e9beb409400a8a4d85e83bb0685c1457e9ac0b7a00819e9f5991ac25ffabb56a78e2f017c1acc010b3a1babfe6de690ba531abd
languageName: node
linkType: hard
"yallist@npm:^5.0.0":
version: 5.0.0
resolution: "yallist@npm:5.0.0"
checksum: 10/1884d272d485845ad04759a255c71775db0fac56308764b4c77ea56a20d56679fad340213054c8c9c9c26fcfd4c4b2a90df993b7e0aaf3cdb73c618d1d1a802a
languageName: node
linkType: hard

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

View file

@ -1,71 +0,0 @@
import React, { useMemo } from "react";
import { View } from "react-native";
import { Button, Modal, Portal } from "react-native-paper";
import Text from "~/components/Text";
import { useRootNav } from "~/navigation/Context";
import { defibsActions, useDefibsState } from "~/stores";
import { useTheme } from "~/theme";
export default function DaeSuggestModal() {
const { showDaeSuggestModal } = useDefibsState(["showDaeSuggestModal"]);
const navigationRef = useRootNav();
const { colors } = useTheme();
const styles = useMemo(
() => ({
container: {
backgroundColor: colors.surface,
padding: 20,
marginHorizontal: 16,
borderRadius: 12,
},
title: { fontSize: 20, fontWeight: "bold" },
paragraph: { marginTop: 10, fontSize: 16 },
actionsColumn: {
marginTop: 18,
gap: 10,
},
}),
[colors.surface],
);
const dismiss = () => {
defibsActions.setShowDaeSuggestModal(false);
};
const goToDaeList = () => {
dismiss();
// DAEList is inside the Drawer navigator which is the RootStack "Main" screen.
// Using the root navigation ref makes this modal independent from current route.
navigationRef?.current?.navigate("Main", {
screen: "DAEList",
});
};
return (
<Portal>
<Modal
visible={!!showDaeSuggestModal}
onDismiss={dismiss}
contentContainerStyle={styles.container}
>
<Text style={styles.title}>Défibrillateur à proximité</Text>
<Text style={styles.paragraph}>
En cas d'arrêt cardiaque, un défibrillateur (DAE) à proximité peut
sauver une vie.
</Text>
<View style={styles.actionsColumn}>
<Button mode="contained" onPress={goToDaeList}>
Chercher un défibrillateur
</Button>
<Button mode="outlined" onPress={dismiss}>
Non merci
</Button>
</View>
</Modal>
</Portal>
);
}

View file

@ -25,12 +25,7 @@ export default function AlertSymbolLayer({ level, isDisabled }) {
return (
<Maplibre.SymbolLayer
filter={[
"all",
["==", ["get", "icon"], icon],
// Exclude DAE overlay markers (v1: separate non-clustered layer)
["!=", ["get", "isDefib"], true],
]}
filter={["==", ["get", "icon"], icon]}
key={key}
id={key}
belowLayerID={belowLayerID}

View file

@ -5,23 +5,19 @@ import Maplibre from "@maplibre/maplibre-react-native";
import markerRed from "~/assets/img/marker-red.png";
import markerYellow from "~/assets/img/marker-yellow.png";
import markerGreen from "~/assets/img/marker-green.png";
import markerGrey from "~/assets/img/marker-grey.png";
import markerRedDisabled from "~/assets/img/marker-red-disabled.png";
import markerYellowDisabled from "~/assets/img/marker-yellow-disabled.png";
import markerGreenDisabled from "~/assets/img/marker-green-disabled.png";
import markerOrigin from "~/assets/img/marker-origin.png";
import markerDae from "~/assets/img/marker-dae.png";
const images = {
red: markerRed,
yellow: markerYellow,
green: markerGreen,
grey: markerGrey,
redDisabled: markerRedDisabled,
yellowDisabled: markerYellowDisabled,
greenDisabled: markerGreenDisabled,
origin: markerOrigin,
dae: markerDae,
};
export default function FeatureImages() {

View file

@ -17,12 +17,6 @@ const iconStyle = {
iconSize: 0.5,
};
const defibStyle = {
iconImage: "dae",
iconSize: 0.5,
iconAllowOverlap: true,
};
const useStyles = createStyles(({ theme: { colors } }) => ({
clusterCount: {
textField: "{point_count_abbreviated}",
@ -64,15 +58,6 @@ export default function ShapePoints({ shape, children, ...shapeSourceProps }) {
style={iconStyle}
/>
{/* Defibrillators (DAE) separate layer (non-clustered) */}
<Maplibre.SymbolLayer
filter={["==", ["get", "isDefib"], true]}
key="points-defib"
id="points-defib"
aboveLayerID="points-origin"
style={defibStyle}
/>
{children}
</Maplibre.ShapeSource>
);

View file

@ -1,74 +0,0 @@
// Final exported function to retrieve nearby defibrillators from the embedded DB.
// Usage:
// import getNearbyDefibs from "~/data/getNearbyDefibs";
// const results = await getNearbyDefibs({ lat: 48.8566, lon: 2.3522, radiusMeters: 1000, limit: 20 });
import {
getNearbyDefibs as queryNearby,
getNearbyDefibsBbox,
} from "~/db/defibsRepo";
/**
* @typedef {Object} DefibResult
* @property {string} id
* @property {number} latitude
* @property {number} longitude
* @property {string} nom
* @property {string} adresse
* @property {string} horaires
* @property {string} acces
* @property {number} disponible_24h
* @property {number} distanceMeters
*/
/**
* Retrieve nearby defibrillators, sorted by distance.
* Uses H3 spatial index with automatic bbox fallback.
*
* @param {Object} params
* @param {number} params.lat - User latitude (WGS84)
* @param {number} params.lon - User longitude (WGS84)
* @param {number} params.radiusMeters - Search radius in meters
* @param {number} params.limit - Maximum number of results
* @param {boolean} [params.disponible24hOnly] - Only return 24/7 accessible defibrillators
* @param {boolean} [params.progressive] - Progressive H3 ring expansion (saves queries for small radii)
* @returns {Promise<DefibResult[]>}
*/
export default async function getNearbyDefibs({
lat,
lon,
radiusMeters,
limit,
disponible24hOnly = false,
progressive = true,
}) {
try {
return await queryNearby({
lat,
lon,
radiusMeters,
limit,
disponible24hOnly,
progressive,
});
} catch (err) {
// Fallback to bbox if H3 fails (e.g. missing h3-js on a platform)
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,
radiusMeters,
limit,
disponible24hOnly,
});
}
}

View file

@ -1,221 +0,0 @@
// Defibrillator repository — nearby queries with H3 geo-indexing.
import { latLngToCell, gridDisk } from "~/lib/h3";
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,
];
const H3_RES = 8;
// SQLite max variable number is 999 by default; chunk IN() queries accordingly.
const SQL_VAR_LIMIT = 900;
// Compute k (ring size) needed to cover a given radius at a given H3 resolution.
function kForRadius(radiusMeters, res = H3_RES) {
const edge = H3_EDGE_M[res];
// sqrt(3) * edge ≈ diameter between parallel edges of a hexagon
return Math.max(1, Math.ceil(radiusMeters / (edge * Math.sqrt(3))));
}
// Build a bounding-box fallback SQL clause + params.
function bboxClause(lat, lon, radiusMeters) {
// 1 degree latitude ≈ 111_320 m
const dLat = radiusMeters / 111_320;
// 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 ?",
params: [lat - dLat, lat + dLat, lon - dLon, lon + dLon],
};
}
/**
* @typedef {Object} Defib
* @property {string} id
* @property {number} latitude
* @property {number} longitude
* @property {string} nom
* @property {string} adresse
* @property {string} horaires
* @property {Object} horaires_std
* @property {number[]|null} horaires_std.days - ISO 8601 day numbers (1=Mon7=Sun)
* @property {{open:string,close:string}[]|null} horaires_std.slots - Time ranges
* @property {boolean} horaires_std.is24h
* @property {boolean} horaires_std.businessHours
* @property {boolean} horaires_std.nightHours
* @property {boolean} horaires_std.events
* @property {string} horaires_std.notes
* @property {string} acces
* @property {number} disponible_24h
*/
/**
* Fetch defibrillators near a given point.
*
* @param {Object} params
* @param {number} params.lat - User latitude
* @param {number} params.lon - User longitude
* @param {number} params.radiusMeters - Search radius in meters
* @param {number} params.limit - Max results returned
* @param {boolean} [params.disponible24hOnly] - Filter 24/7 accessible only
* @param {boolean} [params.progressive] - Enable progressive expansion (k=1,2,3)
* @returns {Promise<(Defib & { distanceMeters: number })[]>}
*/
export async function getNearbyDefibs({
lat,
lon,
radiusMeters,
limit,
disponible24hOnly = false,
progressive = false,
}) {
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,
);
}
// One-shot: compute full disk and query
const cells = gridDisk(latLngToCell(lat, lon, H3_RES), maxK);
const candidates = await queryCells(db, cells, disponible24hOnly);
return rankAndFilter(candidates, lat, lon, radiusMeters, limit);
}
// Progressive expansion: start at k=1, expand until enough results or maxK.
async function progressiveSearch(
db,
lat,
lon,
radiusMeters,
limit,
dispo24h,
maxK,
) {
let allCandidates = [];
const seenIds = new Set();
for (let k = 1; k <= maxK; k++) {
const cells = gridDisk(latLngToCell(lat, lon, H3_RES), k);
const rows = await queryCells(db, cells, dispo24h);
for (const row of rows) {
if (!seenIds.has(row.id)) {
seenIds.add(row.id);
allCandidates.push(row);
}
}
// 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,
);
if (ranked.length >= limit) return ranked;
}
}
return rankAndFilter(allCandidates, lat, lon, radiusMeters, limit);
}
// Query the DB for rows matching a set of H3 cells, chunking if needed.
async function queryCells(db, cells, dispo24h) {
if (cells.length === 0) return [];
const results = [];
// Chunk cells to stay under SQLite variable limit
for (let i = 0; i < cells.length; i += SQL_VAR_LIMIT) {
const chunk = cells.slice(i, i + SQL_VAR_LIMIT);
const placeholders = chunk.map(() => "?").join(",");
let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h
FROM defibs WHERE h3 IN (${placeholders})`;
const params = [...chunk];
if (dispo24h) {
sql += " AND disponible_24h = 1";
}
const rows = await db.getAllAsync(sql, params);
results.push(...rows);
}
return results;
}
// Parse horaires_std JSON string into object.
function parseHorairesStd(row) {
try {
return { ...row, horaires_std: JSON.parse(row.horaires_std) };
} catch {
return { ...row, horaires_std: null };
}
}
// Compute distance, filter by radius, sort, and limit.
function rankAndFilter(candidates, lat, lon, radiusMeters, limit) {
const withDist = [];
for (const row of candidates) {
const distanceMeters = haversine(lat, lon, row.latitude, row.longitude);
if (distanceMeters <= radiusMeters) {
withDist.push({ ...parseHorairesStd(row), distanceMeters });
}
}
withDist.sort((a, b) => a.distanceMeters - b.distanceMeters);
return withDist.slice(0, limit);
}
/**
* Bbox fallback use when H3 is unavailable.
*
* @param {Object} params
* @param {number} params.lat
* @param {number} params.lon
* @param {number} params.radiusMeters
* @param {number} params.limit
* @param {boolean} [params.disponible24hOnly]
* @returns {Promise<(Defib & { distanceMeters: number })[]>}
*/
export async function getNearbyDefibsBbox({
lat,
lon,
radiusMeters,
limit,
disponible24hOnly = false,
}) {
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
FROM defibs WHERE ${clause}`;
if (disponible24hOnly) {
sql += " AND disponible_24h = 1";
}
const rows = await db.getAllAsync(sql, params);
return rankAndFilter(rows, lat, lon, radiusMeters, limit);
}

View file

@ -1,123 +0,0 @@
// 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<EnsureEmbeddedDbResult>}
*/
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,
};

View file

@ -1,98 +0,0 @@
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([]);
});
});

View file

@ -1,359 +0,0 @@
// 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 = getDbImpl();
}
return _dbPromise;
}
/**
* Close the current DB connection and clear all cached state.
* After calling this, the next `getDb()` / `getDbSafe()` call will re-open
* the DB from disk picking up any file that was swapped in the meantime.
*/
export function resetDb() {
// Close the op-sqlite backend if it was loaded.
try {
// eslint-disable-next-line global-require
const { resetDbOpSqlite } = require("./openDbOpSqlite");
if (typeof resetDbOpSqlite === "function") {
resetDbOpSqlite();
}
} catch {
// op-sqlite not available — nothing to close.
}
_dbPromise = null;
_backendPromise = null;
_selectedBackendName = null;
}
/**
* Non-throwing DB opener.
*
* 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";
const logErrorDetails = (label, err) => {
if (!err) {
console.warn(`${prefix} ${label} <no error object>`);
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 ?? "<unknown-backend>";
console.warn(`${prefix} backend attempted:`, backend);
logErrorDetails(`(backend=${backend})`, entry?.error);
}
}
return { db: null, error };
}
}
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;
},
};
}

View file

@ -1,87 +0,0 @@
// 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<any>} 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,
};

View file

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

View file

@ -1,32 +0,0 @@
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,
);
});
});

View file

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

View file

@ -1,34 +0,0 @@
// 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,
};

View file

@ -1,19 +0,0 @@
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,
);
});
});

View file

@ -128,7 +128,7 @@ export default function useLatestWithSubscription(
// Some devices keep the WS transport "connected" after a lock/unlock, but the
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
const FOREGROUND_KICK_MIN_INACTIVE_MS = 30_000;
const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
if (
@ -239,15 +239,6 @@ export default function useLatestWithSubscription(
if (age < livenessStaleMs) return;
const now = Date.now();
const becameInactiveAt = lastBecameInactiveAtRef.current;
const inactiveWindowMs = becameInactiveAt ? now - becameInactiveAt : null;
if (
typeof inactiveWindowMs === "number" &&
inactiveWindowMs < livenessStaleMs + 15_000
) {
return;
}
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
lastLivenessKickAtRef.current = now;
@ -285,7 +276,7 @@ export default function useLatestWithSubscription(
// Escalation policy for repeated consecutive stale kicks.
if (
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD + 2 &&
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
) {
const lastRecovery = wsLastRecoveryDateRef.current
@ -319,7 +310,7 @@ export default function useLatestWithSubscription(
// ignore
}
networkActions.triggerReload("transport");
networkActions.triggerReload();
} else if (
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS

View file

@ -131,7 +131,7 @@ export default function useStreamQueryWithSubscription(
// Some devices keep the WS transport "connected" after a lock/unlock, but the
// per-operation subscription stops delivering. Trigger a controlled resubscribe.
const FOREGROUND_KICK_MIN_INACTIVE_MS = 30_000;
const FOREGROUND_KICK_MIN_INACTIVE_MS = 3_000;
const FOREGROUND_KICK_MIN_INTERVAL_MS = 15_000;
if (
@ -281,15 +281,6 @@ export default function useStreamQueryWithSubscription(
});
}
// Avoid spamming resubscribe triggers.
const becameInactiveAt = lastBecameInactiveAtRef.current;
const inactiveWindowMs = becameInactiveAt ? now - becameInactiveAt : null;
if (
typeof inactiveWindowMs === "number" &&
inactiveWindowMs < livenessStaleMs + 15_000
) {
return;
}
if (now - lastLivenessKickAtRef.current < livenessStaleMs) return;
lastLivenessKickAtRef.current = now;
@ -327,7 +318,7 @@ export default function useStreamQueryWithSubscription(
// Escalation policy for repeated consecutive stale kicks.
if (
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD + 2 &&
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_RELOAD &&
now - lastReloadAtRef.current >= MIN_ESCALATION_INTERVAL_MS
) {
const lastRecovery = wsLastRecoveryDateRef.current
@ -361,7 +352,7 @@ export default function useStreamQueryWithSubscription(
// ignore
}
networkActions.triggerReload("transport");
networkActions.triggerReload();
} else if (
consecutiveStaleKicksRef.current >= STALE_KICKS_BEFORE_WS_RESTART &&
now - lastWsRestartAtRef.current >= MIN_ESCALATION_INTERVAL_MS

View file

@ -15,8 +15,6 @@ import {
Dark as NavigationDarkTheme,
} from "~/theme/navigation";
import DaeSuggestModal from "~/containers/DaeSuggestModal";
// import { navActions } from "~/stores";
// const linking = {
@ -88,9 +86,6 @@ export default function LayoutProviders({ layoutKey, setLayoutKey, children }) {
>
{children}
</NavigationContainer>
{/* Global persistent modal: mounted outside navigation tree, but can navigate via RootNav ref */}
<DaeSuggestModal />
</ComposeComponents>
</>
);

View file

@ -1,63 +0,0 @@
// 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;

View file

@ -7,8 +7,6 @@ import {
} from "@expo/vector-icons";
import { useNavigation, CommonActions } from "@react-navigation/native";
import FontAwesome6 from "@expo/vector-icons/FontAwesome6";
import DrawerContent from "~/navigation/DrawerNav/DrawerContent";
import { useDrawerState } from "~/navigation/Context";
import getDefaultDrawerWidth from "~/navigation/DrawerNav/getDefaultDrawerWidth";
@ -25,8 +23,6 @@ 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";
@ -87,7 +83,6 @@ export default React.memo(function DrawerNav() {
return (
<Drawer.Navigator
backBehavior="history"
drawerContent={(props) => <DrawerContent {...props} />}
drawerStyle={{
width: getDefaultDrawerWidth(dimensions),
@ -371,27 +366,6 @@ export default React.memo(function DrawerNav() {
}}
listeners={{}}
/>
<Drawer.Screen
name="DAEList"
component={DAEList}
options={{
drawerLabel: "Défibrillateurs",
drawerIcon: ({ focused }) => (
<FontAwesome6
name="heart-circle-bolt"
{...iconProps}
{...(focused ? iconFocusedProps : {})}
/>
// <MaterialCommunityIcons
// name="heart-flash"
// {...iconProps}
// {...(focused ? iconFocusedProps : {})}
// />
),
unmountOnBlur: true,
}}
listeners={{}}
/>
<Drawer.Screen
name="Links"
component={Links}
@ -529,14 +503,6 @@ export default React.memo(function DrawerNav() {
}}
component={SendAlertFinder}
/>
<Drawer.Screen
name="DAEItem"
component={DAEItem}
options={{
hidden: true,
unmountOnBlur: true,
}}
/>
{devModeEnabled && (
<Drawer.Screen
name="Developer"

View file

@ -1,7 +1,7 @@
import React from "react";
import { Image } from "react-native";
import { useNavigation, CommonActions } from "@react-navigation/native";
import { useNavigation } from "@react-navigation/native";
import { HeaderBackButton } from "@react-navigation/elements";
import { MaterialCommunityIcons } from "@expo/vector-icons";
@ -28,24 +28,7 @@ export default function HeaderLeft(props) {
if (canGoBack) {
navigation.goBack();
} else {
// HeaderLeft is rendered by the RootStack which has a single
// "Main" screen, so canGoBack is always false here.
// Dispatch GO_BACK targeted at the Drawer navigator so it
// uses its history-based back behaviour.
const rootState = navigation.getState();
const drawerNavState = rootState.routes[0]?.state;
if (
drawerNavState?.key &&
drawerNavState.history?.filter((h) => h.type === "route")
.length > 1
) {
navigation.dispatch({
...CommonActions.goBack(),
target: drawerNavState.key,
});
} else {
navigation.navigate(drawerState.topTabPrev || "SendAlert");
}
navigation.navigate(drawerState.topTabPrev || "SendAlert");
}
}}
backImage={() => (

View file

@ -57,11 +57,6 @@ function getHeaderTitle(route) {
case "SendAlertFinder":
return "Par mot-clé";
case "DAEList":
return "Défibrillateurs";
case "DAEItem":
return "Défibrillateur";
case "ConnectivityError":
return "Non connecté";

View file

@ -20,7 +20,6 @@ import getRetryMaxAttempts from "./getRetryMaxAttemps";
import { createLogger } from "~/lib/logger";
import { NETWORK_SCOPES } from "~/lib/logger/scopes";
import createCache from "./cache";
const { useNetworkState, networkActions } = store;
@ -29,15 +28,11 @@ const networkProvidersLogger = createLogger({
feature: "NetworkProviders",
});
const sharedApolloCache = createCache();
const initializeNewApolloClient = (reload) => {
if (reload) {
const { apolloClient } = network;
apolloClient.stop();
if (apolloClient.cache !== sharedApolloCache) {
apolloClient.clearStore();
}
apolloClient.clearStore();
}
network.apolloClient = createApolloClient({
@ -45,7 +40,6 @@ const initializeNewApolloClient = (reload) => {
GRAPHQL_URL: env.GRAPHQL_URL,
GRAPHQL_WS_URL: env.GRAPHQL_WS_URL,
getRetryMaxAttempts,
cache: sharedApolloCache,
});
};
initializeNewApolloClient();
@ -57,62 +51,34 @@ network.oaFilesKy = oaFilesKy;
export default function NetworkProviders({ children }) {
const [key, setKey] = useState(0);
const [transportClient, setTransportClient] = useState(
() => network.apolloClient,
);
const networkState = useNetworkState([
"initialized",
"triggerReload",
"reloadKind",
"transportGeneration",
]);
const networkState = useNetworkState(["initialized", "triggerReload"]);
useEffect(() => {
if (networkState.triggerReload) {
networkProvidersLogger.debug("Network triggerReload received", {
reloadKind: networkState.reloadKind,
reloadId: store.getAuthState()?.reloadId,
hasUserToken: !!store.getAuthState()?.userToken,
});
const isFullReload = networkState.reloadKind !== "transport";
initializeNewApolloClient(true);
if (isFullReload) {
setTransportClient(network.apolloClient);
setKey((prevKey) => prevKey + 1);
} else {
setTransportClient(network.apolloClient);
networkProvidersLogger.debug("Network transport recovered in place", {
reloadId: store.getAuthState()?.reloadId,
hasUserToken: !!store.getAuthState()?.userToken,
transportGeneration: networkState.transportGeneration,
});
networkActions.onReload();
}
setKey((prevKey) => prevKey + 1);
}
}, [
networkState.triggerReload,
networkState.reloadKind,
networkState.transportGeneration,
]);
}, [networkState.triggerReload]);
useEffect(() => {
if (key > 0) {
networkProvidersLogger.debug("Network reloaded", {
reloadKind: networkState.reloadKind,
reloadId: store.getAuthState()?.reloadId,
hasUserToken: !!store.getAuthState()?.userToken,
});
networkActions.onReload();
}
}, [key, networkState.reloadKind]);
}, [key]);
if (!networkState.initialized) {
return <Loader />;
}
const providers = [[ApolloProvider, { client: transportClient }]];
const providers = [[ApolloProvider, { client: network.apolloClient }]];
return (
<ComposeComponents key={key} components={providers}>

View file

@ -19,7 +19,6 @@ if (__DEV__ || process.env.NODE_ENV !== "production") {
}
export default function createApolloClient(options) {
const cache = options.cache || createCache();
const errorLink = createErrorLink(options);
const authLink = createAuthLink(options);
const cancelLink = createCancelLink();
@ -51,6 +50,8 @@ export default function createApolloClient(options) {
httpLink: httpChain,
});
const cache = createCache();
const apolloClient = new ApolloClient({
cache,
// connectToDevTools: true, // Enable dev tools for better debugging

View file

@ -20,6 +20,8 @@ export default function ControlButtons({
setZoomLevel,
detached,
}) {
// const styles = useStyles();
return (
<>
<View

View file

@ -2,14 +2,6 @@ export default function prioritizeFeatures(features) {
return features
.filter(({ properties }) => !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;

View file

@ -4,8 +4,6 @@ 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,
@ -15,11 +13,6 @@ 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;
@ -102,58 +95,15 @@ 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,
);
// Only show available defibs on the alert navigation map
if (status !== "open") {
return;
}
const id = `defib:${defib.id}`;
features.push({
type: "Feature",
id,
properties: {
id,
defib,
isDefib: true,
},
geometry: {
type: "Point",
coordinates: [lon, lat],
},
});
});
}
return {
type: "FeatureCollection",
features,
};
}, [list, showDefibsOnAlertMap, corridorDefibs]);
}, [list]);
const superCluster = useShallowMemo(() => {
const cluster = new Supercluster({ radius: 40, maxZoom: 16 });
// Do not cluster defibs in v1
const clusterable = featureCollection.features.filter(
(f) => !f?.properties?.isDefib,
);
cluster.load(clusterable);
cluster.load(featureCollection.features);
return cluster;
}, [featureCollection.features]);
// console.log({ superCluster: JSON.stringify(superCluster) });
@ -173,15 +123,6 @@ 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 =
@ -216,8 +157,6 @@ export default function useFeatures({
}, [
setShape,
clusterFeature,
featureCollection.features,
showDefibsOnAlertMap,
userCoords,
hasUserCoords,
routeCoords,

View file

@ -3,7 +3,7 @@ import { useNavigation } from "@react-navigation/native";
import { ANIMATION_DURATION } from "~/containers/Map/constants";
import { alertActions, defibsActions } from "~/stores";
import { alertActions } from "~/stores";
import { createLogger } from "~/lib/logger";
import { FEATURE_SCOPES, UI_SCOPES } from "~/lib/logger/scopes";
@ -29,12 +29,6 @@ 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;

View file

@ -6,18 +6,14 @@ 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,
useDefibsState,
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";
@ -83,107 +79,6 @@ export default withConnectivity(
const isSent = userId === sessionUserId;
const navigation = useNavigation();
const toast = useToast();
const { showDefibsOnAlertMap: defibsEnabled } = useDefibsState([
"showDefibsOnAlertMap",
]);
const [loadingDaeCorridor, setLoadingDaeCorridor] = useState(false);
const toggleDefibsOnAlertMap = useCallback(async () => {
if (defibsEnabled) {
defibsActions.setShowDefibsOnAlertMap(false);
return;
}
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, defibsEnabled, loadingDaeCorridor, navigation, toast]);
const [notifyAroundMutation] = useMutation(NOTIFY_AROUND_MUTATION);
const notifyAround = useCallback(async () => {
@ -503,45 +398,6 @@ export default withConnectivity(
</View>
)}
{isOpen && alert.location?.coordinates && (
<View
key="show-defibs"
style={[styles.actionContainer, styles.actionShowDefibs]}
>
<Button
mode="contained"
disabled={loadingDaeCorridor}
icon={() => (
<MaterialCommunityIcons
name={
loadingDaeCorridor
? "loading"
: defibsEnabled
? "heart-off"
: "heart-pulse"
}
style={[styles.actionIcon, styles.actionShowDefibsIcon]}
/>
)}
style={[
styles.actionButton,
defibsEnabled
? styles.actionShowDefibsButtonActive
: styles.actionShowDefibsButton,
]}
onPress={toggleDefibsOnAlertMap}
>
<Text
style={[styles.actionText, styles.actionShowDefibsText]}
>
{defibsEnabled
? "Ne plus afficher les défibrillateurs"
: "Afficher les défibrillateurs"}
</Text>
</Button>
</View>
)}
{!isSent && alert.location?.coordinates && (
<MapLinksButton coordinates={alert.location.coordinates} />
)}

View file

@ -70,14 +70,6 @@ export default createStyles(({ wp, hp, scaleText, theme: { colors } }) => ({
actionComingHelpButton: {},
actionComingHelpText: {},
actionComingHelpIcon: {},
actionShowDefibsButton: {
backgroundColor: colors.blue,
},
actionShowDefibsButtonActive: {
backgroundColor: colors.grey,
},
actionShowDefibsText: {},
actionShowDefibsIcon: {},
actionSmsButton: {},
actionSmsText: {},
actionSmsIcon: {},

View file

@ -1,459 +0,0 @@
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 Drawer from "react-native-drawer";
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 StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup";
import Text from "~/components/Text";
import IconTouchTarget from "~/components/IconTouchTarget";
import { useTheme } from "~/theme";
import { useDefibsState, useNetworkState } from "~/stores";
import useLocation from "~/hooks/useLocation";
import {
osmProfileUrl,
profileDefaultModes,
} from "~/scenes/AlertCurMap/routing";
import { routeToInstructions } from "~/lib/geo/osrmTextInstructions";
import {
announceForA11yIfScreenReaderEnabled,
setA11yFocusAfterInteractions,
} from "~/lib/a11y";
import markerDae from "~/assets/img/marker-dae.png";
import RoutingSteps from "~/scenes/AlertCurMap/RoutingSteps";
import MapHeadRouting from "~/scenes/AlertCurMap/MapHeadRouting";
import {
STATE_CALCULATING_INIT,
STATE_CALCULATING_LOADED,
STATE_CALCULATING_LOADING,
} from "~/scenes/AlertCurMap/constants";
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 [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL);
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 [routeError, setRouteError] = useState(null);
const [loadingRoute, setLoadingRoute] = useState(false);
const [route, setRoute] = useState(null);
const [calculating, setCalculating] = useState(STATE_CALCULATING_INIT);
const defaultProfile = "foot";
const [profile, setProfile] = useState(defaultProfile);
// 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);
setCalculating(STATE_CALCULATING_LOADING);
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 fetchedRoute = result.routes[0];
const decoded = polyline
.decode(fetchedRoute.geometry)
.map((p) => p.reverse());
setRouteCoords(decoded);
setRoute(fetchedRoute);
setCalculating(STATE_CALCULATING_LOADED);
}
} 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,
]);
// Compute instructions from route steps
const allSteps = useMemo(() => {
if (!route) return [];
return route.legs.flatMap((leg) => leg.steps);
}, [route]);
const instructions = useMemo(() => {
if (allSteps.length === 0) return [];
return routeToInstructions(allSteps);
}, [allSteps]);
const distance = useMemo(
() => allSteps.reduce((acc, step) => acc + (step?.distance || 0), 0),
[allSteps],
);
const duration = useMemo(
() => allSteps.reduce((acc, step) => acc + (step?.duration || 0), 0),
[allSteps],
);
const destinationName = useMemo(() => {
if (!route) return defib?.nom || "";
const { legs } = route;
const lastLeg = legs[legs.length - 1];
if (!lastLeg) return defib?.nom || "";
const { steps } = lastLeg;
const lastStep = steps[steps.length - 1];
return lastStep?.name || defib?.nom || "";
}, [route, defib]);
// Stepper drawer state
const [stepperIsOpened, setStepperIsOpened] = useState(false);
const routingSheetTitleA11yRef = useRef(null);
const a11yStepsEntryRef = useRef(null);
const mapHeadOpenRef = useRef(null);
const mapHeadSeeAllRef = useRef(null);
const lastStepsTriggerRef = useRef(null);
const openStepper = useCallback((triggerRef) => {
if (triggerRef) {
lastStepsTriggerRef.current = triggerRef;
}
setStepperIsOpened(true);
}, []);
const closeStepper = useCallback(() => {
setStepperIsOpened(false);
setA11yFocusAfterInteractions(lastStepsTriggerRef);
}, []);
const stepperOnOpen = useCallback(() => {
if (!stepperIsOpened) {
setStepperIsOpened(true);
}
setA11yFocusAfterInteractions(routingSheetTitleA11yRef);
announceForA11yIfScreenReaderEnabled("Liste des étapes ouverte");
}, [stepperIsOpened]);
const stepperOnClose = useCallback(() => {
if (stepperIsOpened) {
setStepperIsOpened(false);
}
announceForA11yIfScreenReaderEnabled("Liste des étapes fermée");
setA11yFocusAfterInteractions(lastStepsTriggerRef);
}, [stepperIsOpened]);
// Defib marker GeoJSON
const defibGeoJSON = useMemo(() => {
if (!hasDefibCoords) return null;
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Point",
coordinates: [defib.longitude, defib.latitude],
},
properties: {
id: defib.id,
nom: defib.nom || "Défibrillateur",
},
},
],
};
}, [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]);
const profileDefaultMode = profileDefaultModes[profile];
if (!defib) return null;
return (
<View style={styles.container}>
{/* Offline banner */}
{!hasInternetConnection && (
<View
style={[
styles.offlineBanner,
{ backgroundColor: (colors.error || "#F44336") + "15" },
]}
>
<MaterialCommunityIcons
name="wifi-off"
size={18}
color={colors.error || "#F44336"}
/>
<Text
style={[
styles.offlineBannerText,
{ color: colors.error || "#F44336" },
]}
>
Hors ligne l'itinéraire n'est pas disponible
</Text>
</View>
)}
<Drawer
type="overlay"
tweenHandler={(ratio) => ({
main: { opacity: (2 - ratio) / 2 },
})}
tweenDuration={250}
openDrawerOffset={40}
open={stepperIsOpened}
onOpen={stepperOnOpen}
onClose={stepperOnClose}
tapToClose
negotiatePan
content={
<RoutingSteps
setProfile={setProfile}
profile={profile}
closeStepper={closeStepper}
destinationName={destinationName}
distance={distance}
duration={duration}
instructions={instructions}
calculatingState={calculating}
titleA11yRef={routingSheetTitleA11yRef}
/>
}
>
<View style={{ flex: 1 }}>
{/* A11y entry point for routing steps */}
<IconTouchTarget
ref={a11yStepsEntryRef}
accessibilityLabel="Ouvrir la liste des étapes de l'itinéraire"
accessibilityHint="Affiche la destination, la distance, la durée et toutes les étapes sans utiliser la carte."
onPress={() => openStepper(a11yStepsEntryRef)}
style={({ pressed }) => ({
position: "absolute",
top: 4,
left: 4,
zIndex: 10,
backgroundColor: colors.surface,
borderRadius: 8,
opacity: pressed ? 0.7 : 1,
})}
>
<MaterialCommunityIcons
name="format-list-bulleted"
size={24}
color={colors.onSurface}
/>
</IconTouchTarget>
<MapView
mapRef={mapRef}
compassViewPosition={1}
compassViewMargin={{ x: 10, y: 10 }}
>
<Camera
cameraKey={cameraKey}
setCameraKey={setCameraKey}
refreshCamera={refreshCamera}
cameraRef={cameraRef}
followUserLocation={!bounds}
followUserMode={
bounds
? Maplibre.UserTrackingMode.None
: Maplibre.UserTrackingMode.Follow
}
followPitch={0}
zoomLevel={zoomLevel}
bounds={bounds}
detached={false}
/>
{/* Route line */}
{routeGeoJSON && (
<Maplibre.ShapeSource id="routeSource" shape={routeGeoJSON}>
<Maplibre.LineLayer
id="routeLineLayer"
style={{
lineColor: "rgba(49, 76, 205, 0.84)",
lineWidth: 4,
lineCap: "round",
lineJoin: "round",
lineOpacity: 0.84,
}}
/>
</Maplibre.ShapeSource>
)}
<Maplibre.Images images={{ dae: markerDae }} />
{/* Defib marker */}
{defibGeoJSON && (
<Maplibre.ShapeSource id="defibItemSource" shape={defibGeoJSON}>
<Maplibre.SymbolLayer
id="defibItemSymbol"
style={{
iconImage: "dae",
iconSize: 0.5,
iconAllowOverlap: true,
textField: ["get", "nom"],
textSize: 12,
textOffset: [0, 1.8],
textAnchor: "top",
textMaxWidth: 14,
textColor: colors.onSurface,
textHaloColor: colors.surface,
textHaloWidth: 1,
}}
/>
</Maplibre.ShapeSource>
)}
{/* User location */}
{isLastKnown && hasUserCoords ? (
<LastKnownLocationMarker
coordinates={coords}
timestamp={lastKnownTimestamp}
id="lastKnownLocation_daeItem"
/>
) : (
<Maplibre.UserLocation visible showsUserHeadingIndicator />
)}
</MapView>
{/* Head routing step overlay */}
{instructions.length > 0 && (
<MapHeadRouting
instructions={instructions}
distance={distance}
profileDefaultMode={profileDefaultMode}
openStepper={openStepper}
openStepperTriggerRef={mapHeadOpenRef}
seeAllStepsTriggerRef={mapHeadSeeAllRef}
calculatingState={calculating}
/>
)}
</View>
</Drawer>
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
{/* Route error */}
{routeError && !loadingRoute && (
<View style={styles.routeErrorOverlay}>
<Text
style={[
styles.routeErrorText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Impossible de calculer l'itinéraire
</Text>
</View>
)}
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
offlineBanner: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 10,
gap: 8,
},
offlineBannerText: {
fontSize: 13,
flex: 1,
},
routeErrorOverlay: {
position: "absolute",
bottom: 16,
left: 16,
right: 16,
alignItems: "center",
},
routeErrorText: {
fontSize: 13,
textAlign: "center",
},
});

View file

@ -1,533 +0,0 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
View,
ScrollView,
StyleSheet,
TouchableOpacity,
Platform,
} from "react-native";
import { Button, Modal, Portal } from "react-native-paper";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { getApps, showLocation } from "react-native-map-link";
import Text from "~/components/Text";
import { 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 (
<View
style={[
styles.infoRow,
{ borderBottomColor: colors.outlineVariant || colors.grey },
]}
>
<MaterialCommunityIcons
name={icon}
size={20}
color={colors.primary}
style={styles.infoIcon}
/>
<View style={styles.infoContent}>
<Text
style={[
styles.infoLabel,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{label}
</Text>
<Text style={[styles.infoValue, valueStyle]}>{value}</Text>
</View>
</View>
);
}
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(
<Text key="24h" style={styles.scheduleItem}>
Ouvert 24h/24
</Text>,
);
}
if (h.businessHours) {
parts.push(
<Text key="bh" style={styles.scheduleItem}>
Heures ouvrables (Lun-Ven 08h-18h)
</Text>,
);
}
if (h.nightHours) {
parts.push(
<Text key="nh" style={styles.scheduleItem}>
Heures de nuit (20h-08h)
</Text>,
);
}
if (h.events) {
parts.push(
<Text key="ev" style={styles.scheduleItem}>
Selon événements
</Text>,
);
}
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(
<Text key="days" style={styles.scheduleItem}>
Jours : {dayStr}
</Text>,
);
}
if (Array.isArray(h.slots) && h.slots.length > 0) {
const slotsStr = h.slots
.map((s) => `${s.open || "?"} ${s.close || "?"}`)
.join(", ");
parts.push(
<Text key="slots" style={styles.scheduleItem}>
Créneaux : {slotsStr}
</Text>,
);
}
if (h.notes) {
parts.push(
<Text
key="notes"
style={[
styles.scheduleItem,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{h.notes}
</Text>,
);
}
if (parts.length > 0) {
return (
<View style={styles.scheduleContainer}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons
name="clock-outline"
size={20}
color={colors.primary}
style={styles.infoIcon}
/>
<Text style={styles.sectionTitle}>Horaires détaillés</Text>
</View>
<View style={styles.scheduleParts}>{parts}</View>
</View>
);
}
}
// Fallback to raw horaires string
if (defib.horaires) {
return (
<View style={styles.scheduleContainer}>
<View style={styles.sectionHeader}>
<MaterialCommunityIcons
name="clock-outline"
size={20}
color={colors.primary}
style={styles.infoIcon}
/>
<Text style={styles.sectionTitle}>Horaires</Text>
</View>
<Text
style={[
styles.scheduleRaw,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{defib.horaires}
</Text>
</View>
);
}
return null;
}
export default React.memo(function DAEItemInfos() {
const { colors } = useTheme();
const navigation = useNavigation();
const { selectedDefib: defib } = useDefibsState(["selectedDefib"]);
const [navModalVisible, setNavModalVisible] = useState(false);
const [availableApps, setAvailableApps] = useState([]);
useEffect(() => {
(async () => {
const result = await getApps({ alwaysIncludeGoogle: true });
setAvailableApps(result);
})();
}, []);
const { status, label: availabilityLabel } = getDefibAvailability(
defib?.horaires_std,
defib?.disponible_24h,
);
const statusColor = STATUS_COLORS[status];
const openNavModal = useCallback(() => {
setNavModalVisible(true);
}, []);
const closeNavModal = useCallback(() => {
setNavModalVisible(false);
}, []);
const goToCarte = useCallback(() => {
closeNavModal();
navigation.navigate("DAEItemCarte");
}, [navigation, closeNavModal]);
const openExternalApp = useCallback(
(app) => {
closeNavModal();
if (defib?.latitude && defib?.longitude) {
showLocation({
latitude: defib.latitude,
longitude: defib.longitude,
app: app.id,
naverCallerName:
Platform.OS === "ios"
? "com.alertesecours.alertesecours"
: "com.alertesecours",
});
}
},
[defib, closeNavModal],
);
const modalStyles = useMemo(
() => ({
container: {
backgroundColor: colors.surface,
marginHorizontal: 24,
borderRadius: 16,
paddingVertical: 16,
},
title: {
fontSize: 18,
fontWeight: "700",
textAlign: "center",
paddingHorizontal: 16,
paddingBottom: 12,
},
subtitle: {
fontSize: 14,
color: colors.onSurfaceVariant || colors.grey,
textAlign: "center",
paddingHorizontal: 16,
paddingBottom: 12,
},
option: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 14,
paddingHorizontal: 20,
},
optionText: {
fontSize: 16,
marginLeft: 16,
flex: 1,
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.outlineVariant || colors.grey,
marginHorizontal: 16,
},
cancelButton: {
marginTop: 8,
marginHorizontal: 16,
},
}),
[colors],
);
if (!defib) return null;
return (
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
contentContainerStyle={styles.contentContainer}
>
{/* Header with availability */}
<View
style={[
styles.availabilityCard,
{ backgroundColor: statusColor + "12" },
]}
>
<MaterialCommunityIcons
name={STATUS_ICONS[status]}
size={36}
color={statusColor}
/>
<View style={styles.availabilityInfo}>
<Text style={[styles.availabilityStatus, { color: statusColor }]}>
{status === "open"
? "Disponible"
: status === "closed"
? "Indisponible"
: "Disponibilité inconnue"}
</Text>
<Text
style={[
styles.availabilityLabel,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{availabilityLabel}
</Text>
</View>
</View>
{/* Basic info */}
<InfoRow icon="heart-pulse" label="Nom" value={defib.nom} />
<InfoRow icon="map-marker" label="Adresse" value={defib.adresse} />
<InfoRow icon="door-open" label="Accès" value={defib.acces} />
<InfoRow
icon="map-marker-distance"
label="Distance"
value={formatDistance(defib.distanceMeters)}
/>
{/* Schedule section */}
<ScheduleSection defib={defib} />
{/* Itinéraire button */}
<View style={styles.itineraireContainer}>
<Button
mode="contained"
onPress={openNavModal}
icon={({ size, color }) => (
<MaterialCommunityIcons
name="navigation-variant"
size={size}
color={color}
/>
)}
style={styles.itineraireButton}
contentStyle={styles.itineraireButtonContent}
>
Itinéraire
</Button>
</View>
{/* Back to list */}
<View style={styles.backContainer}>
<Button
mode="outlined"
onPress={() => navigation.navigate("DAEList")}
icon="arrow-left"
style={styles.backButton}
>
Retour à la liste
</Button>
</View>
{/* Navigation app chooser modal */}
<Portal>
<Modal
visible={navModalVisible}
onDismiss={closeNavModal}
contentContainerStyle={modalStyles.container}
>
<Text style={modalStyles.title}>Itinéraire</Text>
<Text style={modalStyles.subtitle}>
Quelle application souhaitez-vous utiliser ?
</Text>
{/* In-app navigation option */}
<TouchableOpacity
accessibilityRole="button"
onPress={goToCarte}
style={modalStyles.option}
activeOpacity={0.6}
>
<MaterialCommunityIcons
name="navigation-variant"
size={24}
color={colors.primary}
/>
<Text style={modalStyles.optionText}>
Naviguer dans l'application
</Text>
</TouchableOpacity>
{/* External navigation apps */}
{availableApps.map((app) => (
<React.Fragment key={app.id}>
<View style={modalStyles.separator} />
<TouchableOpacity
accessibilityRole="button"
onPress={() => openExternalApp(app)}
style={modalStyles.option}
activeOpacity={0.6}
>
<MaterialCommunityIcons
name="open-in-new"
size={24}
color={colors.onSurface}
/>
<Text style={modalStyles.optionText}>{app.name}</Text>
</TouchableOpacity>
</React.Fragment>
))}
<Button
mode="text"
onPress={closeNavModal}
style={modalStyles.cancelButton}
>
Annuler
</Button>
</Modal>
</Portal>
</ScrollView>
);
});
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,
},
});

View file

@ -1,130 +0,0 @@
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 (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="heart-off"
size={56}
color={colors.onSurfaceVariant || colors.grey}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Aucun défibrillateur sélectionné</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Sélectionnez un défibrillateur depuis la liste pour voir ses détails.
</Text>
<Button
mode="contained"
onPress={() => navigation.navigate("DAEList")}
style={styles.backButton}
icon="arrow-left"
>
Retour à la liste
</Button>
</View>
);
}
export default React.memo(function DAEItem() {
const { colors } = useTheme();
const { selectedDefib } = useDefibsState(["selectedDefib"]);
if (!selectedDefib) {
return <EmptyState />;
}
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
tabBarLabelStyle: {
fontFamily,
fontSize: 12,
},
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.outlineVariant || colors.grey,
},
}}
>
<Tab.Screen
name="DAEItemInfos"
component={DAEItemInfos}
options={{
tabBarLabel: "Infos",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="information-outline"
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name="DAEItemCarte"
component={DAEItemCarte}
options={{
tabBarLabel: "Carte",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="map-marker-outline"
color={color}
size={size}
/>
),
}}
/>
</Tab.Navigator>
);
});
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,
},
});

View file

@ -1,253 +0,0 @@
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 StepZoomButtonGroup from "~/containers/Map/StepZoomButtonGroup";
import Text from "~/components/Text";
import Loader from "~/components/Loader";
import { useTheme } from "~/theme";
import { defibsActions } from "~/stores";
import markerDae from "~/assets/img/marker-dae.png";
import useNearbyDefibs from "./useNearbyDefibs";
function defibsToGeoJSON(defibs) {
return {
type: "FeatureCollection",
features: defibs.map((d) => {
return {
type: "Feature",
id: d.id,
geometry: {
type: "Point",
coordinates: [d.longitude, d.latitude],
},
properties: {
id: d.id,
nom: d.nom || "Défibrillateur",
},
};
}),
};
}
function LoadingView({ message }) {
const { colors } = useTheme();
return (
<View style={styles.loadingContainer}>
<Loader containerProps={{ style: styles.loaderInner }} />
<Text
style={[
styles.loadingText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{message}
</Text>
</View>
);
}
function EmptyNoLocation() {
const { colors } = useTheme();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="crosshairs-off"
size={56}
color={colors.onSurfaceVariant || colors.grey}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Localisation indisponible</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Activez la géolocalisation pour afficher les défibrillateurs sur la
carte.
</Text>
</View>
);
}
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, setZoomLevel] = 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 <EmptyNoLocation />;
}
// Waiting for location
if (!hasLocation && defibs.length === 0 && !hasCoords) {
return <LoadingView message="Recherche de votre position…" />;
}
// Loading defibs from database
if (loading && defibs.length === 0 && !hasCoords) {
return (
<LoadingView message="Chargement des défibrillateurs à proximité…" />
);
}
return (
<View style={styles.container}>
<MapView
mapRef={mapRef}
compassViewPosition={1}
compassViewMargin={{ x: 10, y: 10 }}
>
<Camera
cameraKey={cameraKey}
setCameraKey={setCameraKey}
refreshCamera={refreshCamera}
cameraRef={cameraRef}
followUserLocation={followUserLocation}
followUserMode={followUserMode}
followPitch={0}
zoomLevel={zoomLevel}
bounds={null}
detached={false}
/>
<Maplibre.Images images={{ dae: markerDae }} />
{geoJSON.features.length > 0 && (
<Maplibre.ShapeSource
id="defibSource"
shape={geoJSON}
onPress={onMarkerPress}
>
<Maplibre.SymbolLayer
id="defibSymbolLayer"
style={{
iconImage: "dae",
iconSize: 0.5,
iconAllowOverlap: true,
textField: ["get", "nom"],
textSize: 11,
textOffset: [0, 1.5],
textAnchor: "top",
textMaxWidth: 12,
textColor: colors.onSurface,
textHaloColor: colors.surface,
textHaloWidth: 1,
textOptional: true,
}}
/>
</Maplibre.ShapeSource>
)}
{isLastKnown && hasCoords ? (
<LastKnownLocationMarker
coordinates={coords}
timestamp={lastKnownTimestamp}
id="lastKnownLocation_daeList"
/>
) : (
<Maplibre.UserLocation visible showsUserHeadingIndicator />
)}
</MapView>
<StepZoomButtonGroup mapRef={mapRef} setZoomLevel={setZoomLevel} />
</View>
);
});
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 32,
},
loaderInner: {
flex: 0,
},
loadingText: {
fontSize: 14,
textAlign: "center",
marginTop: 12,
lineHeight: 20,
},
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,
},
});

View file

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

View file

@ -1,166 +0,0 @@
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 (
<TouchableRipple
onPress={onPress}
style={[
styles.row,
{ borderBottomColor: colors.outlineVariant || colors.grey },
]}
accessibilityRole="button"
accessibilityLabel={`${defib.nom || "Défibrillateur"}, ${formatDistance(
defib.distanceMeters,
)}, ${label}`}
accessibilityHint="Ouvrir le détail de ce défibrillateur"
>
<View style={styles.rowInner}>
<View style={styles.iconContainer}>
<MaterialCommunityIcons
name={STATUS_ICONS[status]}
size={28}
color={statusColor}
/>
</View>
<View style={styles.content}>
<Text style={styles.name} numberOfLines={1}>
{defib.nom || "Défibrillateur"}
</Text>
<Text
style={[
styles.address,
{ color: colors.onSurfaceVariant || colors.grey },
]}
numberOfLines={1}
>
{defib.adresse || "Adresse non renseignée"}
</Text>
<View style={styles.meta}>
<View
style={[
styles.statusBadge,
{ backgroundColor: statusColor + "20" },
]}
>
<Text style={[styles.statusText, { color: statusColor }]}>
{label}
</Text>
</View>
</View>
</View>
<View style={styles.distanceContainer}>
<MaterialCommunityIcons
name="map-marker-distance"
size={16}
color={colors.onSurfaceVariant || colors.grey}
/>
<Text
style={[
styles.distance,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{formatDistance(defib.distanceMeters)}
</Text>
</View>
</View>
</TouchableRipple>
);
}
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",
},
});

View file

@ -1,338 +0,0 @@
import React, { useCallback } from "react";
import { View, FlatList, StyleSheet } from "react-native";
import { Button, Switch } 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 { defibsActions } from "~/stores";
import useNearbyDefibs from "./useNearbyDefibs";
import DefibRow from "./DefibRow";
function LoadingView({ message }) {
const { colors } = useTheme();
return (
<View style={styles.loadingContainer}>
<Loader containerProps={{ style: styles.loaderInner }} />
<Text
style={[
styles.loadingText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
{message}
</Text>
</View>
);
}
function EmptyNoLocation() {
const { colors } = useTheme();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="crosshairs-off"
size={56}
color={colors.onSurfaceVariant || colors.grey}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Localisation indisponible</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Activez la géolocalisation pour trouver les défibrillateurs à proximité.
Vérifiez les paramètres de localisation de votre appareil.
</Text>
</View>
);
}
function EmptyError({ error, onRetry }) {
const { colors } = useTheme();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="alert-circle-outline"
size={56}
color={colors.error || "#F44336"}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Erreur de chargement</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Impossible de charger les défibrillateurs.{"\n"}
Veuillez réessayer ultérieurement.
</Text>
{onRetry && (
<Button mode="contained" onPress={onRetry} style={styles.retryButton}>
Réessayer
</Button>
)}
</View>
);
}
function EmptyNoResults() {
const { colors } = useTheme();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="heart-pulse"
size={56}
color={colors.onSurfaceVariant || colors.grey}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Aucun défibrillateur</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Aucun défibrillateur trouvé dans un rayon de 10 km autour de votre
position.
</Text>
</View>
);
}
const keyExtractor = (item) => item.id;
function EmptyNoAvailable({ showUnavailable }) {
const { colors } = useTheme();
return (
<View style={styles.emptyContainer}>
<MaterialCommunityIcons
name="heart-pulse"
size={56}
color={colors.onSurfaceVariant || colors.grey}
style={styles.emptyIcon}
/>
<Text style={styles.emptyTitle}>Aucun défibrillateur disponible</Text>
<Text
style={[
styles.emptyText,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Aucun défibrillateur actuellement ouvert dans un rayon de 10 km. Activez
l'option « Afficher les indisponibles » pour voir tous les
défibrillateurs.
</Text>
</View>
);
}
function AvailabilityToggle({ showUnavailable, allCount, filteredCount }) {
const { colors } = useTheme();
const onToggle = useCallback(() => {
defibsActions.setShowUnavailable(!showUnavailable);
}, [showUnavailable]);
const countLabel =
!showUnavailable && allCount > filteredCount
? ` (${allCount - filteredCount} masqués)`
: "";
return (
<View
style={[
styles.toggleRow,
{ borderBottomColor: colors.outlineVariant || colors.grey },
]}
>
<View style={styles.toggleLabelContainer}>
<MaterialCommunityIcons
name="eye-off-outline"
size={18}
color={colors.onSurfaceVariant || colors.grey}
/>
<Text
style={[
styles.toggleLabel,
{ color: colors.onSurfaceVariant || colors.grey },
]}
>
Afficher les indisponibles{countLabel}
</Text>
</View>
<Switch value={showUnavailable} onValueChange={onToggle} />
</View>
);
}
export default React.memo(function DAEListListe() {
const { colors } = useTheme();
const {
defibs,
allDefibs,
loading,
error,
noLocation,
hasLocation,
reload,
showUnavailable,
} = useNearbyDefibs();
const renderItem = useCallback(({ item }) => <DefibRow defib={item} />, []);
// No location available
if (noLocation && !hasLocation) {
return <EmptyNoLocation />;
}
// Waiting for location
if (!hasLocation && allDefibs.length === 0) {
return <LoadingView message="Recherche de votre position…" />;
}
// Loading defibs from database
if (loading && allDefibs.length === 0) {
return (
<LoadingView message="Chargement des défibrillateurs à proximité…" />
);
}
// Error state (non-blocking if we have stale data)
if (error && allDefibs.length === 0) {
return <EmptyError error={error} onRetry={reload} />;
}
// No results at all
if (!loading && allDefibs.length === 0 && hasLocation) {
return <EmptyNoResults />;
}
// Has defibs but none available (filtered to empty)
const showEmptyAvailable =
!loading && defibs.length === 0 && allDefibs.length > 0 && !showUnavailable;
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
{error && allDefibs.length > 0 && (
<View
style={[
styles.errorBanner,
{ backgroundColor: (colors.error || "#F44336") + "15" },
]}
>
<MaterialCommunityIcons
name="alert-circle-outline"
size={16}
color={colors.error || "#F44336"}
/>
<Text
style={[
styles.errorBannerText,
{ color: colors.error || "#F44336" },
]}
>
Erreur de mise à jour données potentiellement obsolètes
</Text>
</View>
)}
<AvailabilityToggle
showUnavailable={showUnavailable}
allCount={allDefibs.length}
filteredCount={defibs.length}
/>
{showEmptyAvailable ? (
<EmptyNoAvailable />
) : (
<FlatList
data={defibs}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={styles.list}
initialNumToRender={15}
maxToRenderPerBatch={10}
windowSize={5}
/>
)}
</View>
);
});
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 32,
},
loaderInner: {
flex: 0,
},
loadingText: {
fontSize: 14,
textAlign: "center",
marginTop: 12,
lineHeight: 20,
},
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,
},
toggleRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
},
toggleLabelContainer: {
flexDirection: "row",
alignItems: "center",
gap: 8,
flex: 1,
},
toggleLabel: {
fontSize: 13,
},
});

View file

@ -1,77 +0,0 @@
import React from "react";
import { View, StyleSheet } from "react-native";
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";
import DaeUpdateBanner from "./DaeUpdateBanner";
const Tab = createBottomTabNavigator();
export default React.memo(function DAEList() {
const { colors } = useTheme();
return (
<View style={styles.container}>
<DaeUpdateBanner />
<View style={styles.tabContainer}>
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.onSurfaceVariant || colors.grey,
tabBarLabelStyle: {
fontFamily,
fontSize: 12,
},
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.outlineVariant || colors.grey,
},
}}
>
<Tab.Screen
name="DAEListListe"
component={DAEListListe}
options={{
tabBarLabel: "Liste",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="format-list-bulleted"
color={color}
size={size}
/>
),
}}
/>
<Tab.Screen
name="DAEListCarte"
component={DAEListCarte}
options={{
tabBarLabel: "Carte",
tabBarIcon: ({ color, size }) => (
<MaterialCommunityIcons
name="map-marker-outline"
color={color}
size={size}
/>
),
}}
/>
</Tab.Navigator>
</View>
</View>
);
});
const styles = StyleSheet.create({
container: {
flex: 1,
},
tabContainer: {
flex: 1,
},
});

View file

@ -1,102 +0,0 @@
import { useEffect, useRef, useCallback, useMemo, useState } from "react";
import useLocation from "~/hooks/useLocation";
import { defibsActions, useDefibsState } from "~/stores";
import { getDefibAvailability } from "~/utils/dae/getDefibAvailability";
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.
* By default, only available (open) defibs are returned; toggle showUnavailable to see all.
*/
export default function useNearbyDefibs() {
const { coords, isLastKnown, lastKnownTimestamp } = useLocation();
const {
nearUserDefibs,
loadingNearUser,
errorNearUser,
showUnavailable,
daeUpdateState,
} = useDefibsState([
"nearUserDefibs",
"loadingNearUser",
"errorNearUser",
"showUnavailable",
"daeUpdateState",
]);
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]);
// After a successful DB update, reset the position cache so the next
// render re-queries the fresh database.
const prevUpdateState = useRef(daeUpdateState);
useEffect(() => {
if (prevUpdateState.current !== "done" && daeUpdateState === "done") {
lastLoadedRef.current = null;
}
prevUpdateState.current = daeUpdateState;
}, [daeUpdateState]);
useEffect(() => {
if (hasLocation) {
setNoLocation(false);
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]);
const filteredDefibs = useMemo(() => {
if (showUnavailable) return nearUserDefibs;
return nearUserDefibs.filter((d) => {
const { status } = getDefibAvailability(d.horaires_std, d.disponible_24h);
return status === "open";
});
}, [nearUserDefibs, showUnavailable]);
return {
defibs: filteredDefibs,
allDefibs: nearUserDefibs,
loading: loadingNearUser,
error: errorNearUser,
hasLocation,
noLocation,
isLastKnown,
lastKnownTimestamp,
coords,
reload: loadDefibs,
showUnavailable,
};
}

View file

@ -10,8 +10,7 @@ import {
} from "react-native-gesture-handler";
import { createStyles, useTheme } from "~/theme";
import { useMutation } from "@apollo/client";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { format, fr } from "date-fns";
import { Feather } from "@expo/vector-icons";
import { DELETE_NOTIFICATION, MARK_NOTIFICATION_AS_READ } from "./gql";
import {

View file

@ -6,19 +6,11 @@ import uuidGenerator from "react-native-uuid";
import { phoneCallEmergency } from "~/lib/phone-call";
import network from "~/network";
import {
getSessionState,
alertActions,
defibsActions,
useParamsState,
} from "~/stores";
import { getSessionState, alertActions, useParamsState } from "~/stores";
import { getCurrentLocation } from "~/location";
import useSendAlertSMSToEmergency from "~/hooks/useSendAlertSMSToEmergency";
import alertsList from "~/misc/alertsList";
import subjectSuggestsDefib from "~/utils/dae/subjectSuggestsDefib";
import { SEND_ALERT_MUTATION } from "./gql";
export default function useOnSubmit() {
@ -85,13 +77,6 @@ async function onSubmit(args, context) {
speed,
};
// DAE suggest modal — must run before network call so it works offline.
// Show on red alerts unconditionally, or when cardiac keywords are detected.
const matchingAlert = alertsList.find((a) => a.title === subject);
if (level === "red" || subjectSuggestsDefib(subject, matchingAlert?.desc)) {
defibsActions.setShowDaeSuggestModal(true);
}
const { userId, deviceId } = getSessionState();
const createdAt = new Date().toISOString();
@ -140,7 +125,6 @@ async function onSubmit(args, context) {
});
alertActions.setNavAlertCur({ alert });
navigation.navigate("Main", {
screen: "AlertCur",
params: {

View file

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

View file

@ -1,223 +0,0 @@
import { createAtom } from "~/lib/atomic-zustand";
import getNearbyDefibs from "~/data/getNearbyDefibs";
import {
computeCorridorQueryRadiusMeters,
filterDefibsInCorridor,
} from "~/utils/geo/corridor";
import { updateDaeDb } from "~/db/updateDaeDb";
import memoryAsyncStorage from "~/storage/memoryAsyncStorage";
import { STORAGE_KEYS } from "~/storage/storageKeys";
const DEFAULT_NEAR_USER_RADIUS_M = 10_000;
const DEFAULT_CORRIDOR_M = 10_000;
const DEFAULT_LIMIT = 200;
const AUTO_DISMISS_DELAY = 4_000;
/**
* Convert a technical DAE update error into a user-friendly French message.
* The raw technical details are already logged via console.warn in updateDaeDb.
*/
function userFriendlyDaeError(error) {
const msg = error?.message || "";
if (msg.includes("Network") || msg.includes("network")) {
return "Impossible de contacter le serveur. Vérifiez votre connexion internet et réessayez.";
}
if (msg.includes("HTTP")) {
return "Le serveur a rencontré un problème. Veuillez réessayer ultérieurement.";
}
if (msg.includes("Download failed") || msg.includes("file is empty")) {
return "Le téléchargement a échoué. Veuillez réessayer.";
}
if (msg.includes("failed validation")) {
return "Le fichier téléchargé est corrompu. Veuillez réessayer.";
}
return "La mise à jour a échoué. Veuillez réessayer ultérieurement.";
}
export default createAtom(({ merge, reset }) => {
const actions = {
reset,
setShowDefibsOnAlertMap: (showDefibsOnAlertMap) => {
merge({ showDefibsOnAlertMap });
},
setSelectedDefib: (selectedDefib) => {
merge({ selectedDefib });
},
setShowDaeSuggestModal: (showDaeSuggestModal) => {
merge({ showDaeSuggestModal });
},
setShowUnavailable: (showUnavailable) => {
merge({ showUnavailable });
},
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 };
}
},
// ── DAE DB Over-the-Air Update ─────────────────────────────────────
loadLastDaeUpdate: async () => {
try {
const stored = await memoryAsyncStorage.getItem(
STORAGE_KEYS.DAE_DB_UPDATED_AT,
);
if (stored) {
merge({ daeLastUpdatedAt: stored });
}
} catch {
// Non-fatal
}
},
triggerDaeUpdate: async () => {
merge({
daeUpdateState: "checking",
daeUpdateProgress: 0,
daeUpdateError: null,
});
const result = await updateDaeDb({
onPhase: (phase) => {
merge({ daeUpdateState: phase });
},
onProgress: ({ totalBytesWritten, totalBytesExpectedToWrite }) => {
const progress =
totalBytesExpectedToWrite > 0
? totalBytesWritten / totalBytesExpectedToWrite
: 0;
merge({
daeUpdateState: "downloading",
daeUpdateProgress: progress,
});
},
});
if (result.alreadyUpToDate) {
merge({ daeUpdateState: "up-to-date" });
setTimeout(() => {
merge({ daeUpdateState: "idle" });
}, AUTO_DISMISS_DELAY);
return;
}
if (!result.success) {
merge({
daeUpdateState: "error",
daeUpdateError: userFriendlyDaeError(result.error),
});
return;
}
// Success: update stored timestamp and clear loaded defibs
// so the next query fetches from the fresh DB.
merge({
daeUpdateState: "done",
daeLastUpdatedAt: result.updatedAt,
nearUserDefibs: [],
corridorDefibs: [],
});
setTimeout(() => {
merge({ daeUpdateState: "idle" });
}, AUTO_DISMISS_DELAY);
},
dismissDaeUpdateError: () => {
merge({ daeUpdateState: "idle", daeUpdateError: null });
},
};
return {
default: {
nearUserDefibs: [],
corridorDefibs: [],
showDefibsOnAlertMap: false,
selectedDefib: null,
showDaeSuggestModal: false,
showUnavailable: false,
loadingNearUser: false,
loadingCorridor: false,
errorNearUser: null,
errorCorridor: null,
// DAE DB update state
daeUpdateState: "idle", // "idle"|"checking"|"downloading"|"installing"|"done"|"error"|"up-to-date"
daeUpdateProgress: 0, // 0..1
daeUpdateError: null,
daeLastUpdatedAt: null,
},
actions,
};
});

View file

@ -13,7 +13,6 @@ import params from "./params";
import notifications from "./notifications";
import permissionWizard from "./permissionWizard";
import aggregatedMessages from "./aggregatedMessages";
import defibs from "./defibs";
const store = createStore({
tree,
@ -29,7 +28,6 @@ const store = createStore({
permissionWizard,
notifications,
aggregatedMessages,
defibs,
});
// console.log("store", JSON.stringify(Object.keys(store), null, 2));
@ -102,9 +100,4 @@ export const {
getAggregatedMessagesState,
subscribeAggregatedMessagesState,
aggregatedMessagesActions,
useDefibsState,
getDefibsState,
subscribeDefibsState,
defibsActions,
} = store;

View file

@ -9,28 +9,20 @@ export default createAtom(({ merge, get }) => {
wsLastHeartbeatDate: null,
wsLastRecoveryDate: null,
triggerReload: false,
reloadKind: null,
initialized: true,
hasInternetConnection: true,
transportGeneration: 0,
},
actions: {
triggerReload: (reloadKind = "full") => {
triggerReload: () => {
merge({
initialized: false,
triggerReload: true,
reloadKind,
initialized: reloadKind === "transport" ? true : false,
transportGeneration:
reloadKind === "transport"
? get("transportGeneration") + 1
: get("transportGeneration"),
});
},
onReload: () => {
merge({
initialized: true,
triggerReload: false,
reloadKind: null,
});
},
WSConnected: () => {

View file

@ -1,174 +0,0 @@
/**
* @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" };
}

View file

@ -1,60 +0,0 @@
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" });
});
});

View file

@ -1,77 +0,0 @@
/**
* 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(...texts) {
return texts.some((input) => {
const text = normalizeSubjectText(input);
if (!text) {
return false;
}
return DEFIB_SUGGESTION_REGEXES.some((re) => re.test(text));
});
}
export const __private__ = {
normalizeSubjectText,
DEFIB_SUGGESTION_REGEXES,
};
export default subjectSuggestsDefib;

View file

@ -1,65 +0,0 @@
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);
});
test("matches when keyword is in second argument (description)", () => {
expect(
subjectSuggestsDefib(
"urgence médicale mortelle",
"crise cardiaque, attaque cérébrale, hémorragie importante, blessure grave",
),
).toBe(true);
});
test("matches when keyword is only in subject, not description", () => {
expect(subjectSuggestsDefib("arrêt cardiaque", "some other desc")).toBe(
true,
);
});
test("does not match when neither subject nor description has keywords", () => {
expect(subjectSuggestsDefib("agression", "violence physique")).toBe(false);
});
test("handles null/undefined in multi-arg", () => {
expect(subjectSuggestsDefib(null, "cardiaque")).toBe(true);
expect(subjectSuggestsDefib("cardiaque", undefined)).toBe(true);
expect(subjectSuggestsDefib(null, undefined)).toBe(false);
});
});

View file

@ -1,79 +0,0 @@
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 useralert segment.
* Corridor definition: distance(point, lineSegment(useralert)) <= 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;
}

View file

@ -1,48 +0,0 @@
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"]);
});
});

View file

@ -1,14 +0,0 @@
// Haversine distance in meters between two WGS84 points.
const DEG_TO_RAD = Math.PI / 180;
const EARTH_RADIUS_M = 6_371_000;
export default function haversine(lat1, lon1, lat2, lon2) {
const dLat = (lat2 - lat1) * DEG_TO_RAD;
const dLon = (lon2 - lon1) * DEG_TO_RAD;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * DEG_TO_RAD) *
Math.cos(lat2 * DEG_TO_RAD) *
Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a));
}

View file

@ -5044,16 +5044,6 @@ __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"
@ -7023,7 +7013,6 @@ __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"
@ -7105,7 +7094,6 @@ __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"
@ -7116,7 +7104,6 @@ __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"
@ -7557,13 +7544,6 @@ __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"
@ -10909,19 +10889,6 @@ __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"
@ -11993,13 +11960,6 @@ __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"