diff --git a/__mocks__/expo-av.ts b/__mocks__/expo-av.ts
deleted file mode 100644
index 500b5988..00000000
--- a/__mocks__/expo-av.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// Mock for expo-av
-export const Audio = {
- setAudioModeAsync: jest.fn().mockResolvedValue(undefined),
- Sound: class MockSound {
- static createAsync = jest.fn().mockResolvedValue({
- sound: new this(),
- status: { isLoaded: true },
- });
-
- playAsync = jest.fn().mockResolvedValue({ status: { isPlaying: true } });
- stopAsync = jest.fn().mockResolvedValue({ status: { isPlaying: false } });
- unloadAsync = jest.fn().mockResolvedValue(undefined);
- setVolumeAsync = jest.fn().mockResolvedValue(undefined);
- },
- setIsEnabledAsync: jest.fn().mockResolvedValue(undefined),
- getPermissionsAsync: jest.fn().mockResolvedValue({
- granted: true,
- canAskAgain: true,
- expires: 'never',
- status: 'granted',
- }),
- requestPermissionsAsync: jest.fn().mockResolvedValue({
- granted: true,
- canAskAgain: true,
- expires: 'never',
- status: 'granted',
- }),
-};
-
-export const InterruptionModeIOS = {
- MixWithOthers: 0,
- DoNotMix: 1,
- DuckOthers: 2,
-};
-
-export const AVPlaybackSource = {};
diff --git a/app.config.ts b/app.config.ts
index 62503963..f8eeab17 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -34,7 +34,6 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
orientation: 'default',
icon: './assets/icon.png',
userInterfaceStyle: 'automatic',
- newArchEnabled: true,
updates: {
fallbackToCacheTimeout: 0,
},
@@ -212,7 +211,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
[
'@rnmapbox/maps',
{
- RNMapboxMapsVersion: '11.8.0',
+ // Keep in step with the `mapbox` field of the installed @rnmapbox/maps — the JS
+ // bindings are generated against a specific native SDK, and pinning an older one
+ // makes style props the bindings emit (symbolZOffset and friends) trap natively.
+ RNMapboxMapsVersion: '11.23.1',
},
],
[
@@ -224,6 +226,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'Resgrid Dispatch uses your location, including in the background, to keep the department map updated with your position. For example, while you are working a call away from the console, your location is periodically sent so other dispatchers and responders can see where you are, even when the app is not on screen.',
locationAlwaysPermission:
'Resgrid Dispatch uses your location in the background to keep the department map updated with your position. For example, while you are working a call away from the console, your location is periodically sent so other dispatchers and responders can see where you are, even when the app is not on screen.',
+ // Required even though getMotionActivityAsync() is never called: expo-location links
+ // CoreMotion (MotionActivityPermissionRequester), and App Store static analysis rejects
+ // the binary with ITMS-90683 whenever the framework is referenced and the string is absent.
+ motionUsagePermission:
+ 'Resgrid Dispatch uses motion data to improve the accuracy of the location shown on the department map. For example, while you are driving to an incident, motion data helps distinguish travel from a stop so other dispatchers and responders see an accurate position.',
isIosBackgroundLocationEnabled: true,
isAndroidBackgroundLocationEnabled: true,
isAndroidForegroundServiceEnabled: true,
@@ -257,10 +264,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
android: {
extraProguardRules: '-keep class expo.modules.location.** { *; }',
extraMavenRepos: ['../../node_modules/@notifee/react-native/android/libs'],
- targetSdkVersion: 35,
+ targetSdkVersion: 36,
},
ios: {
deploymentTarget: '18.1',
+ // Apps built with the iOS 27 SDK must adopt the UIKit scene life cycle or they are
+ // killed at launch. Remove once on SDK 58, whose template adopts it by default.
+ enableSceneSupport: true,
},
},
],
@@ -304,7 +314,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'expo-navigation-bar',
{
position: 'relative',
- visibility: 'hidden',
+ hidden: true,
behavior: 'inset-touch',
},
],
@@ -328,9 +338,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
],
'@config-plugins/react-native-callkeep',
'expo-web-browser',
+ 'expo-image',
+ 'expo-sharing',
+ 'expo-status-bar',
'./customGradle.plugin.js',
'./customManifest.plugin.js',
- ['app-icon-badge', appIconBadgeConfig],
+ './plugins/withResourceBundleDeploymentTarget.js',
+ ['./plugins/withIconBadge.js', appIconBadgeConfig],
],
extra: {
...ClientEnv,
diff --git a/electron/main.js b/electron/main.js
index 7cfec462..9a5d1cb1 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -275,6 +275,17 @@ app.whenReady().then(() => {
});
});
+// Settings such as the selected server URL live in the renderer's localStorage, which
+// Chromium writes to disk lazily. Flush it before exiting so a change made just before
+// quitting survives the next launch.
+app.on('before-quit', () => {
+ try {
+ session.defaultSession.flushStorageData();
+ } catch (err) {
+ console.error('Failed to flush storage data before quit:', err);
+ }
+});
+
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
diff --git a/jest-env-setup.js b/jest-env-setup.js
new file mode 100644
index 00000000..2a4c8f5e
--- /dev/null
+++ b/jest-env-setup.js
@@ -0,0 +1,4 @@
+// Must run before jest-expo's preset setup requires `expo/src/winter`.
+// Uses React Native's fetch in tests instead of expo's winter fetch, whose
+// FetchResponse extends a native class that is unavailable after jest teardown.
+process.env.EXPO_PUBLIC_USE_RN_FETCH = '1';
diff --git a/jest-setup.ts b/jest-setup.ts
index 6f7a0323..55c56082 100644
--- a/jest-setup.ts
+++ b/jest-setup.ts
@@ -13,7 +13,8 @@ jest.mock('expo-audio', () => ({
pause: jest.fn(),
remove: jest.fn(),
replace: jest.fn(),
- seekTo: jest.fn(),
+ seekTo: jest.fn().mockResolvedValue(undefined),
+ addListener: jest.fn(() => ({ remove: jest.fn() })),
playing: false,
paused: false,
isLoaded: true,
diff --git a/jest.config.js b/jest.config.js
index 63c1806e..dfe0781b 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,8 +1,14 @@
+const expoPreset = require('jest-expo/jest-preset');
+
+// Must be set before babel-preset-expo transforms modules (it inlines
+// EXPO_PUBLIC_* vars) — makes expo's winter runtime keep RN's fetch in tests.
+process.env.EXPO_PUBLIC_USE_RN_FETCH = '1';
+
module.exports = {
preset: 'jest-expo',
// Reanimated v4 worklets: resolve to the non-native builds in Jest
- // (vendored — worklets 0.5.1 does not ship jest/resolver.js)
- resolver: '/jest.resolver.js',
+ resolver: 'react-native-worklets/jest/resolver.js',
+ setupFiles: ['/jest-env-setup.js', ...(expoPreset.setupFiles ?? [])],
setupFilesAfterEnv: ['/jest-setup.ts'],
testMatch: ['**/?(*.)+(spec|test).ts?(x)'],
testPathIgnorePatterns: ['/node_modules/', '\\.\\._.*'],
@@ -11,7 +17,7 @@ module.exports = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleDirectories: ['node_modules', '/'],
transformIgnorePatterns: [
- 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|@legendapp/motion|@gluestack-ui|nativewind|react-native-css|expo-audio|@aptabase/.*|@shopify/flash-list))',
+ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|standard-navigation|@sentry/react-native|native-base|react-native-svg|@legendapp/motion|@gluestack-ui|nativewind|react-native-css|expo-audio|@aptabase/.*|@dev-plugins/.*|@shopify/flash-list))',
],
coverageReporters: ['json-summary', ['text', { file: 'coverage.txt' }], 'cobertura'],
reporters: [
diff --git a/jest.resolver.js b/jest.resolver.js
deleted file mode 100644
index c889009b..00000000
--- a/jest.resolver.js
+++ /dev/null
@@ -1,15 +0,0 @@
-// Reanimated v4 worklets: resolve to the non-native builds in Jest.
-// Vendored copy of react-native-worklets/jest/resolver.js (added in worklets
-// 0.8.x) — this repo pins react-native-worklets 0.5.1, which does not bundle it.
-
-/** @type {import('jest-resolve').SyncResolver} */
-module.exports = (request, options) => {
- const { defaultResolver } = options;
- if (options.basedir.includes('react-native-worklets') || request.includes('react-native-worklets')) {
- const workletOptions = { ...options };
- workletOptions.extensions = workletOptions.extensions?.filter((ext) => !ext.includes('native'));
- options = workletOptions;
- }
-
- return defaultResolver(request, options);
-};
diff --git a/package.json b/package.json
index a8fd0217..706666b0 100644
--- a/package.json
+++ b/package.json
@@ -87,30 +87,30 @@
}
},
"dependencies": {
- "@config-plugins/react-native-callkeep": "^11.0.0",
- "@config-plugins/react-native-webrtc": "~12.0.0",
- "@dev-plugins/react-query": "~0.2.0",
- "@expo/config-plugins": "~54.0.4",
+ "@config-plugins/react-native-callkeep": "14.0.1",
+ "@config-plugins/react-native-webrtc": "15.0.1",
+ "@dev-plugins/react-query": "0.4.0",
+ "@expo/config-plugins": "~57.0.9",
"@expo/html-elements": "~0.10.1",
- "@expo/metro-runtime": "~6.1.2",
+ "@expo/metro-runtime": "~57.0.16",
"@gluestack-ui/core": "^5.0.15",
"@gluestack-ui/utils": "^5.0.6",
"@gorhom/bottom-sheet": "~5.0.5",
"@hookform/resolvers": "~3.9.0",
"@legendapp/motion": "~2.4.0",
- "@livekit/react-native": "^2.9.1",
- "@livekit/react-native-expo-plugin": "^1.0.1",
- "@livekit/react-native-webrtc": "^137.0.2",
+ "@livekit/react-native": "2.12.0",
+ "@livekit/react-native-expo-plugin": "1.0.2",
+ "@livekit/react-native-webrtc": "144.1.2",
"@microsoft/signalr": "~8.0.7",
"@notifee/react-native": "^9.1.8",
"@novu/react-native": "~2.6.6",
"@react-buoy/core": "^1.5.23",
"@react-buoy/env": "^1.5.23",
"@react-buoy/network": "^1.5.23",
- "@react-native-community/netinfo": "^11.4.1",
- "@rnmapbox/maps": "10.1.42-rc.0",
+ "@react-native-community/netinfo": "12.0.1",
+ "@rnmapbox/maps": "10.3.5",
"@semantic-release/git": "^10.0.1",
- "@sentry/react-native": "~7.2.0",
+ "@sentry/react-native": "~8.20.0",
"@shopify/flash-list": "2.0.2",
"@tanstack/react-query": "~5.52.1",
"app-icon-badge": "^0.1.2",
@@ -120,73 +120,72 @@
"countly-sdk-react-native-bridge": "^25.4.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.1",
- "expo": "~54.0.37",
- "expo-application": "~7.0.8",
- "expo-asset": "~12.0.13",
- "expo-audio": "~1.1.1",
- "expo-auth-session": "~7.0.11",
- "expo-av": "~16.0.8",
- "expo-build-properties": "~1.0.10",
- "expo-clipboard": "~8.0.8",
- "expo-constants": "~18.0.14",
- "expo-crypto": "~15.0.9",
- "expo-dev-client": "~6.0.21",
- "expo-device": "~8.0.10",
- "expo-document-picker": "~14.0.8",
- "expo-file-system": "~19.0.24",
- "expo-font": "~14.0.12",
- "expo-image": "~3.0.11",
- "expo-image-manipulator": "~14.0.8",
- "expo-image-picker": "~17.0.11",
- "expo-keep-awake": "~15.0.8",
- "expo-linking": "~8.0.12",
- "expo-localization": "~17.0.9",
- "expo-location": "~19.0.8",
- "expo-navigation-bar": "~5.0.10",
- "expo-notifications": "~0.32.17",
- "expo-router": "~6.0.24",
- "expo-screen-orientation": "~9.0.9",
- "expo-sharing": "~14.0.8",
- "expo-splash-screen": "~31.0.13",
- "expo-status-bar": "~3.0.9",
- "expo-system-ui": "~6.0.9",
- "expo-task-manager": "~14.0.9",
- "expo-web-browser": "~15.0.11",
+ "expo": "~57.0.25",
+ "expo-application": "~57.0.3",
+ "expo-asset": "~57.0.18",
+ "expo-audio": "~57.0.5",
+ "expo-auth-session": "~57.0.13",
+ "expo-build-properties": "~57.0.22",
+ "expo-clipboard": "~57.0.2",
+ "expo-constants": "~57.0.19",
+ "expo-crypto": "~57.0.3",
+ "expo-dev-client": "~57.0.19",
+ "expo-device": "~57.0.2",
+ "expo-document-picker": "~57.0.2",
+ "expo-file-system": "~57.0.7",
+ "expo-font": "~57.0.4",
+ "expo-image": "~57.0.5",
+ "expo-image-manipulator": "~57.0.20",
+ "expo-image-picker": "~57.0.20",
+ "expo-keep-awake": "~57.0.2",
+ "expo-linking": "~57.0.11",
+ "expo-localization": "~57.0.2",
+ "expo-location": "~57.0.20",
+ "expo-navigation-bar": "~57.0.2",
+ "expo-notifications": "~57.0.21",
+ "expo-router": "~57.0.23",
+ "expo-screen-orientation": "~57.0.2",
+ "expo-sharing": "~57.0.22",
+ "expo-splash-screen": "~57.0.9",
+ "expo-status-bar": "~57.0.1",
+ "expo-system-ui": "~57.0.4",
+ "expo-task-manager": "~57.0.20",
+ "expo-web-browser": "~57.0.3",
"geojson": "~0.5.0",
"he": "^1.2.0",
"i18next": "~23.14.0",
"jwt-decode": "^4.0.0",
- "livekit-client": "^2.15.7",
+ "livekit-client": "^2.20.1",
"lodash": "^4.17.21",
"lodash.memoize": "~4.1.2",
"lucide-react-native": "~0.475.0",
"mapbox-gl": "^3.15.0",
"moti": "~0.29.0",
"nativewind": "5.0.0-preview.4",
- "react": "19.1.0",
- "react-dom": "19.1.0",
+ "react": "19.2.3",
+ "react-dom": "19.2.3",
"react-error-boundary": "~4.0.13",
"react-hook-form": "~7.53.0",
"react-i18next": "~15.0.1",
- "react-native": "0.81.5",
+ "react-native": "0.86.3",
"react-native-base64": "~0.2.1",
"react-native-ble-manager": "^12.1.5",
"react-native-callkeep": "github:Irfanwani/react-native-callkeep#957193d0716f1c2dfdc18e627cbff0f8a0800971",
"react-native-css": "^3.0.7",
"react-native-edge-to-edge": "1.6.0",
"react-native-flash-message": "~0.4.2",
- "react-native-gesture-handler": "~2.28.0",
- "react-native-keyboard-controller": "1.18.5",
+ "react-native-gesture-handler": "~2.32.0",
+ "react-native-keyboard-controller": "1.21.9",
"react-native-logs": "~5.3.0",
- "react-native-mmkv": "~3.3.0",
- "react-native-reanimated": "~4.1.1",
+ "react-native-mmkv": "3.3.3",
+ "react-native-reanimated": "4.5.1",
"react-native-restart": "0.0.27",
- "react-native-safe-area-context": "~5.6.0",
- "react-native-screens": "~4.16.0",
- "react-native-svg": "15.12.1",
+ "react-native-safe-area-context": "~5.7.0",
+ "react-native-screens": "~4.26.0",
+ "react-native-svg": "15.15.4",
"react-native-web": "^0.21.0",
- "react-native-webview": "13.15.0",
- "react-native-worklets": "0.5.1",
+ "react-native-webview": "13.16.1",
+ "react-native-worklets": "0.10.1",
"react-query-kit": "~3.3.0",
"sanitize-html": "2.17.0",
"tailwind-variants": "~0.2.1",
@@ -194,10 +193,11 @@
"zustand": "~4.5.5"
},
"devDependencies": {
- "@babel/core": "~7.26.0",
+ "@babel/core": "^7.29.0",
"@commitlint/cli": "~19.2.2",
"@commitlint/config-conventional": "~19.2.2",
- "@expo/config": "~12.0.12",
+ "@expo/config": "^57.0.0",
+ "@react-native/jest-preset": "~0.86.3",
"@tailwindcss/postcss": "^4.2.0",
"@testing-library/jest-dom": "~6.5.0",
"@testing-library/react-native": "~12.9.0",
@@ -206,7 +206,7 @@
"@types/i18n-js": "~3.8.9",
"@types/jest": "~29.5.14",
"@types/lodash.memoize": "~4.1.9",
- "@types/react": "~19.1.10",
+ "@types/react": "~19.2.14",
"@types/react-native-base64": "~0.2.2",
"@types/sanitize-html": "^2.16.0",
"@typescript-eslint/eslint-plugin": "~5.62.0",
@@ -233,18 +233,17 @@
"eslint-plugin-unused-imports": "~2.0.0",
"jest": "~29.7.0",
"jest-environment-jsdom": "~29.7.0",
- "jest-expo": "~54.0.18",
+ "jest-expo": "~57.0.5",
"jest-junit": "~16.0.0",
"lint-staged": "~15.2.9",
"np": "~10.0.7",
- "patch-package": "^8.0.0",
+ "patch-package": "^8.0.1",
"postinstall-postinstall": "^2.1.0",
"prettier": "~3.3.3",
"react-native-svg-transformer": "~1.5.1",
"tailwindcss": "^4.2.0",
- "ts-jest": "~29.1.2",
"ts-node": "~10.9.2",
- "typescript": "~5.9.2",
+ "typescript": "~6.0.3",
"wait-on": "^9.0.3"
},
"repository": {
@@ -267,12 +266,7 @@
"install": {
"exclude": [
"eslint-config-expo",
- "react-native@~0.76.6",
- "react-native-reanimated@~3.16.1",
- "react-native-gesture-handler@~2.20.0",
- "react-native-screens@~4.4.0",
- "react-native-safe-area-context@~4.12.0",
- "react-native-webview@~13.12.5"
+ "@sentry/react-native"
]
}
},
@@ -281,6 +275,7 @@
},
"resolutions": {
"form-data": "4.0.4",
- "lightningcss": "1.30.1"
+ "lightningcss": "1.30.1",
+ "react-native-safe-area-context": "5.7.0"
}
}
diff --git a/patches/@rnmapbox+maps+10.1.42-rc.0.patch b/patches/@rnmapbox+maps+10.3.5.patch
similarity index 100%
rename from patches/@rnmapbox+maps+10.1.42-rc.0.patch
rename to patches/@rnmapbox+maps+10.3.5.patch
diff --git a/patches/react-native-webview+13.15.0.patch b/patches/react-native-webview+13.16.1.patch
similarity index 100%
rename from patches/react-native-webview+13.15.0.patch
rename to patches/react-native-webview+13.16.1.patch
diff --git a/plugins/__tests__/withIconBadge.test.ts b/plugins/__tests__/withIconBadge.test.ts
new file mode 100644
index 00000000..3d274d83
--- /dev/null
+++ b/plugins/__tests__/withIconBadge.test.ts
@@ -0,0 +1,147 @@
+import type { AppIconBadgeConfig } from 'app-icon-badge/types';
+import { execFileSync } from 'child_process';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+import withIconBadge from '../withIconBadge';
+
+jest.mock('child_process', () => ({ execFileSync: jest.fn() }));
+
+interface RenderJob {
+ icon: string;
+ dstPath: string;
+ isAdaptiveIcon: boolean;
+}
+
+interface TestConfig {
+ _internal: { projectRoot: string };
+ icon: string;
+ ios: { icon: string | Record };
+ android: { adaptiveIcon: { foregroundImage: string } };
+}
+
+const mockExecFileSync = execFileSync as jest.MockedFunction;
+const badges: AppIconBadgeConfig['badges'] = [{ text: 'development', type: 'banner', color: 'white' }];
+const outputDir = path.join('.expo', 'app-icon-badge');
+
+describe('withIconBadge', () => {
+ let projectRoot: string;
+
+ const createConfig = (): TestConfig => ({
+ _internal: { projectRoot },
+ icon: './assets/icon.png',
+ ios: { icon: './assets/ios-icon.png' },
+ android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } },
+ });
+
+ const applyPlugin = (config: TestConfig, options: AppIconBadgeConfig): TestConfig => withIconBadge(config, options);
+
+ const getRenderJobs = (call: number): RenderJob[] => JSON.parse((mockExecFileSync.mock.calls[call][1] as string[])[1]);
+
+ beforeEach(() => {
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'icon-badge-'));
+ fs.mkdirSync(path.join(projectRoot, 'assets'));
+ ['icon.png', 'ios-icon.png', 'adaptive-icon.png'].forEach((file) => fs.writeFileSync(path.join(projectRoot, 'assets', file), file));
+
+ mockExecFileSync.mockReset();
+ mockExecFileSync.mockImplementation((_file, args) => {
+ const jobs: RenderJob[] = JSON.parse((args as string[])[1]);
+ jobs.forEach((job) => fs.writeFileSync(job.dstPath, 'badged'));
+ return Buffer.from('');
+ });
+ });
+
+ afterEach(() => {
+ fs.rmSync(projectRoot, { recursive: true, force: true });
+ });
+
+ it('leaves the config untouched when disabled', () => {
+ const config = applyPlugin(createConfig(), { enabled: false, badges });
+
+ expect(config.icon).toBe('./assets/icon.png');
+ expect(mockExecFileSync).not.toHaveBeenCalled();
+ });
+
+ it('finishes rendering every icon before pointing the config at it', () => {
+ const config = applyPlugin(createConfig(), { badges });
+ const iconPaths = [config.icon, config.ios.icon as string, config.android.adaptiveIcon.foregroundImage];
+
+ expect(mockExecFileSync).toHaveBeenCalledTimes(1);
+ expect(getRenderJobs(0).map((job) => job.isAdaptiveIcon)).toEqual([false, false, true]);
+ expect(new Set(iconPaths).size).toBe(3);
+ iconPaths.forEach((iconPath) => {
+ expect(iconPath.startsWith(outputDir)).toBe(true);
+ expect(fs.readFileSync(path.join(projectRoot, iconPath), 'utf8')).toBe('badged');
+ });
+ expect(fs.readdirSync(path.join(projectRoot, outputDir)).filter((file) => file.includes('.partial.'))).toEqual([]);
+ });
+
+ it('reuses finished icons on repeated config evaluations', () => {
+ const first = applyPlugin(createConfig(), { badges });
+ const second = applyPlugin(createConfig(), { badges });
+
+ expect(mockExecFileSync).toHaveBeenCalledTimes(1);
+ expect(second.icon).toBe(first.icon);
+ expect(second.android.adaptiveIcon.foregroundImage).toBe(first.android.adaptiveIcon.foregroundImage);
+ });
+
+ it('does not badge its own output again when applied twice to the same config', () => {
+ const config = applyPlugin(createConfig(), { badges });
+ const badgedPaths = [config.icon, config.ios.icon as string, config.android.adaptiveIcon.foregroundImage];
+
+ applyPlugin(config, { badges });
+
+ expect(mockExecFileSync).toHaveBeenCalledTimes(1);
+ expect([config.icon, config.ios.icon, config.android.adaptiveIcon.foregroundImage]).toEqual(badgedPaths);
+ badgedPaths.forEach((iconPath) => expect(fs.existsSync(path.join(projectRoot, iconPath))).toBe(true));
+ });
+
+ it('renders an output deleted since the last application from the original icon, not its own output', () => {
+ const config = applyPlugin(createConfig(), { badges });
+ fs.rmSync(path.join(projectRoot, outputDir), { recursive: true, force: true });
+
+ applyPlugin(config, { badges });
+
+ expect(mockExecFileSync).toHaveBeenCalledTimes(2);
+ expect(getRenderJobs(1).map((job) => path.relative(projectRoot, job.icon))).toEqual([path.join('assets', 'icon.png'), path.join('assets', 'ios-icon.png'), path.join('assets', 'adaptive-icon.png')]);
+ [config.icon, config.ios.icon as string, config.android.adaptiveIcon.foregroundImage].forEach((iconPath) => expect(fs.existsSync(path.join(projectRoot, iconPath))).toBe(true));
+ });
+
+ it('badges the original icon when the same config is applied again with a different badge', () => {
+ const config = applyPlugin(createConfig(), { badges });
+ const firstIcon = config.icon;
+
+ applyPlugin(config, { badges: [{ ...badges[0], text: 'staging' }] });
+
+ expect(mockExecFileSync).toHaveBeenCalledTimes(2);
+ expect(path.relative(projectRoot, getRenderJobs(1)[0].icon)).toBe(path.join('assets', 'icon.png'));
+ expect(config.icon).not.toBe(firstIcon);
+ });
+
+ it('re-renders and removes the stale icons when a badge changes', () => {
+ const first = applyPlugin(createConfig(), { badges });
+ const second = applyPlugin(createConfig(), { badges: [{ ...badges[0], text: 'staging' }] });
+
+ expect(mockExecFileSync).toHaveBeenCalledTimes(2);
+ expect(second.icon).not.toBe(first.icon);
+ expect(fs.existsSync(path.join(projectRoot, first.icon))).toBe(false);
+ expect(fs.existsSync(path.join(projectRoot, second.icon))).toBe(true);
+ });
+
+ it('throws rather than pointing at an unfinished icon when rendering fails', () => {
+ mockExecFileSync.mockImplementation(() => {
+ throw new Error('render failed');
+ });
+
+ expect(() => applyPlugin(createConfig(), { badges })).toThrow('render failed');
+ });
+
+ it('leaves non-path icon values alone', () => {
+ const iosIcon = { light: './assets/ios-icon.png' };
+ const config = applyPlugin({ ...createConfig(), ios: { icon: iosIcon } }, { badges });
+
+ expect(config.ios.icon).toBe(iosIcon);
+ expect(getRenderJobs(0)).toHaveLength(2);
+ });
+});
diff --git a/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts b/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts
new file mode 100644
index 00000000..294b86be
--- /dev/null
+++ b/plugins/__tests__/withResourceBundleDeploymentTarget.test.ts
@@ -0,0 +1,28 @@
+import { addResourceBundleDeploymentTarget } from '../withResourceBundleDeploymentTarget';
+
+const podfile = `target 'ResgridDispatch' do
+ post_install do |installer|
+ react_native_post_install(installer)
+ end
+end
+`;
+
+describe('addResourceBundleDeploymentTarget', () => {
+ it('raises resource bundles inside the post_install hook', () => {
+ const lines = addResourceBundleDeploymentTarget(podfile).split('\n');
+ const hookLine = lines.findIndex((line) => line.includes('post_install do |installer|'));
+
+ expect(lines[hookLine + 1]).toContain('@generated begin resgrid-resource-bundle-deployment-target');
+ expect(lines.join('\n')).toContain('result.resource_bundle_targets.each');
+ expect(lines.join('\n')).toContain("build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported");
+ expect(lines.findIndex((line) => line.includes('react_native_post_install'))).toBeGreaterThan(hookLine);
+ });
+
+ it('only adds the block once across repeated prebuilds', () => {
+ const once = addResourceBundleDeploymentTarget(podfile);
+ const twice = addResourceBundleDeploymentTarget(once);
+
+ expect(twice).toBe(once);
+ expect(twice.match(/resource_bundle_targets/g)).toHaveLength(1);
+ });
+});
diff --git a/plugins/withIconBadge.js b/plugins/withIconBadge.js
new file mode 100644
index 00000000..46b9200c
--- /dev/null
+++ b/plugins/withIconBadge.js
@@ -0,0 +1,139 @@
+/* eslint-env node */
+const { execFileSync } = require('child_process');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+
+/**
+ * Stamps the environment/version badges onto the app icons for non-production builds.
+ *
+ * Replaces the `app-icon-badge` config plugin, which starts its icon writes without ever awaiting
+ * them. Expo registers its own icon mods after project plugins, so they run first and read the
+ * badged PNGs while a write — or the truncation from one of prebuild's repeated config evaluations —
+ * is still in flight, and jimp fails with "Could not find MIME for Buffer ".
+ *
+ * Config plugins are synchronous, so the badges are rendered in a child process that is waited on.
+ * Output names carry a hash of their inputs, so repeat evaluations reuse the finished files, and each
+ * file is renamed into place only once the render has fully completed.
+ */
+
+const OUTPUT_DIR = path.join('.expo', 'app-icon-badge');
+const RENDERER_VERSION = require('app-icon-badge/package.json').version;
+
+function getIconTargets(config) {
+ return [
+ {
+ name: 'icon',
+ source: config.icon,
+ isAdaptiveIcon: false,
+ apply: (iconPath) => {
+ config.icon = iconPath;
+ },
+ },
+ {
+ name: 'ios-icon',
+ source: config.ios?.icon,
+ isAdaptiveIcon: false,
+ apply: (iconPath) => {
+ config.ios.icon = iconPath;
+ },
+ },
+ {
+ name: 'adaptive-icon',
+ source: config.android?.adaptiveIcon?.foregroundImage,
+ isAdaptiveIcon: true,
+ apply: (iconPath) => {
+ config.android.adaptiveIcon.foregroundImage = iconPath;
+ },
+ },
+ ].filter((target) => typeof target.source === 'string');
+}
+
+// Per config object, the source each icon was rendered from and the output the config was pointed at.
+// Applied to the same config again, the plugin renders from those sources rather than badging its own
+// output, so a changed badge or a deleted output is rendered afresh.
+const appliedIcons = new WeakMap();
+
+function resolveSource(config, target) {
+ const applied = appliedIcons.get(config)?.[target.name];
+ return applied && applied.output === target.source ? applied.source : target.source;
+}
+
+// A copy of a config this plugin already rewrote carries no record of its sources. Its icons are this
+// plugin's output; badging them again would stack a second badge and delete the first render.
+function isBadgeOutput(projectRoot, source) {
+ const relative = path.relative(path.resolve(projectRoot, OUTPUT_DIR), path.resolve(projectRoot, source));
+ return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative);
+}
+
+function getOutputPath(projectRoot, target, badges) {
+ const hash = crypto
+ .createHash('sha256')
+ .update(fs.readFileSync(path.resolve(projectRoot, target.source)))
+ .update(JSON.stringify({ badges, isAdaptiveIcon: target.isAdaptiveIcon, renderer: RENDERER_VERSION }))
+ .digest('hex')
+ .slice(0, 12);
+
+ return path.join(OUTPUT_DIR, `${target.name}-${hash}.png`);
+}
+
+function renderBadges(projectRoot, targets, badges) {
+ const outputDir = path.resolve(projectRoot, OUTPUT_DIR);
+ fs.mkdirSync(outputDir, { recursive: true });
+
+ const jobs = targets.map((target) => {
+ const finalPath = path.resolve(projectRoot, target.output);
+ return {
+ icon: path.resolve(projectRoot, target.source),
+ dstPath: finalPath.replace(/\.png$/, '.partial.png'),
+ finalPath,
+ badges,
+ isAdaptiveIcon: target.isAdaptiveIcon,
+ };
+ });
+
+ // addBadge() does not await its own write, so the files are only complete once the child exits.
+ execFileSync(process.execPath, [__filename, JSON.stringify(jobs)], { stdio: ['ignore', 'ignore', 'inherit'] });
+
+ jobs.forEach((job) => fs.renameSync(job.dstPath, job.finalPath));
+
+ targets.forEach((target) => {
+ const current = path.basename(target.output);
+ fs.readdirSync(outputDir)
+ .filter((file) => file.startsWith(`${target.name}-`) && file !== current)
+ .forEach((file) => fs.rmSync(path.join(outputDir, file), { force: true }));
+ });
+}
+
+const withIconBadge = (config, { enabled = true, badges = [] } = {}) => {
+ if (!enabled || badges.length === 0) {
+ return config;
+ }
+
+ const projectRoot = config._internal?.projectRoot ?? process.cwd();
+ const targets = getIconTargets(config)
+ .map((target) => ({ ...target, source: resolveSource(config, target) }))
+ .filter((target) => !isBadgeOutput(projectRoot, target.source))
+ .map((target) => ({ ...target, output: getOutputPath(projectRoot, target, badges) }));
+ const pending = targets.filter((target) => !fs.existsSync(path.resolve(projectRoot, target.output)));
+
+ if (pending.length > 0) {
+ renderBadges(projectRoot, pending, badges);
+ }
+
+ targets.forEach((target) => target.apply(target.output));
+ appliedIcons.set(config, { ...appliedIcons.get(config), ...Object.fromEntries(targets.map((target) => [target.name, { source: target.source, output: target.output }])) });
+ return config;
+};
+
+module.exports = withIconBadge;
+
+if (require.main === module) {
+ const { addBadge } = require('app-icon-badge');
+ const jobs = JSON.parse(process.argv[2]);
+
+ Promise.all(jobs.map(({ icon, dstPath, badges, isAdaptiveIcon }) => addBadge({ icon, dstPath, badges, isAdaptiveIcon }))).catch((error) => {
+ console.error(error);
+ process.exit(1);
+ });
+}
diff --git a/plugins/withResourceBundleDeploymentTarget.js b/plugins/withResourceBundleDeploymentTarget.js
new file mode 100644
index 00000000..98f4f967
--- /dev/null
+++ b/plugins/withResourceBundleDeploymentTarget.js
@@ -0,0 +1,43 @@
+/* eslint-env node */
+const { withPodfile } = require('@expo/config-plugins');
+const { mergeContents } = require('@expo/config-plugins/build/utils/generateCode');
+
+/**
+ * Raises the iOS deployment target of CocoaPods resource-bundle targets to React Native's floor.
+ *
+ * `react_native_post_install` lifts every pod's native target to `min_ios_version_supported`, but
+ * skips the resource bundles that pods such as MapboxMaps, RNSVG and react-native-permissions
+ * declare. Those keep their podspec minimum (12.4 / 14.0), which Xcode 27 rejects outright — its
+ * supported range starts at iOS 15.0 — failing the build with "IPHONEOS_DEPLOYMENT_TARGET is set to 12.4".
+ */
+
+const POST_INSTALL_BLOCK = ` installer.target_installation_results.pod_target_installation_results.each_value do |result|
+ result.resource_bundle_targets.each do |bundle_target|
+ bundle_target.build_configurations.each do |build_config|
+ deployment_target = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET']
+ next if deployment_target.nil? || deployment_target.to_f >= min_ios_version_supported.to_f
+
+ build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = min_ios_version_supported
+ end
+ end
+ end`;
+
+function addResourceBundleDeploymentTarget(podfile) {
+ return mergeContents({
+ tag: 'resgrid-resource-bundle-deployment-target',
+ src: podfile,
+ newSrc: POST_INSTALL_BLOCK,
+ anchor: /^\s*post_install do \|installer\|/,
+ offset: 1,
+ comment: '#',
+ }).contents;
+}
+
+const withResourceBundleDeploymentTarget = (config) =>
+ withPodfile(config, (podfileConfig) => {
+ podfileConfig.modResults.contents = addResourceBundleDeploymentTarget(podfileConfig.modResults.contents);
+ return podfileConfig;
+ });
+
+module.exports = withResourceBundleDeploymentTarget;
+module.exports.addResourceBundleDeploymentTarget = addResourceBundleDeploymentTarget;
diff --git a/src/__tests__/app/call/[id].security.test.tsx b/src/__tests__/app/call/[id].security.test.tsx
index 2bbd0be5..231dea3e 100644
--- a/src/__tests__/app/call/[id].security.test.tsx
+++ b/src/__tests__/app/call/[id].security.test.tsx
@@ -41,6 +41,10 @@ jest.mock('expo-router', () => ({
Stack: {
Screen: ({ children, ...props }: any) => {children}
,
},
+ useFocusEffect: jest.fn((callback: () => void) => {
+ const React = require('react');
+ React.useEffect(callback, []);
+ }),
}));
// Mock expo-constants
@@ -172,13 +176,6 @@ jest.mock('nativewind', () => ({
}));
// Mock navigation hook
-jest.mock('@react-navigation/native', () => ({
- useFocusEffect: jest.fn((callback: () => void) => {
- const React = require('react');
- React.useEffect(callback, []);
- }),
-}));
-
// Mock components
jest.mock('@/components/common/loading', () => ({
Loading: () => Loading...
,
diff --git a/src/__tests__/app/call/[id].test.tsx b/src/__tests__/app/call/[id].test.tsx
index 871e5528..8656467e 100644
--- a/src/__tests__/app/call/[id].test.tsx
+++ b/src/__tests__/app/call/[id].test.tsx
@@ -175,6 +175,9 @@ jest.mock('expo-router', () => ({
back: jest.fn(),
push: jest.fn(),
})),
+ useIsFocused: jest.fn(() => true),
+ useNavigation: jest.fn(),
+ useFocusEffect: jest.fn(),
}));
// Mock Lucide React Native icons
@@ -404,13 +407,6 @@ jest.mock('expo-navigation-bar', () => ({
getBehaviorAsync: jest.fn(),
}));
-// Mock @react-navigation/native
-jest.mock('@react-navigation/native', () => ({
- useIsFocused: jest.fn(() => true),
- useNavigation: jest.fn(),
- useFocusEffect: jest.fn(),
-}));
-
// Mock react-native-edge-to-edge
jest.mock('react-native-edge-to-edge', () => ({
SystemBars: {
diff --git a/src/__tests__/app/calls.test.tsx b/src/__tests__/app/calls.test.tsx
index d7420e88..c9e57510 100644
--- a/src/__tests__/app/calls.test.tsx
+++ b/src/__tests__/app/calls.test.tsx
@@ -25,6 +25,11 @@ jest.mock('expo-router', () => ({
router: {
push: jest.fn(),
},
+ useFocusEffect: jest.fn((callback: () => void) => {
+ const React = require('react');
+ React.useEffect(callback, []);
+ }),
+ useIsFocused: jest.fn(() => true),
}));
// Mock storage
@@ -205,14 +210,6 @@ jest.mock('@/components/ui/focus-aware-status-bar', () => ({
}));
// Mock useFocusEffect and useIsFocused
-jest.mock('@react-navigation/native', () => ({
- useFocusEffect: jest.fn((callback: () => void) => {
- const React = require('react');
- React.useEffect(callback, []);
- }),
- useIsFocused: jest.fn(() => true),
-}));
-
import CallsScreen from '../../app/(app)/calls';
describe('CallsScreen', () => {
diff --git a/src/__tests__/app/index.test.tsx b/src/__tests__/app/index.test.tsx
index f762abb5..4f2793ba 100644
--- a/src/__tests__/app/index.test.tsx
+++ b/src/__tests__/app/index.test.tsx
@@ -14,7 +14,10 @@ jest.mock('react-native-safe-area-context', () => ({
jest.mock('@/hooks/use-app-lifecycle');
jest.mock('@/stores/app/location-store');
jest.mock('@/hooks/use-map-signalr-updates', () => ({
- useMapSignalRUpdates: jest.fn(),
+ useMapSignalRUpdates: jest.fn(() => ({ requestRefresh: jest.fn() })),
+}));
+jest.mock('@/hooks/use-map-live-locations', () => ({
+ useMapLiveLocations: jest.fn(() => ({ applyToFetchedPins: (pins: unknown[]) => pins })),
}));
jest.mock('@/hooks/use-map-layers', () => ({
useMapLayers: jest.fn(() => ({
@@ -30,15 +33,6 @@ jest.mock('@/hooks/use-map-layers', () => ({
})),
MapLayerType: { ALL: 'ALL' },
}));
-jest.mock('@react-navigation/native', () => ({
- useIsFocused: jest.fn(() => true),
- useNavigation: jest.fn(() => ({
- navigate: jest.fn(),
- push: jest.fn(),
- replace: jest.fn(),
- goBack: jest.fn(),
- })),
-}));
jest.mock('@/api/mapping/mapping', () => ({
getMapDataAndMarkers: jest.fn().mockResolvedValue({
Data: { MapMakerInfos: [] },
@@ -71,6 +65,13 @@ jest.mock('expo-router', () => ({
useFocusEffect: jest.fn(() => {
// Don't call the callback to prevent infinite loops in tests
}),
+ useIsFocused: jest.fn(() => true),
+ useNavigation: jest.fn(() => ({
+ navigate: jest.fn(),
+ push: jest.fn(),
+ replace: jest.fn(),
+ goBack: jest.fn(),
+ })),
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
diff --git a/src/__tests__/app/map.web.test.tsx b/src/__tests__/app/map.web.test.tsx
new file mode 100644
index 00000000..fa90d6e4
--- /dev/null
+++ b/src/__tests__/app/map.web.test.tsx
@@ -0,0 +1,193 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+import { getMapDataAndMarkers } from '@/api/mapping/mapping';
+import { LIVE_LOCATION_REFRESH_DELAY_MS } from '@/hooks/use-map-live-locations';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+import { useSignalRStore } from '@/stores/signalr/signalr-store';
+
+import MapWeb from '../../app/(app)/map.web';
+
+// Minimal mapbox-gl stand-in that records what the screen does to the map and its markers.
+interface MockMarker {
+ pinId: string;
+ lngLat: [number, number];
+ setLngLat: jest.Mock;
+ remove: jest.Mock;
+}
+interface MockMap {
+ handlers: Record void>;
+ flyTo: jest.Mock;
+}
+const mockMarkers: MockMarker[] = [];
+const mockMaps: MockMap[] = [];
+
+jest.mock('mapbox-gl', () => {
+ class Popup {
+ setHTML = jest.fn(() => this);
+ }
+ class Marker {
+ pinId: string;
+ lngLat: [number, number] = [0, 0];
+ popup: Popup | undefined;
+ setLngLat = jest.fn((lngLat: [number, number]) => {
+ this.lngLat = lngLat;
+ return this;
+ });
+ remove = jest.fn();
+ constructor(options: { element: { pinId: string } }) {
+ this.pinId = options.element.pinId;
+ mockMarkers.push(this as unknown as MockMarker);
+ }
+ setPopup(popup: Popup) {
+ this.popup = popup;
+ return this;
+ }
+ getPopup() {
+ return this.popup;
+ }
+ addTo() {
+ return this;
+ }
+ }
+ class MapboxMap {
+ handlers: Record void> = {};
+ flyTo = jest.fn();
+ constructor() {
+ mockMaps.push(this as unknown as MockMap);
+ }
+ addControl() {}
+ on(event: string, handler: () => void) {
+ this.handlers[event] = handler;
+ }
+ once() {}
+ off() {}
+ remove() {}
+ setStyle() {}
+ getLayer() {
+ return undefined;
+ }
+ getSource() {
+ return undefined;
+ }
+ addSource() {}
+ addLayer() {}
+ }
+ class Control {}
+ return { __esModule: true, default: { Map: MapboxMap, Marker, Popup, NavigationControl: Control, GeolocateControl: Control, accessToken: '' } };
+});
+
+jest.mock('@/stores/signalr/signalr-store', () => {
+ const { create } = jest.requireActual('zustand');
+ return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) };
+});
+
+jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() }));
+jest.mock('@/lib/map-markers-web', () => ({ buildMapPinPopupHtml: jest.fn(() => '
'), createMapMarkerElement: jest.fn((pin: { Id: string }) => ({ pinId: pin.Id })) }));
+jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) }));
+jest.mock('@/lib/env', () => ({ Env: { MAPBOX_PUBKEY: 'pk.test' } }));
+jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() } }));
+jest.mock('@/stores/app/location-store', () => ({ useLocationStore: (selector: (state: object) => unknown) => selector({ latitude: null, longitude: null }) }));
+jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }) }));
+jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+jest.mock('expo-router', () => ({ Stack: { Screen: () => null }, useFocusEffect: jest.fn() }));
+jest.mock('@/components/ui/focus-aware-status-bar', () => ({ FocusAwareStatusBar: () => null }));
+jest.mock('@/hooks/use-active-map-layers', () => ({ useActiveMapLayers: () => ({ activeLayers: [] }) }));
+jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) }));
+jest.mock('@/hooks/use-map-layers', () => {
+ const layerState = {
+ layers: [],
+ visibleLayers: new Set(),
+ isLoading: false,
+ fetchLayers: () => {},
+ toggleLayer: () => {},
+ showAllLayers: () => {},
+ hideAllLayers: () => {},
+ getVisibleLayerData: () => [],
+ };
+ return { useMapLayers: () => layerState, MapLayerType: { ALL: 'ALL' } };
+});
+
+const mockGetMapDataAndMarkers = getMapDataAndMarkers as jest.MockedFunction;
+
+const makePin = (id: string, type: number, latitude: number, longitude: number, poiTypeId: number | null = null) =>
+ ({ Id: id, Type: type, Latitude: latitude, Longitude: longitude, Title: id, PoiTypeId: poiTypeId }) as MapMakerInfoData;
+
+const HYDRANTS = { PoiTypeId: 7, Name: 'Hydrants', Color: '#f00', ImagePath: '', PoiImage: '', Marker: '', IsDestination: false };
+
+const mapResponse = (pins: MapMakerInfoData[]) => ({ Data: { MapMakerInfos: pins, CenterLat: '39.5', CenterLon: '-119.8', ZoomLevel: '11', PoiLayers: [HYDRANTS] } }) as any;
+
+const REST_PINS = [makePin('c1', 0, 39.1, -119.1), makePin('u12', 1, 39.2, -119.2), makePin('pABC', 3, 39.3, -119.3), makePin('poi5', 4, 39.4, -119.4, 7)];
+
+const liveMarker = (pinId: string) => mockMarkers.filter((marker) => marker.pinId === pinId && marker.remove.mock.calls.length === 0);
+
+describe('Map screen (web) realtime locations', () => {
+ const originalDocument = (global as any).document;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ mockMarkers.length = 0;
+ mockMaps.length = 0;
+ mockGetMapDataAndMarkers.mockReset();
+ mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS));
+ useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 });
+ // Only the stylesheet injection touches document; report it as already present.
+ (global as any).document = { getElementById: () => ({}) };
+ });
+
+ afterEach(() => {
+ (global as any).document = originalDocument;
+ jest.useRealTimers();
+ });
+
+ const renderLoadedMap = async () => {
+ render( , { createNodeMock: () => ({}) });
+ act(() => mockMaps[0].handlers.load());
+ await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length));
+ };
+
+ const pushUnknownPin = () => {
+ act(() => {
+ useSignalRStore.setState({ liveLocations: { u99: { pinId: 'u99', latitude: 38, longitude: -118, timestamp: null, receivedAt: Date.now() } } });
+ });
+ };
+
+ it('moves a pushed pin in place and never re-centres the camera', async () => {
+ await renderLoadedMap();
+ expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ useSignalRStore.setState({ liveLocations: { pabc: { pinId: 'pabc', latitude: 40, longitude: -120, timestamp: null, receivedAt: Date.now() } } });
+ });
+
+ const [personMarker] = liveMarker('pABC');
+ expect(personMarker.setLngLat).toHaveBeenLastCalledWith([-120, 40]);
+ expect(mockMarkers).toHaveLength(REST_PINS.length);
+ mockMarkers.forEach((marker) => expect(marker.remove).not.toHaveBeenCalled());
+
+ pushUnknownPin();
+ await act(async () => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS);
+ });
+ await waitFor(() => expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2));
+ expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1);
+ });
+
+ it("keeps the user's POI layer toggles across a background refetch", async () => {
+ await renderLoadedMap();
+
+ fireEvent.press(screen.getByTestId('layers-button'));
+ fireEvent.press(screen.getByText('Hydrants'));
+ expect(liveMarker('poi5')).toHaveLength(0);
+
+ pushUnknownPin();
+ await act(async () => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS);
+ });
+ await waitFor(() => expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2));
+ await act(async () => {});
+
+ expect(liveMarker('poi5')).toHaveLength(0);
+ expect(liveMarker('u12')).toHaveLength(1);
+ });
+});
diff --git a/src/__tests__/app/root/lockscreen.test.tsx b/src/__tests__/app/root/lockscreen.test.tsx
index 10859393..9019bd4e 100644
--- a/src/__tests__/app/root/lockscreen.test.tsx
+++ b/src/__tests__/app/root/lockscreen.test.tsx
@@ -1,6 +1,5 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
-import { NavigationContainer } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import Lockscreen from '../../../app/lockscreen';
@@ -11,6 +10,7 @@ import useLockscreenStore from '@/stores/lockscreen/store';
// Mock dependencies
jest.mock('expo-router', () => ({
useRouter: jest.fn(),
+ useIsFocused: jest.fn(() => true),
}));
jest.mock('react-i18next', () => ({
@@ -38,7 +38,7 @@ jest.mock('@/lib/auth/api', () => ({
jest.mock('@/stores/lockscreen/store');
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- return {children} ;
+ return <>{children}>;
};
describe('Lockscreen', () => {
@@ -122,7 +122,7 @@ describe('Lockscreen', () => {
expect(passwordInput.props.type).toBe('password');
// Find all pressable elements and get the eye icon toggle (it's inside InputSlot)
- const allElements = root.findAllByType('View');
+ const allElements = root.findAll((node: any) => node.type === 'View');
const inputSlot = allElements.find((el: any) => el.props.className?.includes('pr-3'));
// Trigger the press on the InputSlot which has the onPress handler
diff --git a/src/__tests__/app/root/maintenance.test.tsx b/src/__tests__/app/root/maintenance.test.tsx
index c101589a..f354e81b 100644
--- a/src/__tests__/app/root/maintenance.test.tsx
+++ b/src/__tests__/app/root/maintenance.test.tsx
@@ -1,6 +1,5 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
-import { NavigationContainer } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import Maintenance from '../../../app/maintenance';
@@ -9,6 +8,7 @@ import { Env } from '@/lib/env';
// Mock dependencies
jest.mock('expo-router', () => ({
useRouter: jest.fn(),
+ useIsFocused: jest.fn(() => true),
}));
jest.mock('react-i18next', () => ({
@@ -31,7 +31,7 @@ jest.mock('@/lib/env', () => ({
}));
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
- return {children} ;
+ return <>{children}>;
};
describe('Maintenance', () => {
diff --git a/src/api/config/__tests__/index.test.ts b/src/api/config/__tests__/index.test.ts
new file mode 100644
index 00000000..b322545e
--- /dev/null
+++ b/src/api/config/__tests__/index.test.ts
@@ -0,0 +1,38 @@
+const mockAxiosGet = jest.fn();
+
+jest.mock('axios', () => ({
+ __esModule: true,
+ default: { get: (...args: unknown[]) => mockAxiosGet(...args) },
+}));
+jest.mock('@/lib/storage/app', () => ({
+ getBaseApiUrl: () => 'https://api-eu-central.resgrid.com/api/v4',
+}));
+jest.mock('../../common', () => ({
+ createApiEndpoint: () => ({ get: jest.fn() }),
+}));
+
+import { getSystemConfig } from '../index';
+
+describe('getSystemConfig', () => {
+ beforeEach(() => {
+ mockAxiosGet.mockReset();
+ mockAxiosGet.mockResolvedValue({ data: { Data: { Locations: [] } } });
+ });
+
+ it('calls the configured server anonymously so it works before sign-in', async () => {
+ const result = await getSystemConfig();
+
+ expect(result).toEqual({ Data: { Locations: [] } });
+ expect(mockAxiosGet).toHaveBeenCalledTimes(1);
+ const [url, config] = mockAxiosGet.mock.calls[0];
+ expect(url).toBe('https://api-eu-central.resgrid.com/api/v4/Config/GetSystemConfig');
+ expect(config.headers?.Authorization).toBeUndefined();
+ expect(config.timeout).toBeGreaterThan(0);
+ });
+
+ it('can query an explicit server', async () => {
+ await getSystemConfig('https://api.resgrid.com/api/v4');
+
+ expect(mockAxiosGet.mock.calls[0][0]).toBe('https://api.resgrid.com/api/v4/Config/GetSystemConfig');
+ });
+});
diff --git a/src/api/config/index.ts b/src/api/config/index.ts
index c2180396..f7eec994 100644
--- a/src/api/config/index.ts
+++ b/src/api/config/index.ts
@@ -1,10 +1,15 @@
+import axios from 'axios';
+
+import { getBaseApiUrl } from '@/lib/storage/app';
import { type GetConfigResult } from '@/models/v4/configs/getConfigResult';
import { type GetSystemConfigResult } from '@/models/v4/configs/getSystemConfigResult';
import { createApiEndpoint } from '../common';
+// A custom server URL that doesn't answer shouldn't leave the server picker spinning.
+const SYSTEM_CONFIG_TIMEOUT_MS = 10000;
+
const getConfigApi = createApiEndpoint('/Config/GetConfig');
-const getSystemConfigApi = createApiEndpoint('/Config/GetSystemConfig');
export const getConfig = async (key: string) => {
const response = await getConfigApi.get({
@@ -13,7 +18,10 @@ export const getConfig = async (key: string) => {
return response.data;
};
-export const getSystemConfig = async () => {
- const response = await getSystemConfigApi.get();
+// GetSystemConfig is anonymous and is needed on the login screen (to list the Resgrid hosted
+// sites) before there is a session, so it bypasses the authenticated api client - that client
+// refuses to send any request without an access token.
+export const getSystemConfig = async (baseApiUrl: string = getBaseApiUrl()) => {
+ const response = await axios.get(`${baseApiUrl}/Config/GetSystemConfig`, { timeout: SYSTEM_CONFIG_TIMEOUT_MS });
return response.data;
};
diff --git a/src/api/operations/__tests__/operations.test.ts b/src/api/operations/__tests__/operations.test.ts
index a60a5434..118c26f1 100644
--- a/src/api/operations/__tests__/operations.test.ts
+++ b/src/api/operations/__tests__/operations.test.ts
@@ -1,11 +1,26 @@
-jest.mock('@/api/common/client', () => ({ api: { get: jest.fn(), post: jest.fn() } }));
+jest.mock('@/api/common/client', () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() } }));
import { api } from '@/api/common/client';
-import { addResourceUsage, buildF42, getDeployments, getTimeReports, newTimeReport, saveTimeEntries, submitTimeReport, validateCalOesMarsWorkItem } from '@/api/operations/operations';
+import {
+ addResourceUsage,
+ approveTimeReport,
+ buildF42,
+ deleteExpense,
+ getDeployments,
+ getExpenses,
+ getTimeReports,
+ newTimeReport,
+ saveExpense,
+ saveTimeEntries,
+ signTimeReport,
+ submitTimeReport,
+ validateCalOesMarsWorkItem,
+} from '@/api/operations/operations';
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(api.get).mockResolvedValue({ data: { Data: [] } });
jest.mocked(api.post).mockResolvedValue({ data: { Data: { Id: 'r-1', Entries: [] }, Errors: [], Warnings: [] } });
+ jest.mocked(api.delete).mockResolvedValue({ data: { Status: 'Success' } });
});
it('lists open deployments by default and everything on request', async () => {
@@ -17,15 +32,50 @@ it('lists open deployments by default and everything on request', async () => {
expect(api.get).toHaveBeenLastCalledWith('/TimeReports/GetTimeReports', { params: { deploymentId: 'dep-1' } });
});
-it('creates, saves and submits a daily time report with the whole entry list as the unit of save', async () => {
- await newTimeReport('dep-1', '2026-09-19');
- expect(api.post).toHaveBeenCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19' });
- const entry = { Id: null, SubjectType: 0, DeploymentPersonnelId: 'p-1', EntryType: 0, StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T18:00:00', PaidBreakMinutes: 0, UnpaidBreakMinutes: 30, AgencySuppliedMeals: true, AgencySuppliedAccommodation: false, SortOrder: 0 };
+it('opens crew and individual reports and saves only the local wall clock', async () => {
+ await newTimeReport('dep-1', '2026-09-19', { deploymentUnitId: 'du-1' });
+ expect(api.post).toHaveBeenCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19', DeploymentUnitId: 'du-1', DeploymentPersonnelId: null });
+ await newTimeReport('dep-1', '2026-09-19', { deploymentPersonnelId: 'dp-5' });
+ expect(api.post).toHaveBeenLastCalledWith('/TimeReports/NewTimeReport', { DeploymentId: 'dep-1', ReportDate: '2026-09-19', DeploymentUnitId: null, DeploymentPersonnelId: 'dp-5' });
+
+ const entry = {
+ Id: 'e-1',
+ SubjectType: 0,
+ DeploymentPersonnelId: 'p-1',
+ EntryType: 0,
+ StartTime: '2026-09-19T15:00:00.000Z',
+ EndTime: '2026-09-20T01:00:00.000Z',
+ StartLocal: '2026-09-19T08:00',
+ EndLocal: '2026-09-19T18:00',
+ PaidBreakMinutes: 0,
+ UnpaidBreakMinutes: 30,
+ AgencySuppliedMeals: true,
+ AgencySuppliedAccommodation: false,
+ SortOrder: 0,
+ Hours: 9.5,
+ };
const saved = await saveTimeEntries('r-1', [entry]);
- expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveTimeEntries', { TimeReportId: 'r-1', Entries: [entry] });
+ const { StartTime: _start, EndTime: _end, Hours: _hours, ...sent } = entry;
+ expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveTimeEntries', { TimeReportId: 'r-1', Entries: [sent] });
expect(saved.Errors).toEqual([]);
await submitTimeReport('r-1');
expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SubmitTimeReport', { Id: 'r-1' });
+ await signTimeReport('r-1', true);
+ expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SignTimeReport', { Id: 'r-1', ContractorSigned: true, CustomerSignerName: null });
+ await signTimeReport('r-1', false, 'J. Agency');
+ expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SignTimeReport', { Id: 'r-1', ContractorSigned: false, CustomerSignerName: 'J. Agency' });
+ await approveTimeReport('r-1');
+ expect(api.post).toHaveBeenLastCalledWith('/TimeReports/ApproveTimeReport', { Id: 'r-1' });
+});
+
+it('files, lists and removes expenses with the receipt riding as base64', async () => {
+ await getExpenses('dep-1');
+ expect(api.get).toHaveBeenLastCalledWith('/TimeReports/GetExpenses', { params: { deploymentId: 'dep-1' } });
+ const input = { DeploymentId: 'dep-1', ExpenseDate: '2026-09-19', ExpenseType: 4, Amount: 88.4, ReceiptData: 'AAAA', ReceiptFileName: 'r.jpg', ReceiptContentType: 'image/jpeg' };
+ await saveExpense(input);
+ expect(api.post).toHaveBeenLastCalledWith('/TimeReports/SaveExpense', input, { timeout: 60000 });
+ await deleteExpense('x-1');
+ expect(api.delete).toHaveBeenCalledWith('/TimeReports/DeleteExpense', { params: { id: 'x-1' } });
});
it('posts usage readings and the F-42 draft / validation calls as the server expects them', async () => {
diff --git a/src/api/operations/operations.ts b/src/api/operations/operations.ts
index 0e1753e7..10352c8e 100644
--- a/src/api/operations/operations.ts
+++ b/src/api/operations/operations.ts
@@ -6,6 +6,8 @@ import type {
CalOesMarsWorkItem,
Deployment,
DeploymentAccess,
+ Expense,
+ ExpenseInput,
FieldCostAccess,
OperationsResult,
ResourceUsage,
@@ -22,12 +24,43 @@ export const getDeployments = async (active = true) => (await api.get (await api.get>('/Deployments/GetDeployment', { params: { id } })).data.Data;
export const getDeploymentByCallId = async (callId: number) => (await api.get>('/Deployments/GetDeploymentByCallId', { params: { callId } })).data.Data;
+/** Who a new report covers: one deployed unit's crew (CTR), one person, or — managers only — the whole deployment. */
+export interface TimeReportScopeInput {
+ deploymentUnitId?: string | null;
+ deploymentPersonnelId?: string | null;
+}
+
+// Reports come back with their entries so a day's report opens straight from the list.
export const getTimeReports = async (deploymentId: string) => (await api.get>('/TimeReports/GetTimeReports', { params: { deploymentId } })).data.Data;
export const getTimeReport = async (id: string) => (await api.get>('/TimeReports/GetTimeReport', { params: { id } })).data.Data;
-export const newTimeReport = async (deploymentId: string, reportDate: string) => (await api.post('/TimeReports/NewTimeReport', { DeploymentId: deploymentId, ReportDate: reportDate })).data;
-// The whole entry list is the unit of save: the server replaces the report's entries with what it is sent.
-export const saveTimeEntries = async (timeReportId: string, entries: TimeEntry[]) => (await api.post('/TimeReports/SaveTimeEntries', { TimeReportId: timeReportId, Entries: entries })).data;
+export const newTimeReport = async (deploymentId: string, reportDate: string, scope: TimeReportScopeInput = {}) =>
+ (
+ await api.post('/TimeReports/NewTimeReport', {
+ DeploymentId: deploymentId,
+ ReportDate: reportDate,
+ DeploymentUnitId: scope.deploymentUnitId ?? null,
+ DeploymentPersonnelId: scope.deploymentPersonnelId ?? null,
+ })
+ ).data;
+// The server replaces the entries of the subjects the caller may write with what it is sent; everyone else's stay as stored.
+// Only the local wall clock travels (StartLocal/EndLocal): the server converts it in the department's zone.
+export const saveTimeEntries = async (timeReportId: string, entries: TimeEntry[]) =>
+ (
+ await api.post('/TimeReports/SaveTimeEntries', {
+ TimeReportId: timeReportId,
+ Entries: entries.map(({ StartTime: _start, EndTime: _end, Hours: _hours, ...entry }) => entry),
+ })
+ ).data;
export const submitTimeReport = async (id: string) => (await api.post('/TimeReports/SubmitTimeReport', { Id: id })).data;
+/** The crew boss signs as the acting user; the customer (agency) signer is a typed name. */
+export const signTimeReport = async (id: string, contractorSigned: boolean, customerSignerName?: string | null) =>
+ (await api.post('/TimeReports/SignTimeReport', { Id: id, ContractorSigned: contractorSigned, CustomerSignerName: customerSignerName || null })).data;
+export const approveTimeReport = async (id: string) => (await api.post('/TimeReports/ApproveTimeReport', { Id: id })).data;
+
+export const getExpenses = async (deploymentId: string) => (await api.get>('/TimeReports/GetExpenses', { params: { deploymentId } })).data.Data;
+// Receipts ride as base64; allow a slower link than the default request timeout.
+export const saveExpense = async (input: ExpenseInput) => (await api.post>('/TimeReports/SaveExpense', input, { timeout: 60000 })).data.Data;
+export const deleteExpense = async (id: string) => (await api.delete>('/TimeReports/DeleteExpense', { params: { id } })).data;
export const getFieldCostAccess = async () => (await api.get>('/FieldCost/GetAccess')).data.Data;
export const getResourceUsage = async (deploymentId: string) => (await api.get>('/FieldCost/GetResourceUsage', { params: { deploymentId } })).data.Data;
diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx
index 34905fe1..debe8cf8 100644
--- a/src/app/(app)/_layout.tsx
+++ b/src/app/(app)/_layout.tsx
@@ -57,6 +57,7 @@ async function teardownSignedInSession(): Promise {
['SignalR update hub', () => signalR.disconnectUpdateHub()],
['SignalR chat hub', () => signalR.disconnectChatHub()],
['SignalR geolocation hub', () => signalR.disconnectGeolocationHub()],
+ ['realtime map locations', () => signalR.clearLiveLocations()],
['LiveKit room', () => useLiveKitStore.getState().disconnectFromRoom()],
['audio stream', () => useAudioStreamStore.getState().cleanup()],
['audio service', () => audioService.cleanup()],
@@ -246,6 +247,24 @@ export default function TabLayout() {
if (!isCurrentRun()) return;
+ // The dispatch console's live maps move unit and personnel pins from the geolocation hub's feed, so it
+ // connects with the update hub (and, like it, disconnects on background and reconnects on resume).
+ try {
+ await useSignalRStore.getState().connectGeolocationHub();
+ logger.info({
+ message: 'SignalR geolocation hub connected successfully',
+ context: { platform: Platform.OS },
+ });
+ } catch (error) {
+ logger.error({
+ message: 'Failed to connect SignalR geolocation hub during initialization',
+ context: { error, platform: Platform.OS },
+ });
+ // Don't fail initialization if SignalR connection fails
+ }
+
+ if (!isCurrentRun()) return;
+
// Connect the realtime chat hub only when the Chat.System feature flag is on for
// this department; when it is off every chat surface stays hidden.
if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) {
diff --git a/src/app/(app)/calls.tsx b/src/app/(app)/calls.tsx
index 6a8b891a..46b1066c 100644
--- a/src/app/(app)/calls.tsx
+++ b/src/app/(app)/calls.tsx
@@ -1,5 +1,4 @@
-import { useFocusEffect } from '@react-navigation/native';
-import { type Href, router } from 'expo-router';
+import { type Href, router, useFocusEffect } from 'expo-router';
import { PlusIcon, RefreshCcwDotIcon, Search, X } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/src/app/(app)/home.tsx b/src/app/(app)/home.tsx
index acc777b1..06c6ddad 100644
--- a/src/app/(app)/home.tsx
+++ b/src/app/(app)/home.tsx
@@ -1,5 +1,4 @@
-import { useFocusEffect } from '@react-navigation/native';
-import { type Href, router } from 'expo-router';
+import { type Href, router, useFocusEffect } from 'expo-router';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -28,6 +27,7 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { HStack } from '@/components/ui/hstack';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
+import { useSetStatusForCall } from '@/hooks/use-set-status-for-call';
import { logger } from '@/lib/logging';
import { isCallActive, isCallPending, isCallScheduled } from '@/lib/utils';
import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
@@ -462,28 +462,16 @@ export default function DispatchConsole() {
});
};
- // Handle setting unit status for a call
- const handleSetUnitStatusForCall = (unitId: string, unitName: string) => {
- // This could open a status selection modal or navigate to unit status screen
- addActivityLogEntry({
- type: 'unit',
- action: t('dispatch.unit_status_change'),
- description: unitName,
- metadata: { unitId, callId: selectedCallId ?? undefined },
- });
- // For now, we'll log the event - in a full implementation, this would open a status picker
- };
-
- // Handle setting personnel status for a call
- const handleSetPersonnelStatusForCall = (personnelId: string, personnelName: string) => {
- addActivityLogEntry({
- type: 'personnel',
- action: t('dispatch.personnel_status_change'),
- description: personnelName,
- metadata: { personnelId, callId: selectedCallId ?? undefined },
- });
- // For now, we'll log the event - in a full implementation, this would open a status picker
- };
+ // "+" set-status-for-call: open the unit/personnel actions panel with the destination preset to the
+ // selected call; the status is saved through the panel's normal submit path.
+ const { handleSetUnitStatusForCall, handleSetPersonnelStatusForCall } = useSetStatusForCall({
+ calls,
+ units,
+ personnel,
+ selectedCallId,
+ setSelectedUnitData,
+ setSelectedPersonnelData,
+ });
// Get selected call for filter banner
const selectedCall = useMemo(() => {
diff --git a/src/app/(app)/home.web.tsx b/src/app/(app)/home.web.tsx
index d99eae1f..dbf29e57 100644
--- a/src/app/(app)/home.web.tsx
+++ b/src/app/(app)/home.web.tsx
@@ -1,5 +1,4 @@
-import { useFocusEffect } from '@react-navigation/native';
-import { type Href, router } from 'expo-router';
+import { type Href, router, useFocusEffect } from 'expo-router';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -28,6 +27,7 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { HStack } from '@/components/ui/hstack';
import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
+import { useSetStatusForCall } from '@/hooks/use-set-status-for-call';
import { logger } from '@/lib/logging';
import { isCallActive, isCallPending, isCallScheduled } from '@/lib/utils';
import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
@@ -547,39 +547,16 @@ export default function DispatchConsoleWeb() {
}
}, [selectedCallId]);
- // Handle setting unit status for call
- const handleSetUnitStatusForCall = useCallback(
- (unitId: string) => {
- const unit = units.find((u) => u.UnitId === unitId);
- if (unit) {
- addActivityLogEntry({
- type: 'unit',
- action: t('dispatch.unit_status_change'),
- description: `${unit.Name}`,
- metadata: { unitId, callId: selectedCallId ?? undefined },
- });
- // TODO: Implement status change modal or action
- }
- },
- [units, addActivityLogEntry, t, selectedCallId]
- );
-
- // Handle setting personnel status for call
- const handleSetPersonnelStatusForCall = useCallback(
- (personnelId: string) => {
- const person = personnel.find((p) => p.UserId === personnelId);
- if (person) {
- addActivityLogEntry({
- type: 'personnel',
- action: t('dispatch.personnel_status_change'),
- description: `${person.FirstName} ${person.LastName}`,
- metadata: { personnelId, callId: selectedCallId ?? undefined },
- });
- // TODO: Implement status change modal or action
- }
- },
- [personnel, addActivityLogEntry, t, selectedCallId]
- );
+ // "+" set-status-for-call: open the unit/personnel actions panel with the destination preset to the
+ // selected call; the status is saved through the panel's normal submit path.
+ const { handleSetUnitStatusForCall, handleSetPersonnelStatusForCall } = useSetStatusForCall({
+ calls,
+ units,
+ personnel,
+ selectedCallId,
+ setSelectedUnitData,
+ setSelectedPersonnelData,
+ });
const handleWeatherAlertsPress = useCallback(() => {
router.push('/(app)/weather-alerts' as Href);
diff --git a/src/app/(app)/map.tsx b/src/app/(app)/map.tsx
index 528c1f23..15b76fad 100644
--- a/src/app/(app)/map.tsx
+++ b/src/app/(app)/map.tsx
@@ -16,10 +16,12 @@ import { useActiveMapLayers } from '@/hooks/use-active-map-layers';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAppLifecycle } from '@/hooks/use-app-lifecycle';
import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers';
+import { useMapLiveLocations } from '@/hooks/use-map-live-locations';
import { useMapSignalRUpdates } from '@/hooks/use-map-signalr-updates';
import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
-import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId } from '@/lib/poi-map-layers';
+import { getPinEntityId } from '@/lib/map-pin-ids';
+import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers';
import { onSortOptions } from '@/lib/utils';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData';
@@ -124,12 +126,28 @@ export default function Map() {
const pulseAnim = useRef(new Animated.Value(1)).current;
+ // The POI layers last synced from map data; null until the first load. Background refetches (update-hub
+ // events, realtime locations) must keep the user's layer toggles, so only the first load applies defaults.
+ const syncedPoiLayersRef = useRef(null);
const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => {
+ const previousPoiLayers = syncedPoiLayersRef.current;
+ syncedPoiLayersRef.current = nextPoiLayers;
setPoiLayers(nextPoiLayers);
- setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers));
+ setVisiblePoiLayerIds((currentLayerIds) => (previousPoiLayers === null ? createDefaultVisiblePoiLayerIds(nextPoiLayers) : mergeVisiblePoiLayerIds(currentLayerIds, previousPoiLayers, nextPoiLayers)));
}, []);
- useMapSignalRUpdates(setMapPins, syncPoiLayers);
+ // Realtime unit/personnel positions from the geolocation hub. Its refetch requests go through the
+ // update-hub refetch below, which is wired in once that hook has run.
+ const requestPinsRefreshRef = useRef<(() => void) | null>(null);
+ const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []);
+ const { applyToFetchedPins } = useMapLiveLocations({ pins: mapPins, setPins: setMapPins, requestRefresh: requestPinsRefresh });
+
+ const handleMarkersFetched = useCallback((markers: MapMakerInfoData[], fetchStartedAt: number) => setMapPins(applyToFetchedPins(markers, fetchStartedAt)), [applyToFetchedPins]);
+
+ const { requestRefresh } = useMapSignalRUpdates(handleMarkersFetched, syncPoiLayers);
+ useEffect(() => {
+ requestPinsRefreshRef.current = requestRefresh;
+ }, [requestRefresh]);
const togglePoiLayer = useCallback((layerId: string) => {
setVisiblePoiLayerIds((currentLayerIds) => {
@@ -288,10 +306,11 @@ export default function Map() {
const fetchMapDataAndMarkers = async () => {
try {
+ const fetchStartedAt = Date.now();
const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal);
if (mapDataAndMarkers && mapDataAndMarkers.Data) {
- setMapPins(mapDataAndMarkers.Data.MapMakerInfos);
+ handleMarkersFetched(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt);
syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []);
}
} catch (error) {
@@ -316,7 +335,7 @@ export default function Map() {
return () => {
abortController.abort();
};
- }, [syncPoiLayers]);
+ }, [handleMarkersFetched, syncPoiLayers]);
useEffect(() => {
Animated.loop(
@@ -384,19 +403,19 @@ export default function Map() {
logger.info({
message: 'Setting call as current call',
context: {
- callId: pin.Id,
+ callId: getPinEntityId(pin),
callTitle: pin.Title,
},
});
- await useCoreStore.getState().setActiveCall(pin.Id);
+ await useCoreStore.getState().setActiveCall(getPinEntityId(pin));
useToastStore.getState().showToast('success', t('map.call_set_as_current'));
} catch (error) {
logger.error({
message: 'Failed to set call as current call',
context: {
error,
- callId: pin.Id,
+ callId: getPinEntityId(pin),
callTitle: pin.Title,
},
});
diff --git a/src/app/(app)/map.web.tsx b/src/app/(app)/map.web.tsx
index 41cbad84..78ee2c39 100644
--- a/src/app/(app)/map.web.tsx
+++ b/src/app/(app)/map.web.tsx
@@ -11,12 +11,13 @@ import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar';
import { useActiveMapLayers } from '@/hooks/use-active-map-layers';
import { useAnalytics } from '@/hooks/use-analytics';
import { MapLayerType, useMapLayers } from '@/hooks/use-map-layers';
+import { useMapLiveLocations } from '@/hooks/use-map-live-locations';
import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
import { getDepartmentMapCenter } from '@/lib/map-center';
-import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers';
-import { createMapMarkerElement } from '@/lib/map-markers-web';
-import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId } from '@/lib/poi-map-layers';
+import { hasValidMapCoordinates } from '@/lib/map-markers';
+import { buildMapPinPopupHtml, createMapMarkerElement } from '@/lib/map-markers-web';
+import { createDefaultVisiblePoiLayerIds, filterMapPinsByPoiLayers, getPoiMapLayerId, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData';
import { type PoiLayerData } from '@/models/v4/mapping/poiLayerData';
@@ -31,7 +32,9 @@ export default function MapWeb() {
const { colorScheme } = useColorScheme();
const mapContainer = useRef(null);
const map = useRef(null);
- const markersRef = useRef([]);
+ // Live markers by pin id, so a moved pin is repositioned in place instead of every marker being rebuilt.
+ const markersRef = useRef>(new Map());
+ const markerMetaRef = useRef>(new Map());
const layerIdsRef = useRef([]);
const sourceIdsRef = useRef([]);
const activeSourceIdsRef = useRef([]);
@@ -53,11 +56,21 @@ export default function MapWeb() {
// Custom-map region layers (RE1-T105) rendered on top of the legacy vector layers.
const { activeLayers } = useActiveMapLayers();
+ // The POI layers last synced from map data; null until the first load. A background refetch must keep the
+ // user's layer toggles, so only the first load applies defaults.
+ const syncedPoiLayersRef = useRef(null);
const syncPoiLayers = useCallback((nextPoiLayers: PoiLayerData[]) => {
+ const previousPoiLayers = syncedPoiLayersRef.current;
+ syncedPoiLayersRef.current = nextPoiLayers;
setPoiLayers(nextPoiLayers);
- setVisiblePoiLayerIds(createDefaultVisiblePoiLayerIds(nextPoiLayers));
+ setVisiblePoiLayerIds((currentLayerIds) => (previousPoiLayers === null ? createDefaultVisiblePoiLayerIds(nextPoiLayers) : mergeVisiblePoiLayerIds(currentLayerIds, previousPoiLayers, nextPoiLayers)));
}, []);
+ // Realtime unit/personnel positions from the geolocation hub; its refetch requests reuse fetchMapData below.
+ const requestPinsRefreshRef = useRef<(() => void) | null>(null);
+ const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []);
+ const { applyToFetchedPins } = useMapLiveLocations({ pins: mapPins, setPins: setMapPins, requestRefresh: requestPinsRefresh });
+
const togglePoiLayer = useCallback((layerId: string) => {
setVisiblePoiLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
@@ -146,10 +159,14 @@ export default function MapWeb() {
});
});
+ const markers = markersRef.current;
+ const markerMeta = markerMetaRef.current;
+
return () => {
// Clean up markers
- markersRef.current.forEach((marker) => marker.remove());
- markersRef.current = [];
+ markers.forEach((marker) => marker.remove());
+ markers.clear();
+ markerMeta.clear();
map.current?.remove();
map.current = null;
@@ -184,17 +201,29 @@ export default function MapWeb() {
}, [fetchLayers])
);
- // Fetch map data and markers on mount
- useEffect(() => {
+ // Fetches map data and markers: once on mount, and again in the background when the realtime location feed
+ // asks for it. Only the first successful load may move the camera; a refresh never touches the user's view.
+ const fetchAbortRef = useRef(null);
+ const hasCenteredOnDataRef = useRef(false);
+ const fetchMapData = useCallback(async () => {
+ fetchAbortRef.current?.abort();
const abortController = new AbortController();
+ fetchAbortRef.current = abortController;
- const fetchMapDataAndMarkers = async () => {
- try {
- const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal);
+ try {
+ const fetchStartedAt = Date.now();
+ const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal);
- if (mapDataAndMarkers && mapDataAndMarkers.Data) {
- setMapPins(mapDataAndMarkers.Data.MapMakerInfos);
- syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []);
+ if (abortController.signal.aborted) {
+ return;
+ }
+
+ if (mapDataAndMarkers && mapDataAndMarkers.Data) {
+ setMapPins(applyToFetchedPins(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt));
+ syncPoiLayers(mapDataAndMarkers.Data.PoiLayers ?? []);
+
+ if (!hasCenteredOnDataRef.current) {
+ hasCenteredOnDataRef.current = true;
// Center map on the data center if provided
if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && map.current) {
@@ -211,58 +240,94 @@ export default function MapWeb() {
}
}
}
- } catch (error) {
- // Don't log aborted requests as errors
- if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) {
- logger.debug({
- message: 'Map data fetch was aborted during component unmount',
- });
- return;
- }
-
- logger.error({
- message: 'Failed to fetch initial map data and markers',
- context: { error },
+ }
+ } catch (error) {
+ // Don't log aborted requests as errors
+ if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) {
+ logger.debug({
+ message: 'Map data fetch was aborted',
});
+ return;
+ }
+
+ logger.error({
+ message: 'Failed to fetch map data and markers',
+ context: { error },
+ });
+ } finally {
+ if (fetchAbortRef.current === abortController) {
+ fetchAbortRef.current = null;
}
+ }
+ }, [applyToFetchedPins, syncPoiLayers]);
+
+ useEffect(() => {
+ requestPinsRefreshRef.current = () => {
+ void fetchMapData();
};
+ }, [fetchMapData]);
- fetchMapDataAndMarkers();
+ // Fetch map data and markers on mount
+ useEffect(() => {
+ void fetchMapData();
+ // Abort the request if the component unmounts
return () => {
- abortController.abort();
+ fetchAbortRef.current?.abort();
+ fetchAbortRef.current = null;
};
- }, [syncPoiLayers]);
+ }, [fetchMapData]);
- // Update markers when mapPins change
+ // Reconcile markers with the visible pins: create new ones, move moved ones in place, rebuild only those whose
+ // appearance changed, and remove the rest. The camera is never touched here.
useEffect(() => {
- if (!map.current || !isMapReady) return;
+ const instance = map.current;
+ if (!instance || !isMapReady) return;
- // Remove existing markers
- markersRef.current.forEach((marker) => marker.remove());
- markersRef.current = [];
+ const theme = colorScheme === 'dark' ? 'dark' : 'light';
+ const seenPinIds = new Set();
- // Add new markers
visibleMapPins.forEach((pin) => {
if (!hasValidMapCoordinates(pin)) return;
+ seenPinIds.add(pin.Id);
+
+ const existing = markersRef.current.get(pin.Id);
+ const meta = markerMetaRef.current.get(pin.Id);
+ // Pins that did not change keep their object identity, so most markers are skipped outright.
+ if (existing && meta && meta.pin === pin && meta.signature.startsWith(`${theme}:`)) return;
+
+ const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`;
+
+ if (existing && meta && meta.signature === signature) {
+ if (meta.pin.Latitude !== pin.Latitude || meta.pin.Longitude !== pin.Longitude) {
+ existing.setLngLat([pin.Longitude, pin.Latitude]);
+ existing.getPopup()?.setHTML(buildMapPinPopupHtml(pin));
+ }
+ markerMetaRef.current.set(pin.Id, { pin, signature });
+ return;
+ }
+
+ existing?.remove();
// Create custom marker element using shared utility
- const el = createMapMarkerElement(pin, colorScheme === 'dark' ? 'dark' : 'light');
+ const el = createMapMarkerElement(pin, theme);
// Create popup
- const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(
- `
-
${pin.Title}
- ${getMapPinSummary(pin) ? `
${getMapPinSummary(pin)}
` : ''}
-
- ${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)}
-
-
`
- );
-
- const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(map.current!);
-
- markersRef.current.push(marker);
+ const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(buildMapPinPopupHtml(pin));
+
+ const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(instance);
+
+ markersRef.current.set(pin.Id, marker);
+ markerMetaRef.current.set(pin.Id, { pin, signature });
+ });
+
+ // Remove markers whose pins are gone or hidden
+ markersRef.current.forEach((marker, pinId) => {
+ if (!seenPinIds.has(pinId)) {
+ marker.remove();
+ markersRef.current.delete(pinId);
+ markerMetaRef.current.delete(pinId);
+ }
});
}, [visibleMapPins, isMapReady, colorScheme]);
diff --git a/src/app/(app)/operations/[id].tsx b/src/app/(app)/operations/[id].tsx
index e072d75b..2cc98d9c 100644
--- a/src/app/(app)/operations/[id].tsx
+++ b/src/app/(app)/operations/[id].tsx
@@ -4,7 +4,9 @@ import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';
+import { ExpensesPanel } from '@/components/operations/expenses-panel';
import { MarsPanel } from '@/components/operations/mars-panel';
+import { AwaitingApproval, DayReports, ScopePicker, useScopeLabel } from '@/components/operations/scope-picker';
import { TimeReportEditor } from '@/components/operations/time-report-editor';
import { UsageForm } from '@/components/operations/usage-form';
import { Button, ButtonText } from '@/components/ui/button';
@@ -14,31 +16,46 @@ import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { operationsCapabilities } from '@/lib/operations/capabilities';
-import { localDateKey, rosterSubjects } from '@/lib/operations/time';
-import { DeploymentFinanceMode } from '@/models/v4/operations';
+import {
+ availableScopes,
+ canWriteSubject,
+ coveringReport,
+ dateOf,
+ defaultScope,
+ localDateKey,
+ reportScope,
+ reportsOnDate,
+ scopeKey,
+ scopeName,
+ scopeSubjects,
+ shiftDateKey,
+ subjectNames,
+ type TimeScope,
+} from '@/lib/operations/time';
+import { DeploymentFinanceMode, TimeReportStatus } from '@/models/v4/operations';
import useAuthStore from '@/stores/auth/store';
import { useDeploymentsStatus } from '@/stores/feature-flags/store';
import { useOperationsStore } from '@/stores/operations/store';
-type Section = 'time' | 'usage' | 'mars';
+type Section = 'time' | 'expenses' | 'usage' | 'mars';
-const shiftDay = (dateKey: string, days: number) => {
- const [y, m, d] = dateKey.split('-').map(Number);
- return localDateKey(new Date(y, m - 1, d + days));
-};
-
-// One deployment: the daily time report for a chosen day, resource usage readings and the MARS F-42
-// state. What the person may edit follows the server's access answer and this app's capabilities.
+// One deployment: the crew or individual time report for a chosen day, expenses, resource usage readings and
+// the MARS F-42 state. What the person may open and write comes from the server's TimeAccess (their roster row,
+// the deployed units they crew) and this app's capabilities; approvers get the submitted queue.
export default function OperationsDeploymentScreen() {
const { t } = useTranslation();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
const flagStatus = useDeploymentsStatus();
const userId = useAuthStore((state) => state.userId);
- const { access, costAccess, marsAccess, deployment, report, entries, dirty, issues, warnings, usage, marsItems, validation, busy, error } = useOperationsStore();
+ const { costAccess, marsAccess, deployment, reports, scope, report, entries, dirty, issues, warnings, expenses, usage, marsItems, validation, busy, error } = useOperationsStore();
const [dateKey, setDateKey] = useState(localDateKey());
const dateRef = useRef(dateKey);
const [section, setSection] = useState('time');
+ const activeUnitId = operationsCapabilities.useActiveUnitId();
+ const activeUnitRef = useRef(activeUnitId);
+ activeUnitRef.current = activeUnitId;
+ const scopeLabel = useScopeLabel();
useFocusEffect(
useCallback(() => {
@@ -48,32 +65,57 @@ export default function OperationsDeploymentScreen() {
if (!store.access) await store.loadAccess();
if (!useOperationsStore.getState().access?.Enabled) return;
await useOperationsStore.getState().open(id);
- await useOperationsStore.getState().openReport(dateRef.current, false);
- await Promise.all([useOperationsStore.getState().loadUsage(), useOperationsStore.getState().loadMars()]);
+ const opened = useOperationsStore.getState().deployment;
+ if (opened) useOperationsStore.getState().setScope(defaultScope(opened, activeUnitRef.current), dateRef.current);
+ await Promise.all([useOperationsStore.getState().loadExpenses(), useOperationsStore.getState().loadUsage(), useOperationsStore.getState().loadMars()]);
})();
return () => useOperationsStore.getState().close();
}, [flagStatus, id])
);
- const activeUnitId = operationsCapabilities.useActiveUnitId();
- const subjects = useMemo(() => (deployment ? rosterSubjects(deployment, { userId: userId ?? null, activeUnitId, manager: !!access?.CanManage }) : []), [deployment, userId, activeUnitId, access?.CanManage]);
- const canEditTime = operationsCapabilities.editTime && !!access?.Enabled && subjects.length > 0;
+ const access = deployment?.TimeAccess ?? null;
+ const scopes = useMemo(() => (deployment ? availableScopes(deployment) : []), [deployment]);
+ const names = useMemo(() => (deployment ? subjectNames(deployment) : {}), [deployment]);
+ // The picker offers the subjects of the open report's own scope (a manager may open any report on the day).
+ const openScope: TimeScope | null = report ? reportScope(report) : scope;
+ const subjects = useMemo(() => (deployment ? scopeSubjects(deployment, openScope) : []), [deployment, openScope]);
+ const dayReports = useMemo(() => reportsOnDate(reports, dateKey), [reports, dateKey]);
+ const awaiting = useMemo(() => reports.filter((candidate) => candidate.Status === TimeReportStatus.Submitted).sort((a, b) => a.ReportDate.localeCompare(b.ReportDate)), [reports]);
+ const canApprove = operationsCapabilities.approveTime && !!access?.CanApprove;
+ const supervises = !!access?.CanManage || canApprove;
+ const covered = useMemo(() => {
+ if (report || !scope) return null;
+ const subject = scope.kind === 'crew' ? scope.unitId : scope.kind === 'individual' ? scope.personnelId : null;
+ return subject ? coveringReport(reports, dateKey, subject) : null;
+ }, [report, scope, reports, dateKey]);
+ const canCreate = operationsCapabilities.editTime && !!scope && (scope.kind !== 'deployment' || !!access?.CanManage);
+ const writable = useCallback((subjectId: string) => !!deployment && canWriteSubject(deployment, subjectId), [deployment]);
const showUsage = operationsCapabilities.recordUsage && !!costAccess?.Enabled && !!costAccess.CanRecordUsage;
+ const showExpenses = operationsCapabilities.recordExpenses && (access?.CanManage || (access?.WritableSubjectIds.length ?? 0) > 0);
const showMars = !!marsAccess?.Enabled && deployment?.FinanceMode === DeploymentFinanceMode.CostRecovery;
- const canDraftF42 = operationsCapabilities.draftF42 && showMars && (!!marsAccess?.CanManage || subjects.length > 0);
+ const canDraftF42 = operationsCapabilities.draftF42 && showMars && (!!marsAccess?.CanManage || (access?.WritableSubjectIds.length ?? 0) > 0);
const usageUnits = useMemo(() => {
if (!deployment) return [];
const units = deployment.Units.filter((unit) => unit.IsActive);
- if (access?.CanManage || !activeUnitId) return units;
- const mine = units.filter((unit) => String(unit.UnitId) === activeUnitId);
- return mine.length > 0 ? mine : units;
- }, [deployment, access?.CanManage, activeUnitId]);
+ if (access?.CanManage) return units;
+ const crewed = units.filter((unit) => access?.CrewUnitIds.includes(unit.Id));
+ const mine = activeUnitId ? crewed.filter((unit) => String(unit.UnitId) === activeUnitId) : [];
+ return mine.length > 0 ? mine : crewed;
+ }, [deployment, access, activeUnitId]);
const changeDay = (days: number) => {
- const next = shiftDay(dateKey, days);
+ const next = shiftDateKey(dateKey, days);
dateRef.current = next;
setDateKey(next);
- void useOperationsStore.getState().openReport(next, false);
+ useOperationsStore.getState().setScope(useOperationsStore.getState().scope, next);
+ };
+
+ const openReport = (target: { Id: string; ReportDate: string }) => {
+ const day = dateOf(target.ReportDate);
+ dateRef.current = day;
+ setDateKey(day);
+ setSection('time');
+ useOperationsStore.getState().selectReport(target.Id);
};
if (flagStatus === 'disabled') return ;
@@ -105,13 +147,27 @@ export default function OperationsDeploymentScreen() {
{t('operations.roster', { units: deployment.Units.filter((unit) => unit.IsActive).length, personnel: deployment.Personnel.filter((person) => person.IsActive).length })}
+ {access && access.CrewUnitIds.length > 0 ? (
+
+ {t('operations.crewOf', {
+ names: access.CrewUnitIds.map((unitId) => scopeName(deployment, { kind: 'crew', unitId }))
+ .filter(Boolean)
+ .join(', '),
+ })}
+
+ ) : null}
) : null}
{deployment ? (
-
+
setSection('time')} testID="operations-section-time">
{t('operations.time.title')}
+ {showExpenses ? (
+ setSection('expenses')} testID="operations-section-expenses">
+ {t('operations.expenses.title')}
+
+ ) : null}
{showUsage ? (
setSection('usage')} testID="operations-section-usage">
{t('operations.usage.title')}
@@ -122,9 +178,9 @@ export default function OperationsDeploymentScreen() {
{t('operations.mars.title')}
) : null}
-
+
) : null}
- {deployment && (section === 'time' || section === 'usage') ? (
+ {deployment && section !== 'mars' ? (
changeDay(-1)} testID="operations-day-previous" accessibilityRole="button" accessibilityLabel={t('operations.previousDay')}>
@@ -136,20 +192,51 @@ export default function OperationsDeploymentScreen() {
) : null}
{deployment && section === 'time' ? (
-
+ {canApprove ? : null}
+ useOperationsStore.getState().setScope(next, dateKey)} />
+ {supervises ? : null}
+ {scopes.length === 0 && !supervises ? {t('operations.time.notOnRoster')} : null}
+ {scope || report ? (
+ void useOperationsStore.getState().openReport(dateKey, true)}
+ onChange={(next) => useOperationsStore.getState().setEntries(next)}
+ onSave={() => void useOperationsStore.getState().save()}
+ onSubmit={() => void useOperationsStore.getState().submit()}
+ onSign={(crewBoss, customer) => void useOperationsStore.getState().sign(crewBoss, customer)}
+ onApprove={() => report && void useOperationsStore.getState().approve(report.Id)}
+ />
+ ) : null}
+
+ ) : null}
+ {deployment && section === 'expenses' && showExpenses ? (
+ void useOperationsStore.getState().openReport(dateKey, true)}
- onChange={(next) => useOperationsStore.getState().setEntries(next)}
- onSave={() => void useOperationsStore.getState().save()}
- onSubmit={() => void useOperationsStore.getState().submit()}
+ onAdd={(input) => useOperationsStore.getState().addExpense(input)}
+ onRemove={(expenseId) => useOperationsStore.getState().removeExpense(expenseId)}
/>
) : null}
{deployment && section === 'usage' && showUsage ? (
diff --git a/src/app/(app)/personnel.tsx b/src/app/(app)/personnel.tsx
index 0ba7f08a..5b5523dc 100644
--- a/src/app/(app)/personnel.tsx
+++ b/src/app/(app)/personnel.tsx
@@ -1,5 +1,4 @@
-import { useFocusEffect } from '@react-navigation/native';
-import { type Href, router } from 'expo-router';
+import { type Href, router, useFocusEffect } from 'expo-router';
import { Circle, Search, User, Users, X } from 'lucide-react-native';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/src/app/(app)/pois.tsx b/src/app/(app)/pois.tsx
index 2a429570..72223611 100644
--- a/src/app/(app)/pois.tsx
+++ b/src/app/(app)/pois.tsx
@@ -1,5 +1,4 @@
-import { useFocusEffect } from '@react-navigation/native';
-import { type Href, router } from 'expo-router';
+import { type Href, router, useFocusEffect } from 'expo-router';
import { ChevronDownIcon, MapPinned, Search, X } from 'lucide-react-native';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/src/app/(app)/records.tsx b/src/app/(app)/records.tsx
index 33e9575b..c80a919e 100644
--- a/src/app/(app)/records.tsx
+++ b/src/app/(app)/records.tsx
@@ -91,7 +91,7 @@ export default function RecordsScreen() {
// The flag is authoritative and resolves fail-closed; an unknown state waits rather than redirecting.
if (flagStatus === 'disabled') {
- return ;
+ return ;
}
return (
diff --git a/src/app/(app)/scheduled-calls.tsx b/src/app/(app)/scheduled-calls.tsx
index 4a92efb7..1b8d5087 100644
--- a/src/app/(app)/scheduled-calls.tsx
+++ b/src/app/(app)/scheduled-calls.tsx
@@ -1,5 +1,4 @@
-import { useFocusEffect } from '@react-navigation/native';
-import { type Href, router, Stack } from 'expo-router';
+import { type Href, router, Stack, useFocusEffect } from 'expo-router';
import { CalendarClockIcon, Search, X } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
diff --git a/src/app/(app)/settings.tsx b/src/app/(app)/settings.tsx
index fd3ede32..6ce99531 100644
--- a/src/app/(app)/settings.tsx
+++ b/src/app/(app)/settings.tsx
@@ -49,6 +49,15 @@ export default function Settings() {
await login({ username: data.username, password: data.password });
};
+ // The current session's tokens were issued by the previous server, so switching servers
+ // signs the user out to log in again against the new one.
+ const handleServerUrlChanged = React.useCallback(async () => {
+ logger.info({
+ message: 'Server URL changed, signing out',
+ });
+ await signOut();
+ }, [signOut]);
+
useEffect(() => {
if (status === 'signedIn' && isAuthenticated) {
logger.info({
@@ -117,7 +126,7 @@ export default function Settings() {
setShowLoginInfo(false)} onSubmit={handleLoginInfoSubmit} />
- setShowServerUrl(false)} />
+ setShowServerUrl(false)} onUrlChanged={handleServerUrlChanged} />
);
}
diff --git a/src/app/(app)/units.tsx b/src/app/(app)/units.tsx
index 58104861..b7948b7f 100644
--- a/src/app/(app)/units.tsx
+++ b/src/app/(app)/units.tsx
@@ -1,5 +1,4 @@
-import { useFocusEffect } from '@react-navigation/native';
-import { type Href, router } from 'expo-router';
+import { type Href, router, useFocusEffect } from 'expo-router';
import { Circle, MapPin, Search, Truck, X } from 'lucide-react-native';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
diff --git a/src/app/(app)/weather-alerts/index.tsx b/src/app/(app)/weather-alerts/index.tsx
index 3679d0e8..e5aa28cd 100644
--- a/src/app/(app)/weather-alerts/index.tsx
+++ b/src/app/(app)/weather-alerts/index.tsx
@@ -1,5 +1,4 @@
-import { useFocusEffect } from '@react-navigation/native';
-import { type Href, router } from 'expo-router';
+import { type Href, router, useFocusEffect } from 'expo-router';
import { SettingsIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useCallback, useMemo, useState } from 'react';
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index d4702556..ab6d2204 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -5,12 +5,11 @@ import '../lib/i18n';
import { Env } from '@env';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { FloatingDevTools } from '@react-buoy/core';
-import { createNavigationContainerRef, DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import * as Sentry from '@sentry/react-native';
import { isRunningInExpoGo } from 'expo';
import { useFonts } from 'expo-font';
import * as Notifications from 'expo-notifications';
-import { Stack, useNavigationContainerRef } from 'expo-router';
+import { DarkTheme, DefaultTheme, Stack, ThemeProvider, useNavigationContainerRef } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import React, { useEffect } from 'react';
import { LogBox, Platform, useColorScheme } from 'react-native';
@@ -33,7 +32,6 @@ import { uuidv4 } from '@/lib/utils';
import { appInitializationService } from '@/services/app-initialization.service';
export { ErrorBoundary } from 'expo-router';
-export const navigationRef = createNavigationContainerRef();
export const unstable_settings = {
initialRouteName: '(app)',
diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx
index 59d5ebc1..89a753b7 100644
--- a/src/app/call/[id].tsx
+++ b/src/app/call/[id].tsx
@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next';
import { Alert, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
import WebView from 'react-native-webview';
+import { ActivityLinkLegend, ActivityLinkMarker } from '@/components/calls/activity-link-marker';
import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel';
import { VideoFeedsTab } from '@/components/callVideoFeeds/video-feeds-tab';
import { CheckInTab } from '@/components/checkIn/check-in-tab';
@@ -602,9 +603,12 @@ export default function CallDetail() {
{callExtraData.Activity.map((event, index) => (
-
- {event.StatusText}
-
+
+
+ {event.StatusText}
+
+
+
{event.Name} - {event.Group}
@@ -612,6 +616,7 @@ export default function CallDetail() {
{event.Note}
))}
+
) : (
{t('call_detail.no_timeline')}
diff --git a/src/app/call/[id].web.tsx b/src/app/call/[id].web.tsx
index 1f96cb9d..dd337145 100644
--- a/src/app/call/[id].web.tsx
+++ b/src/app/call/[id].web.tsx
@@ -26,6 +26,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native';
+import { ActivityLinkLegend, ActivityLinkMarker } from '@/components/calls/activity-link-marker';
import { CallSiteInfoTabPanel } from '@/components/calls/call-site-info-tab-panel';
import { VideoFeedsTab } from '@/components/callVideoFeeds/video-feeds-tab';
import { CheckInTab } from '@/components/checkIn/check-in-tab';
@@ -419,7 +420,10 @@ export default function CallDetailWeb() {
- {event.StatusText}
+
+ {event.StatusText}
+
+
{event.Name} - {event.Group}
@@ -428,6 +432,7 @@ export default function CallDetailWeb() {
))}
+
) : (
{t('call_detail.no_timeline')}
@@ -1107,10 +1112,16 @@ const styles = StyleSheet.create({
timelineContent: {
flex: 1,
},
+ timelineStatusRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ gap: 8,
+ marginBottom: 2,
+ },
timelineStatus: {
fontSize: 15,
fontWeight: '600',
- marginBottom: 2,
},
timelineInfo: {
fontSize: 14,
diff --git a/src/app/login/index.web.tsx b/src/app/login/index.web.tsx
index 62f6dfea..d2e7eacb 100644
--- a/src/app/login/index.web.tsx
+++ b/src/app/login/index.web.tsx
@@ -9,12 +9,11 @@ import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native';
import Animated, { FadeIn, FadeInDown, FadeInRight, FadeInUp, FadeOut, FadeOutLeft } from 'react-native-reanimated';
import * as z from 'zod';
-import { getSystemConfig } from '@/api/config';
import { Text } from '@/components/ui/text';
import { useAnalytics } from '@/hooks/use-analytics';
import { useAuth } from '@/lib/auth';
import { logger } from '@/lib/logging';
-import { buildApiUrl, CUSTOM_SERVER_VALUE, toBaseUrl, URL_PATTERN } from '@/lib/server-url';
+import { buildApiUrl, CUSTOM_SERVER_VALUE, findLocationByUrl, loadServerLocations, toBaseUrl, URL_PATTERN } from '@/lib/server-url';
import { type ResgridSystemLocation } from '@/models/v4/configs/getSystemConfigResultData';
import { useServerUrlStore } from '@/stores/app/server-url-store';
@@ -209,15 +208,8 @@ export default function LoginWeb() {
// can match it against a hosted site or show it for editing in the custom field.
const currentUrl = await getUrl();
const currentBaseUrl = toBaseUrl(currentUrl);
-
- let fetchedLocations: ResgridSystemLocation[] = [];
- try {
- const result = await getSystemConfig();
- fetchedLocations = result?.Data?.Locations ?? [];
- } catch (err) {
- // Best-effort: on failure the user can still enter a custom URL manually.
- logger.error({ message: 'Failed to load Resgrid hosted sites', context: { error: err } });
- }
+ // Always includes the Resgrid hosted sites, even if the current server can't be reached.
+ const fetchedLocations = await loadServerLocations();
if (isCancelled) {
return;
@@ -227,7 +219,7 @@ export default function LoginWeb() {
// Preselect the hosted site whose API URL matches the persisted URL; otherwise fall
// back to the Custom option and show the persisted URL so the user can edit it.
- const matchedLocation = fetchedLocations.find((location) => toBaseUrl(location.ApiUrl) === currentBaseUrl);
+ const matchedLocation = findLocationByUrl(fetchedLocations, currentUrl);
if (matchedLocation) {
setSelectedServer(matchedLocation.Name);
setServerUrlValue('url', toBaseUrl(matchedLocation.ApiUrl));
@@ -546,7 +538,7 @@ export default function LoginWeb() {
>
{serverLocations.map((location) => (
- {location.DisplayName || location.Name}
+ {location.Name}
))}
{t('settings.custom')}
diff --git a/src/app/personnel/[id].tsx b/src/app/personnel/[id].tsx
index 2039d7cf..e940e0cf 100644
--- a/src/app/personnel/[id].tsx
+++ b/src/app/personnel/[id].tsx
@@ -1,11 +1,13 @@
import { format } from 'date-fns';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { Building2, Circle, Clock, Mail, MapPin, Phone, Shield, User, Users } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native';
import { getPersonnelInfo } from '@/api/personnel/personnel';
+import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer';
import { Loading } from '@/components/common/loading';
import ZeroState from '@/components/common/zero-state';
import { Box } from '@/components/ui/box';
@@ -19,11 +21,15 @@ import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
+// The detail page only displays custom fields; nothing is edited here.
+const ignoreUdfValues = () => {};
+
export default function PersonnelDetail() {
const { id } = useLocalSearchParams();
const userId = Array.isArray(id) ? id[0] : id;
const router = useRouter();
const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
const { trackEvent } = useAnalytics();
const [person, setPerson] = useState(null);
@@ -321,14 +327,8 @@ export default function PersonnelDetail() {
{t('personnel.custom_fields')}
-
- {person.UdfValues.map((udf, index) => (
-
- {udf.UdfFieldId}
- {udf.Value || '—'}
-
- ))}
-
+ {/* Labels and option values come from the definition; the raw rows only carry field ids and stored keys. */}
+
) : null}
diff --git a/src/app/records/[id].tsx b/src/app/records/[id].tsx
index cdebc466..cb5eb1eb 100644
--- a/src/app/records/[id].tsx
+++ b/src/app/records/[id].tsx
@@ -42,27 +42,39 @@ export default function RecordScreen() {
const [attested, setAttested] = useState(false);
const [isBusy, setIsBusy] = useState(false);
const [message, setMessage] = useState(null);
+ // Edits made since the record was last loaded; they exist only here until they are saved.
+ const [dirty, setDirty] = useState(false);
- const load = useCallback(async () => {
+ /** Loads the record as the server holds it now, replacing any local edits. */
+ const fetchRecord = useCallback(async (): Promise => {
if (!id) {
- return;
+ return null;
}
- setIsBusy(true);
try {
const response = await getRecord(id);
const loaded = response?.Data ?? null;
setRecord(loaded);
setValues(toValueMap(loaded?.Values?.Cells));
+ setDirty(false);
if (loaded?.DefinitionKey) {
setSchema(await fetchSchema(loaded.DefinitionKey, loaded.DefinitionVersion));
}
+ return loaded;
} catch (error) {
logger.error({ message: 'Record load failed', context: { error, id } });
setMessage(t('records.load_failed'));
+ return null;
+ }
+ }, [id, fetchSchema, t]);
+
+ const load = useCallback(async () => {
+ setIsBusy(true);
+ try {
+ await fetchRecord();
} finally {
setIsBusy(false);
}
- }, [id, fetchSchema, t]);
+ }, [fetchRecord]);
useEffect(() => {
void load();
@@ -72,6 +84,39 @@ export default function RecordScreen() {
const editable = record ? record.State === RmsRecordState.Draft || record.State === RmsRecordState.Returned : false;
const entry = record?.DefinitionKey ? entryFor(record.DefinitionKey, record.DefinitionVersion) : null;
+ const handleChange = useCallback((next: ValueMap) => {
+ setValues(next);
+ setDirty(true);
+ }, []);
+
+ /** Sends the edited values and returns the record as the server now holds it, or null when it was not saved. */
+ const persist = useCallback(
+ async (current: RecordData): Promise => {
+ const clientRecordId = `edit-${current.RecordId}-${current.RowVersion}`;
+ const draft = {
+ clientRecordId,
+ recordId: current.RecordId,
+ definitionKey: current.DefinitionKey ?? '',
+ definitionVersion: current.DefinitionVersion,
+ name: current.RecordNumber ?? current.DraftReference ?? entry?.Name ?? t('records.untitled'),
+ values: toValueList(values),
+ rowVersion: current.RowVersion,
+ updatedOn: new Date().toISOString(),
+ };
+ stageDraft(draft);
+ // Passed directly as well: a definition that seals values is never staged on the device.
+ const result = await pushDraft(clientRecordId, draft);
+ if (!result.ok) {
+ // A conflict is shown, never resolved by overwriting: the person reloads and decides.
+ setMessage(result.conflict ? t(`records.conflict_${result.conflict.replace('-', '_')}`) : (result.error ?? t('records.save_failed')));
+ return null;
+ }
+ discardDraft(clientRecordId);
+ return fetchRecord();
+ },
+ [values, entry, stageDraft, pushDraft, discardDraft, fetchRecord, t]
+ );
+
const save = useCallback(async () => {
if (!record || !schema) {
return;
@@ -84,46 +129,42 @@ export default function RecordScreen() {
}
setIsBusy(true);
try {
- const clientRecordId = `edit-${record.RecordId}-${record.RowVersion}`;
- stageDraft({
- clientRecordId,
- recordId: record.RecordId,
- definitionKey: record.DefinitionKey ?? '',
- definitionVersion: record.DefinitionVersion,
- name: record.RecordNumber ?? record.DraftReference ?? entry?.Name ?? t('records.untitled'),
- values: toValueList(values),
- rowVersion: record.RowVersion,
- updatedOn: new Date().toISOString(),
- });
- const result = await pushDraft(clientRecordId);
- if (result.ok) {
- discardDraft(clientRecordId);
+ if (await persist(record)) {
setMessage(t('records.saved'));
- await load();
- return;
}
- // A conflict is shown, never resolved by overwriting: the person reloads and decides.
- setMessage(result.conflict ? t(`records.conflict_${result.conflict.replace('-', '_')}`) : (result.error ?? t('records.save_failed')));
} finally {
setIsBusy(false);
}
- }, [record, schema, values, entry, stageDraft, pushDraft, discardDraft, load, t]);
+ }, [record, schema, values, persist, t]);
const submit = useCallback(async () => {
if (!record) {
return;
}
+ if (dirty && schema) {
+ const found = validate(schema, values, false);
+ setIssues(found);
+ if (found.length > 0) {
+ setMessage(t('records.fix_fields'));
+ return;
+ }
+ }
setIsBusy(true);
try {
- const result = await submitForReview(record.RecordId, record.RowVersion);
+ // Unsaved edits go first; otherwise the server's older copy is what would be put up for review.
+ const current = dirty ? await persist(record) : record;
+ if (!current) {
+ return;
+ }
+ const result = await submitForReview(current.RecordId, current.RowVersion);
setMessage(result.ok ? t('records.submitted') : (result.error ?? t('records.save_failed')));
if (result.ok) {
- await load();
+ await fetchRecord();
}
} finally {
setIsBusy(false);
}
- }, [record, submitForReview, load, t]);
+ }, [record, schema, values, dirty, persist, submitForReview, fetchRecord, t]);
const complete = useCallback(async () => {
if (!record || !schema) {
@@ -137,15 +178,20 @@ export default function RecordScreen() {
}
setIsBusy(true);
try {
- const result = await finalize(record.RecordId, record.RowVersion, attested);
+ // The values just validated are the ones finalized, so unsaved edits are sent first.
+ const current = dirty ? await persist(record) : record;
+ if (!current) {
+ return;
+ }
+ const result = await finalize(current.RecordId, current.RowVersion, attested);
setMessage(result.ok ? t('records.finalized') : (result.error ?? t('records.save_failed')));
if (result.ok) {
- await load();
+ await fetchRecord();
}
} finally {
setIsBusy(false);
}
- }, [record, schema, values, attested, finalize, load, t]);
+ }, [record, schema, values, dirty, attested, persist, finalize, fetchRecord, t]);
if (!record) {
return (
@@ -176,7 +222,8 @@ export default function RecordScreen() {
{message ? {message} : null}
- {schema ? 0} forFinalize={false} onChange={setValues} /> : }
+ {/* Locked while a save is in flight: the reload that follows it replaces the values, so an edit made meanwhile would be lost. */}
+ {schema ? 0 || isBusy} forFinalize={false} onChange={handleChange} /> : }
{/* Attachments upload against a server-owned session, so an interruption resumes. */}
diff --git a/src/app/records/__tests__/[id].test.tsx b/src/app/records/__tests__/[id].test.tsx
new file mode 100644
index 00000000..ca4bc4d9
--- /dev/null
+++ b/src/app/records/__tests__/[id].test.tsx
@@ -0,0 +1,212 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+import { getRecord } from '@/api/records/records';
+import { RmsRecordState } from '@/models/v4/records';
+import { useRecordsStore } from '@/stores/records/store';
+
+import RecordScreen from '../[id]';
+
+interface MockChildrenProps {
+ children?: React.ReactNode;
+ testID?: string;
+}
+
+interface MockButtonProps extends MockChildrenProps {
+ onPress?: () => void;
+ isDisabled?: boolean;
+}
+
+interface MockCheckboxProps extends MockChildrenProps {
+ onChange?: (checked: boolean) => void;
+}
+
+interface MockRecordFormProps {
+ onChange: (values: Record) => void;
+ readOnly?: boolean;
+}
+
+jest.mock('expo-router', () => ({
+ Stack: { Screen: () => null },
+ useLocalSearchParams: () => ({ id: 'r1' }),
+ useRouter: () => ({ back: jest.fn() }),
+}));
+
+jest.mock('react-i18next', () => {
+ // Stable like the real one: the screen's loaders depend on it.
+ const t = (key: string) => key;
+ return { useTranslation: () => ({ t }) };
+});
+
+jest.mock('@/api/records/records', () => ({
+ getRecord: jest.fn(),
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() },
+}));
+
+jest.mock('@/lib/records/schema', () => ({
+ toValueList: jest.fn((values: Record) => Object.values(values)),
+ toValueMap: jest.fn(() => ({})),
+ unsupportedFieldKeys: jest.fn(() => []),
+ validate: jest.fn(() => []),
+}));
+
+jest.mock('@/stores/records/store', () => ({
+ useRecordsStore: jest.fn(),
+}));
+
+jest.mock('@/components/records/record-attachments', () => ({
+ RecordAttachments: () => null,
+}));
+
+jest.mock('@/components/records/record-form', () => {
+ const { TouchableOpacity } = require('react-native');
+ return {
+ RecordForm: ({ onChange, readOnly }: MockRecordFormProps) => (
+ onChange({ 'main:notes': { SectionKey: 'main', FieldKey: 'notes', Value: 'Edited' } })} />
+ ),
+ };
+});
+
+jest.mock('@/components/ui/button', () => {
+ const { Text, TouchableOpacity } = require('react-native');
+ return {
+ Button: ({ children, onPress, isDisabled, testID }: MockButtonProps) => (
+
+ {children}
+
+ ),
+ ButtonText: ({ children }: MockChildrenProps) => {children} ,
+ };
+});
+
+jest.mock('@/components/ui/checkbox', () => {
+ const { TouchableOpacity, View } = require('react-native');
+ return {
+ Checkbox: ({ children, onChange, testID }: MockCheckboxProps) => (
+ onChange?.(true)} testID={testID}>
+ {children}
+
+ ),
+ CheckboxIcon: () => null,
+ CheckboxIndicator: ({ children }: MockChildrenProps) => {children} ,
+ CheckboxLabel: () => null,
+ };
+});
+
+jest.mock('@/components/ui/badge', () => {
+ const { Text, View } = require('react-native');
+ return {
+ Badge: ({ children }: MockChildrenProps) => {children} ,
+ BadgeText: ({ children }: MockChildrenProps) => {children} ,
+ };
+});
+
+jest.mock('@/components/ui/box', () => {
+ const { View } = require('react-native');
+ return { Box: ({ children }: MockChildrenProps) => {children} };
+});
+
+jest.mock('@/components/ui/vstack', () => {
+ const { View } = require('react-native');
+ return { VStack: ({ children }: MockChildrenProps) => {children} };
+});
+
+jest.mock('@/components/ui/heading', () => {
+ const { Text } = require('react-native');
+ return { Heading: ({ children }: MockChildrenProps) => {children} };
+});
+
+jest.mock('@/components/ui/spinner', () => {
+ const { View } = require('react-native');
+ return { Spinner: () => };
+});
+
+jest.mock('@/components/ui/text', () => {
+ const { Text } = require('react-native');
+ return { Text };
+});
+
+const mockGetRecord = getRecord as jest.MockedFunction;
+const mockUseRecordsStore = useRecordsStore as unknown as jest.Mock;
+
+const record = (rowVersion: number) => ({
+ RecordId: 'r1',
+ DefinitionKey: 'shift-log',
+ DefinitionVersion: 3,
+ RecordNumber: 'SL-1',
+ State: RmsRecordState.Draft,
+ RowVersion: rowVersion,
+ Values: { Cells: [] },
+});
+
+const store = {
+ fetchSchema: jest.fn(),
+ stageDraft: jest.fn(),
+ pushDraft: jest.fn(),
+ discardDraft: jest.fn(),
+ submitForReview: jest.fn(),
+ finalize: jest.fn(),
+ entryFor: jest.fn(() => null),
+};
+
+describe('RecordScreen', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseRecordsStore.mockImplementation((selector: (state: typeof store) => unknown) => selector(store));
+ store.fetchSchema.mockResolvedValue({ Sections: [] });
+ store.pushDraft.mockResolvedValue({ ok: true, recordId: 'r1' });
+ store.submitForReview.mockResolvedValue({ ok: true });
+ store.finalize.mockResolvedValue({ ok: true });
+ mockGetRecord.mockResolvedValueOnce({ Data: record(4) } as never).mockResolvedValue({ Data: record(5) } as never);
+ });
+
+ it('submits the stored record as it is when nothing was edited', async () => {
+ const { unmount } = render( );
+ fireEvent.press(await screen.findByTestId('record-submit'));
+
+ await waitFor(() => expect(store.submitForReview).toHaveBeenCalledWith('r1', 4));
+ expect(store.pushDraft).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('saves unsaved edits before submitting, and submits the version that save produced', async () => {
+ const { unmount } = render( );
+ fireEvent.press(await screen.findByTestId('record-form-edit'));
+ fireEvent.press(screen.getByTestId('record-submit'));
+
+ await waitFor(() => expect(store.submitForReview).toHaveBeenCalledWith('r1', 5));
+ expect(store.pushDraft).toHaveBeenCalledWith('edit-r1-4', expect.objectContaining({ recordId: 'r1', rowVersion: 4, values: [expect.objectContaining({ Value: 'Edited' })] }));
+ expect(store.pushDraft.mock.invocationCallOrder[0]).toBeLessThan(store.submitForReview.mock.invocationCallOrder[0]);
+ unmount();
+ });
+
+ it('does not finalize when the unsaved edits could not be saved', async () => {
+ store.pushDraft.mockResolvedValue({ ok: false, conflict: 'etag' });
+
+ const { unmount } = render( );
+ fireEvent.press(await screen.findByTestId('record-form-edit'));
+ fireEvent.press(screen.getByTestId('record-attest'));
+ fireEvent.press(screen.getByTestId('record-finalize'));
+
+ expect(await screen.findByText('records.conflict_etag')).toBeTruthy();
+ expect(store.finalize).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('locks the form while a save is in flight, so an edit cannot be lost to the reload that follows', async () => {
+ let finishSave: (result: { ok: boolean; recordId: string }) => void = () => undefined;
+ store.pushDraft.mockReturnValueOnce(new Promise((resolve) => (finishSave = resolve)));
+ const { unmount } = render( );
+ fireEvent.press(await screen.findByTestId('record-form-edit'));
+
+ fireEvent.press(screen.getByTestId('record-save'));
+
+ await waitFor(() => expect(screen.getByTestId('record-form-edit').props.accessibilityState).toMatchObject({ disabled: true }));
+ finishSave({ ok: true, recordId: 'r1' });
+ await waitFor(() => expect(screen.getByTestId('record-form-edit').props.accessibilityState).toMatchObject({ disabled: false }));
+ unmount();
+ });
+});
diff --git a/src/app/records/new.tsx b/src/app/records/new.tsx
index ba50d834..32ac9c3b 100644
--- a/src/app/records/new.tsx
+++ b/src/app/records/new.tsx
@@ -148,9 +148,10 @@ export default function NewRecordScreen() {
setIsBusy(true);
try {
- // Staged first so an interrupted send leaves the work on the device rather than losing it.
+ // Staged first so an interrupted send leaves the work on the device rather than losing it. A
+ // definition that seals values is not staged, so the draft is handed to the send directly.
stageDraft(draft);
- const result = await pushDraft(draftId);
+ const result = await pushDraft(draftId, draft);
if (result.ok && result.recordId) {
discardDraft(draftId);
router.replace(`/records/${result.recordId}`);
diff --git a/src/app/units/[id].tsx b/src/app/units/[id].tsx
index dafa7676..123d4dc4 100644
--- a/src/app/units/[id].tsx
+++ b/src/app/units/[id].tsx
@@ -1,11 +1,13 @@
import { format } from 'date-fns';
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
import { Building2, Circle, Clock, MapPin, Shield, Truck, User } from 'lucide-react-native';
+import { useColorScheme } from 'nativewind';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView, StyleSheet, View } from 'react-native';
import { getUnitsInfos } from '@/api/units/units';
+import { UdfFieldsRenderer } from '@/components/calls/udf-fields-renderer';
import { Loading } from '@/components/common/loading';
import ZeroState from '@/components/common/zero-state';
import { Box } from '@/components/ui/box';
@@ -19,11 +21,15 @@ import { VStack } from '@/components/ui/vstack';
import { useAnalytics } from '@/hooks/use-analytics';
import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
+// The detail page only displays custom fields; nothing is edited here.
+const ignoreUdfValues = () => {};
+
export default function UnitDetail() {
const { id } = useLocalSearchParams();
const unitId = Array.isArray(id) ? id[0] : id;
const router = useRouter();
const { t } = useTranslation();
+ const { colorScheme } = useColorScheme();
const { trackEvent } = useAnalytics();
const [unit, setUnit] = useState(null);
@@ -289,14 +295,8 @@ export default function UnitDetail() {
{t('units.custom_fields')}
-
- {unit.UdfValues.map((udf, index) => (
-
- {udf.UdfFieldId}
- {udf.Value || '—'}
-
- ))}
-
+ {/* Labels and option values come from the definition; the raw rows only carry field ids and stored keys. */}
+
) : null}
diff --git a/src/components/callVideoFeeds/video-player.tsx b/src/components/callVideoFeeds/video-player.tsx
index ed3cb065..f4104144 100644
--- a/src/components/callVideoFeeds/video-player.tsx
+++ b/src/components/callVideoFeeds/video-player.tsx
@@ -232,7 +232,7 @@ const styles = StyleSheet.create({
flex: 1,
},
loadingOverlay: {
- ...StyleSheet.absoluteFillObject,
+ ...StyleSheet.absoluteFill,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1,
diff --git a/src/components/calls/__tests__/activity-link-marker.test.tsx b/src/components/calls/__tests__/activity-link-marker.test.tsx
new file mode 100644
index 00000000..8b86ed8a
--- /dev/null
+++ b/src/components/calls/__tests__/activity-link-marker.test.tsx
@@ -0,0 +1,60 @@
+import { render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData';
+
+import { ActivityLinkLegend, ActivityLinkMarker } from '../activity-link-marker';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+describe('ActivityLinkMarker', () => {
+ it.each([2, 3, 4])('renders the auto-linked marker with its explanation for source %p', (source) => {
+ render( );
+
+ const marker = screen.getByTestId('activity-link-marker-auto');
+ expect(screen.getByText('call_detail.activity_link.auto_linked')).toBeTruthy();
+ expect(marker.props.accessibilityLabel).toBe('call_detail.activity_link.auto_linked');
+ expect(marker.props.accessibilityHint).toBe('call_detail.activity_link.auto_linked_hint');
+ expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull();
+ });
+
+ it('renders the inferred marker with its explanation for source 5', () => {
+ render( );
+
+ const marker = screen.getByTestId('activity-link-marker-inferred');
+ expect(screen.getByText('call_detail.activity_link.inferred')).toBeTruthy();
+ expect(marker.props.accessibilityHint).toBe('call_detail.activity_link.inferred_hint');
+ expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull();
+ });
+
+ it.each([[1], [null], [undefined]])('renders nothing for source %p', (source) => {
+ const { toJSON } = render( );
+ expect(toJSON()).toBeNull();
+ });
+});
+
+describe('ActivityLinkLegend', () => {
+ const entry = (DestinationSource?: number | null) => ({ DestinationSource }) as DispatchedEventResultData;
+
+ it('renders nothing when no entry is marked', () => {
+ const { toJSON } = render( );
+ expect(toJSON()).toBeNull();
+ });
+
+ it('explains only the marker kinds present', () => {
+ render( );
+
+ expect(screen.getByTestId('activity-link-legend')).toBeTruthy();
+ expect(screen.getByText('call_detail.activity_link.auto_linked_hint')).toBeTruthy();
+ expect(screen.queryByText('call_detail.activity_link.inferred_hint')).toBeNull();
+ });
+
+ it('explains both kinds when both are present', () => {
+ render( );
+
+ expect(screen.getByText('call_detail.activity_link.auto_linked_hint')).toBeTruthy();
+ expect(screen.getByText('call_detail.activity_link.inferred_hint')).toBeTruthy();
+ });
+});
diff --git a/src/components/calls/__tests__/call-notes-modal-new.test.tsx b/src/components/calls/__tests__/call-notes-modal-new.test.tsx
index efd1c30c..7f2f8b04 100644
--- a/src/components/calls/__tests__/call-notes-modal-new.test.tsx
+++ b/src/components/calls/__tests__/call-notes-modal-new.test.tsx
@@ -11,7 +11,7 @@ jest.mock('@/lib/auth');
jest.mock('@/stores/calls/detail-store');
// Mock navigation
-jest.mock('@react-navigation/native', () => ({
+jest.mock('expo-router', () => ({
useIsFocused: () => true,
useNavigation: () => ({
navigate: jest.fn(),
diff --git a/src/components/calls/__tests__/udf-fields-renderer.test.tsx b/src/components/calls/__tests__/udf-fields-renderer.test.tsx
new file mode 100644
index 00000000..98a4a9e2
--- /dev/null
+++ b/src/components/calls/__tests__/udf-fields-renderer.test.tsx
@@ -0,0 +1,175 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+import { getUdfDefinition, getUdfValues } from '@/api/userDefinedFields/userDefinedFields';
+import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData';
+import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput';
+
+import { UdfFieldsRenderer } from '../udf-fields-renderer';
+
+jest.mock('@/api/userDefinedFields/userDefinedFields', () => ({
+ getUdfDefinition: jest.fn(),
+ getUdfValues: jest.fn(),
+}));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string) => fallback ?? key,
+ }),
+}));
+
+const mockGetUdfDefinition = getUdfDefinition as jest.MockedFunction;
+const mockGetUdfValues = getUdfValues as jest.MockedFunction;
+
+const rules = (options: { Key: string; Label: string }[]) => JSON.stringify({ MinLength: null, Options: options });
+
+const makeField = (overrides: Partial): UdfFieldResultData => ({
+ UdfFieldId: 'f1',
+ UdfDefinitionId: 'def-1',
+ Name: 'field',
+ Label: 'Field',
+ Description: '',
+ Placeholder: '',
+ FieldDataType: 0,
+ IsRequired: false,
+ IsReadOnly: false,
+ DefaultValue: '',
+ ValidationRules: '',
+ SortOrder: 0,
+ GroupName: '',
+ IsVisibleOnMobile: true,
+ IsVisibleOnReports: true,
+ IsEnabled: true,
+ Visibility: 0,
+ ...overrides,
+});
+
+const outcome = makeField({
+ UdfFieldId: 'outcome',
+ Name: 'outcome',
+ Label: 'Outcome',
+ FieldDataType: 11,
+ ValidationRules: rules([
+ { Key: 'tx', Label: 'Transported' },
+ { Key: 'refused', Label: 'Refused care' },
+ ]),
+});
+
+const disposition = makeField({
+ UdfFieldId: 'disposition',
+ Name: 'disposition',
+ Label: 'Disposition',
+ FieldDataType: 6,
+ SortOrder: 1,
+ ValidationRules: rules([
+ { Key: 'p1', Label: 'Priority 1' },
+ { Key: 'p2', Label: 'Priority 2' },
+ ]),
+});
+
+const setDefinition = (fields: UdfFieldResultData[]) => {
+ mockGetUdfDefinition.mockResolvedValue({ Data: { UdfDefinitionId: 'def-1', DepartmentId: 1, EntityType: 0, Version: 1, IsActive: true, Fields: fields } } as unknown as Awaited>);
+};
+
+const setValues = (values: { UdfFieldId: string; Value: string }[]) => {
+ mockGetUdfValues.mockResolvedValue({ Data: values } as unknown as Awaited>);
+};
+
+const lastSubmitted = (onValuesChange: jest.Mock): Record => {
+ const calls = onValuesChange.mock.calls;
+ const values = calls[calls.length - 1][0] as UdfFieldValueInput[];
+ return Object.fromEntries(values.map((v) => [v.UdfFieldId, v.Value]));
+};
+
+describe('UdfFieldsRenderer', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ setValues([]);
+ });
+
+ it('renders dropdown options by label and stores the key', async () => {
+ setDefinition([disposition]);
+ const onValuesChange = jest.fn();
+ const { unmount } = render( );
+
+ await waitFor(() => expect(screen.getByText('Priority 2')).toBeTruthy());
+ expect(screen.queryByText(/MinLength/)).toBeNull();
+
+ fireEvent.press(screen.getByText('Priority 2'));
+ expect(lastSubmitted(onValuesChange).disposition).toBe('p2');
+ unmount();
+ });
+
+ it('renders a combo box as a text input with suggestions', async () => {
+ setDefinition([outcome]);
+ const { unmount } = render( );
+
+ await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome')).toBeTruthy());
+ expect(screen.getByText('calls.udf_suggestions')).toBeTruthy();
+ expect(screen.getByText('Transported')).toBeTruthy();
+ expect(screen.getByText('Refused care')).toBeTruthy();
+ unmount();
+ });
+
+ it('stores a picked suggestion as its key and shows its label', async () => {
+ setDefinition([outcome]);
+ const onValuesChange = jest.fn();
+ const { unmount } = render( );
+
+ await waitFor(() => expect(screen.getByText('Refused care')).toBeTruthy());
+ fireEvent.press(screen.getByText('Refused care'));
+
+ expect(lastSubmitted(onValuesChange).outcome).toBe('refused');
+ expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Refused care');
+ unmount();
+ });
+
+ it('sends free text as typed and narrows the suggestions', async () => {
+ setDefinition([outcome]);
+ const onValuesChange = jest.fn();
+ const { unmount } = render( );
+
+ await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome')).toBeTruthy());
+ fireEvent.changeText(screen.getByTestId('udf-combo-input-outcome'), 'Ref');
+ expect(screen.queryByText('Transported')).toBeNull();
+ expect(screen.getByText('Refused care')).toBeTruthy();
+
+ fireEvent.changeText(screen.getByTestId('udf-combo-input-outcome'), 'Referred to crisis line');
+ expect(lastSubmitted(onValuesChange).outcome).toBe('Referred to crisis line');
+ expect(screen.queryByTestId('udf-combo-suggestions-outcome')).toBeNull();
+ unmount();
+ });
+
+ it('shows a stored combo key as its label', async () => {
+ setDefinition([outcome]);
+ setValues([{ UdfFieldId: 'outcome', Value: 'tx' }]);
+ const onValuesChange = jest.fn();
+ const { unmount } = render( );
+
+ await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Transported'));
+ expect(lastSubmitted(onValuesChange).outcome).toBe('tx');
+ unmount();
+ });
+
+ it('leaves a protected combo value untouched so the server keeps it', async () => {
+ setDefinition([outcome]);
+ setValues([{ UdfFieldId: 'outcome', Value: 'REDACTED' }]);
+ const onValuesChange = jest.fn();
+ const { unmount } = render( );
+
+ await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('REDACTED'));
+ expect(lastSubmitted(onValuesChange).outcome).toBe('REDACTED');
+ unmount();
+ });
+
+ it('shows no suggestions and blocks editing when read-only', async () => {
+ setDefinition([outcome]);
+ setValues([{ UdfFieldId: 'outcome', Value: 'refused' }]);
+ const { unmount } = render( );
+
+ await waitFor(() => expect(screen.getByTestId('udf-combo-input-outcome').props.value).toBe('Refused care'));
+ expect(screen.getByTestId('udf-combo-input-outcome').props.editable).toBe(false);
+ expect(screen.queryByTestId('udf-combo-suggestions-outcome')).toBeNull();
+ unmount();
+ });
+});
diff --git a/src/components/calls/activity-link-marker.tsx b/src/components/calls/activity-link-marker.tsx
new file mode 100644
index 00000000..c221f6fa
--- /dev/null
+++ b/src/components/calls/activity-link-marker.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Platform } from 'react-native';
+
+import { Badge, BadgeText } from '@/components/ui/badge';
+import { HStack } from '@/components/ui/hstack';
+import { Text } from '@/components/ui/text';
+import { VStack } from '@/components/ui/vstack';
+import { type ActivityLinkKind, getActivityLinkKind, getActivityLinkKinds } from '@/lib/activity-link';
+import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData';
+
+const MARKER_TEXT: Record = {
+ auto: { label: 'call_detail.activity_link.auto_linked', hint: 'call_detail.activity_link.auto_linked_hint', action: 'info' },
+ inferred: { label: 'call_detail.activity_link.inferred', hint: 'call_detail.activity_link.inferred_hint', action: 'warning' },
+};
+
+const MarkerBadge: React.FC<{ kind: ActivityLinkKind; decorative?: boolean }> = ({ kind, decorative = false }) => {
+ const { t } = useTranslation();
+ const { label, hint, action } = MARKER_TEXT[kind];
+ const labelText = t(label);
+ const hintText = t(hint);
+
+ if (decorative) {
+ return (
+
+ {labelText}
+
+ );
+ }
+
+ return (
+
+ {labelText}
+
+ );
+};
+
+/**
+ * Marks a call-activity status entry the server linked on the sender's behalf ("auto-linked") or attributed to
+ * the call at read time ("inferred"). Renders nothing for explicit entries, older rows and non-status entries.
+ */
+export const ActivityLinkMarker: React.FC<{ source?: number | null }> = React.memo(({ source }) => {
+ const kind = getActivityLinkKind(source);
+ return kind ? : null;
+});
+
+ActivityLinkMarker.displayName = 'ActivityLinkMarker';
+
+/** One line per marker kind present in the list, explaining it; renders nothing when no entry is marked. */
+export const ActivityLinkLegend: React.FC<{ activity?: DispatchedEventResultData[] | null }> = React.memo(({ activity }) => {
+ const { t } = useTranslation();
+ const kinds = React.useMemo(() => getActivityLinkKinds(activity), [activity]);
+ if (kinds.length === 0) return null;
+
+ return (
+
+ {kinds.map((kind) => {
+ const labelText = t(MARKER_TEXT[kind].label);
+ const hintText = t(MARKER_TEXT[kind].hint);
+ return (
+
+
+ {hintText}
+
+ );
+ })}
+
+ );
+});
+
+ActivityLinkLegend.displayName = 'ActivityLinkLegend';
diff --git a/src/components/calls/call-audio-modal.tsx b/src/components/calls/call-audio-modal.tsx
index 138df63a..a4d4e300 100644
--- a/src/components/calls/call-audio-modal.tsx
+++ b/src/components/calls/call-audio-modal.tsx
@@ -1,6 +1,6 @@
import type { BottomSheetBackdropProps } from '@gorhom/bottom-sheet';
import BottomSheet, { BottomSheetBackdrop, BottomSheetView } from '@gorhom/bottom-sheet';
-import { Audio, type AVPlaybackStatus } from 'expo-av';
+import { type AudioPlayer, type AudioStatus, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
import { Music, PauseIcon, PlayIcon, X } from 'lucide-react-native';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -33,7 +33,7 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose,
const { callAudio, isLoadingAudio, errorAudio, fetchCallAudio } = useCallDetailStore();
const [playingId, setPlayingId] = useState(null);
const [loadingId, setLoadingId] = useState(null);
- const soundRef = useRef(null);
+ const soundRef = useRef(null);
// Monotonic id bumped on every play/stop tap so overlapping async loads can tell
// whether they are still the latest request before committing state.
const playRequestRef = useRef(0);
@@ -44,9 +44,9 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose,
const unloadSound = useCallback(async () => {
if (soundRef.current) {
try {
- await soundRef.current.unloadAsync();
+ soundRef.current.remove();
} catch {
- // ignore — sound may already be unloaded
+ // ignore — player may already be released
}
soundRef.current = null;
}
@@ -108,22 +108,25 @@ export const CallAudioModal: React.FC = ({ isOpen, onClose,
try {
setLoadingId(file.Id);
await unloadSound();
- await Audio.setAudioModeAsync({ playsInSilentModeIOS: true, staysActiveInBackground: false });
- const { sound } = await Audio.Sound.createAsync({ uri: file.Url.trim() }, { shouldPlay: true, volume: 1.0 }, (status: AVPlaybackStatus) => {
- if (status.isLoaded && status.didJustFinish) {
- void unloadSound();
- }
- });
+ await setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: false });
- // A newer tap arrived while this clip was loading — discard this sound so it
- // can't overwrite soundRef.current or start overlapping playback.
+ // A newer tap arrived while the audio session was being configured — bail out so
+ // this clip can't overwrite soundRef.current or start overlapping playback.
if (requestId !== playRequestRef.current) {
- await sound.unloadAsync().catch(() => {});
return;
}
+ const sound = createAudioPlayer({ uri: file.Url.trim() });
+ sound.volume = 1.0;
+ sound.addListener('playbackStatusUpdate', (status: AudioStatus) => {
+ if (status.didJustFinish && soundRef.current === sound) {
+ void unloadSound();
+ }
+ });
+
soundRef.current = sound;
setPlayingId(file.Id);
+ sound.play();
} catch (error) {
logger.error({ message: 'Failed to play call audio', context: { error, callId } });
} finally {
diff --git a/src/components/calls/udf-fields-renderer.tsx b/src/components/calls/udf-fields-renderer.tsx
index fb339fa5..2055a962 100644
--- a/src/components/calls/udf-fields-renderer.tsx
+++ b/src/components/calls/udf-fields-renderer.tsx
@@ -1,28 +1,16 @@
-import { useCallback, useEffect, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { ActivityIndicator, StyleSheet, TextInput, View } from 'react-native';
+import { ActivityIndicator, type StyleProp, StyleSheet, TextInput, type TextStyle, View } from 'react-native';
import { getUdfDefinition, getUdfValues } from '@/api/userDefinedFields/userDefinedFields';
+import { comboDisplayText, filterComboSuggestions, findComboOption, parseUdfOptions, selectedOptionKeys, UDF_FIELD_TYPE, type UdfOption } from '@/lib/udf/options';
import { type UdfFieldResultData } from '@/models/v4/userDefinedFields/udfFieldResultData';
import { type UdfFieldValueInput } from '@/models/v4/userDefinedFields/udfFieldValueInput';
import { type UdfFieldValueResultData } from '@/models/v4/userDefinedFields/udfFieldValueResultData';
import { Text } from '../ui/text';
-// FieldDataType enum
-const UDF_FIELD_TYPE = {
- Text: 0,
- Number: 1,
- Decimal: 2,
- Boolean: 3,
- Date: 4,
- DateTime: 5,
- Dropdown: 6,
- MultiSelect: 7,
- Email: 8,
- Phone: 9,
- Url: 10,
-} as const;
+const EMPTY_OPTIONS: UdfOption[] = [];
interface UdfFieldsRendererProps {
entityType: number;
@@ -117,6 +105,15 @@ export const UdfFieldsRenderer: React.FC = ({ entityType
[fields, notifyChange]
);
+ // Parsed once per definition so option lists keep their identity across renders.
+ const optionsByField = useMemo(() => {
+ const map: Record = {};
+ fields.forEach((f) => {
+ map[f.UdfFieldId] = parseUdfOptions(f.ValidationRules);
+ });
+ return map;
+ }, [fields]);
+
if (isLoading) {
return (
@@ -154,7 +151,7 @@ export const UdfFieldsRenderer: React.FC = ({ entityType
case UDF_FIELD_TYPE.Dropdown:
case UDF_FIELD_TYPE.MultiSelect: {
- const opts = field.Options && field.Options.length > 0 ? field.Options : field.ValidationRules ? field.ValidationRules.split(',').map((o) => o.trim()) : [];
+ const opts = optionsByField[field.UdfFieldId] ?? EMPTY_OPTIONS;
return (
@@ -175,6 +172,29 @@ export const UdfFieldsRenderer: React.FC = ({ entityType
);
}
+ case UDF_FIELD_TYPE.ComboBox:
+ return (
+
+
+ {label}
+ {field.IsRequired ? * : null}
+
+ {field.Description ? {field.Description} : null}
+
+
+ );
+
case UDF_FIELD_TYPE.Number:
case UDF_FIELD_TYPE.Decimal:
return (
@@ -339,7 +359,7 @@ const BooleanToggle: React.FC = ({ value, onChange, disabled
};
interface OptionSelectorProps {
- options: string[];
+ options: UdfOption[];
value: string;
multiSelect: boolean;
onChange: (v: string) => void;
@@ -348,37 +368,38 @@ interface OptionSelectorProps {
placeholder?: string;
}
+// Chips show each option's label and store its key, the way the server stores a dropdown/multi-select value.
const OptionSelector: React.FC = ({ options, value, multiSelect, onChange, disabled = false, isDark = false }) => {
- const selectedValues = value ? value.split(',').map((v) => v.trim()) : [];
+ const selectedKeys = selectedOptionKeys(multiSelect ? UDF_FIELD_TYPE.MultiSelect : UDF_FIELD_TYPE.Dropdown, value);
- const toggle = (opt: string) => {
+ const toggle = (key: string) => {
if (disabled) return;
if (multiSelect) {
- const next = selectedValues.includes(opt) ? selectedValues.filter((v) => v !== opt) : [...selectedValues, opt];
+ const next = selectedKeys.includes(key) ? selectedKeys.filter((k) => k !== key) : [...selectedKeys, key];
onChange(next.join(','));
} else {
- onChange(selectedValues.includes(opt) ? '' : opt);
+ onChange(selectedKeys.includes(key) ? '' : key);
}
};
return (
{options.map((opt) => {
- const isSelected = selectedValues.includes(opt);
+ const isSelected = selectedKeys.includes(opt.key);
return (
toggle(opt)}
+ onPress={() => toggle(opt.key)}
accessibilityRole="button"
- accessibilityState={{ selected: isSelected }}
+ accessibilityState={{ selected: isSelected, disabled }}
>
- {opt}
+ {opt.label}
);
})}
@@ -386,6 +407,95 @@ const OptionSelector: React.FC = ({ options, value, multiSe
);
};
+interface ComboBoxInputProps {
+ fieldId: string;
+ options: UdfOption[];
+ value: string;
+ onChange: (fieldId: string, value: string) => void;
+ disabled?: boolean;
+ isDark?: boolean;
+ label: string;
+ placeholder?: string;
+ inputStyle: StyleProp;
+ captionStyle: StyleProp;
+}
+
+// A text input with the field's options offered as suggestions. Picking a suggestion shows its label and stores its
+// key; anything typed is sent as typed and the server stores a typed label as that option's key.
+const ComboBoxInput: React.FC = ({ fieldId, options, value, onChange, disabled = false, isDark = false, label, placeholder, inputStyle, captionStyle }) => {
+ const { t } = useTranslation();
+ // Held locally so typing a string that happens to equal an option key does not flip the input to that label mid-word.
+ const [text, setText] = useState(() => comboDisplayText(options, value));
+ const suggestions = useMemo(() => filterComboSuggestions(options, text), [options, text]);
+ const selectedKey = findComboOption(options, text)?.key;
+
+ const handleChangeText = useCallback(
+ (next: string) => {
+ setText(next);
+ onChange(fieldId, next);
+ },
+ [fieldId, onChange]
+ );
+
+ const handleSelect = useCallback(
+ (option: UdfOption) => {
+ if (disabled) return;
+ setText(option.label);
+ onChange(fieldId, option.key);
+ },
+ [disabled, fieldId, onChange]
+ );
+
+ return (
+
+
+ {!disabled && suggestions.length > 0 ? (
+
+ {t('calls.udf_suggestions')}
+
+ {suggestions.map((option) => (
+
+ ))}
+
+
+ ) : null}
+
+ );
+};
+
+interface ComboSuggestionProps {
+ option: UdfOption;
+ isSelected: boolean;
+ onSelect: (option: UdfOption) => void;
+ isDark: boolean;
+}
+
+const ComboSuggestion: React.FC = React.memo(({ option, isSelected, onSelect, isDark }) => {
+ const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
+ return (
+
+ {option.label}
+
+ );
+});
+
+ComboSuggestion.displayName = 'ComboSuggestion';
+
const styles = StyleSheet.create({
container: {
width: '100%',
@@ -530,4 +640,7 @@ const styles = StyleSheet.create({
optionDisabled: {
opacity: 0.5,
},
+ suggestionsBlock: {
+ marginTop: 8,
+ },
});
diff --git a/src/components/contacts/__tests__/contact-details-extra.test.tsx b/src/components/contacts/__tests__/contact-details-extra.test.tsx
new file mode 100644
index 00000000..c52d2990
--- /dev/null
+++ b/src/components/contacts/__tests__/contact-details-extra.test.tsx
@@ -0,0 +1,68 @@
+import { render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData';
+
+import { ContactDetailsExtra } from '../contact-details-extra';
+
+jest.mock('@/stores/contacts/store', () => ({
+ useContactsStore: {
+ getState: () => ({
+ fetchContactNotes: jest.fn().mockResolvedValue(undefined),
+ fetchContactDetails: jest.fn().mockResolvedValue(undefined),
+ }),
+ },
+}));
+
+jest.mock('@/components/data-protection/protected-reveal-bar', () => ({
+ ProtectedRevealBar: () => null,
+}));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+const makeContact = (customFields: ContactResultData['CustomFields']): ContactResultData =>
+ ({
+ ContactId: 'contact-1',
+ ContactType: ContactType.Person,
+ Name: 'Jane Doe',
+ CustomFields: customFields,
+ }) as ContactResultData;
+
+describe('ContactDetailsExtra custom fields', () => {
+ it('shows the display value the server resolved for option fields', () => {
+ const contact = makeContact([
+ { UdfFieldId: 'outcome', Label: 'Outcome', Value: 'tx', DisplayValue: 'Transported', FieldDataType: 11, GroupName: null, SortOrder: 0 },
+ { UdfFieldId: 'priority', Label: 'Priority', Value: 'p1', DisplayValue: 'Priority 1', FieldDataType: 6, GroupName: 'Triage', SortOrder: 1 },
+ ]);
+
+ const { unmount } = render( );
+
+ expect(screen.getByText('Transported')).toBeTruthy();
+ expect(screen.getByText('Priority 1')).toBeTruthy();
+ expect(screen.getByText('Triage · Priority')).toBeTruthy();
+ expect(screen.queryByText('tx')).toBeNull();
+ unmount();
+ });
+
+ it('falls back to the stored value when the server sends no display value', () => {
+ const contact = makeContact([{ UdfFieldId: 'notes', Label: 'Notes', Value: 'Referred to crisis line', FieldDataType: 11, SortOrder: 0 }]);
+
+ const { unmount } = render( );
+
+ expect(screen.getByText('Referred to crisis line')).toBeTruthy();
+ unmount();
+ });
+
+ it('hides custom fields when the host renders its own', () => {
+ const contact = makeContact([{ UdfFieldId: 'outcome', Label: 'Outcome', Value: 'tx', DisplayValue: 'Transported', FieldDataType: 11, SortOrder: 0 }]);
+
+ const { unmount } = render( );
+
+ expect(screen.queryByTestId('contact-custom-fields')).toBeNull();
+ unmount();
+ });
+});
diff --git a/src/components/contacts/__tests__/contact-details-sheet.test.tsx b/src/components/contacts/__tests__/contact-details-sheet.test.tsx
index 2a699b55..0f1a7ae2 100644
--- a/src/components/contacts/__tests__/contact-details-sheet.test.tsx
+++ b/src/components/contacts/__tests__/contact-details-sheet.test.tsx
@@ -1,3 +1,4 @@
+jest.mock('../contact-details-extra', () => ({ ContactDetailsExtra: () => null }));
import { render, fireEvent } from '@testing-library/react-native';
import React from 'react';
diff --git a/src/components/contacts/__tests__/contact-files-list.test.tsx b/src/components/contacts/__tests__/contact-files-list.test.tsx
index 2ca56ec4..e25194cb 100644
--- a/src/components/contacts/__tests__/contact-files-list.test.tsx
+++ b/src/components/contacts/__tests__/contact-files-list.test.tsx
@@ -1,8 +1,14 @@
import { describe, expect, it, jest } from '@jest/globals';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+import { Alert, Platform } from 'react-native';
-import { safeFileName } from '../contact-files-list';
+import { getContactFileBase64 } from '@/api/contacts/contactFiles';
+import { type ContactFileResultData } from '@/models/v4/contactFiles/contactFilesResult';
-jest.mock('@/hooks/use-analytics');
+import { ContactFilesList, safeFileName } from '../contact-files-list';
+
+jest.mock('@/hooks/use-analytics', () => ({ useAnalytics: () => ({ trackEvent: jest.fn() }) }));
jest.mock('@/api/contacts/contactFiles', () => ({ getContactFileBase64: jest.fn() }));
jest.mock('expo-file-system/legacy', () => ({ documentDirectory: 'file:///documents/', EncodingType: { Base64: 'base64' }, writeAsStringAsync: jest.fn() }));
jest.mock('expo-sharing', () => ({ isAvailableAsync: jest.fn(), shareAsync: jest.fn() }));
@@ -40,3 +46,28 @@ describe('safeFileName', () => {
expect(safeFileName(' ', 'contact_file_1')).toBe('contact_file_1');
});
});
+
+describe('ContactFilesList download failure', () => {
+ const file = { Id: 'f-1', ContactId: 'c-1', Type: 1, TypeName: 'Plan', Name: 'Floor plan', FileName: 'plan.pdf', Mime: 'application/pdf', Size: 2048, Timestamp: '', IsProtected: false } as ContactFileResultData;
+
+ it('tells the dispatcher in the browser, where Alert.alert does nothing', async () => {
+ jest.replaceProperty(Platform, 'OS', 'web');
+ const browserAlert = jest.fn();
+ const nativeAlert = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
+ const previousAlert = window.alert;
+ window.alert = browserAlert;
+ jest.mocked(getContactFileBase64).mockRejectedValue(new Error('offline'));
+
+ try {
+ render( );
+ fireEvent.press(screen.getByTestId('contact-file-download-f-1'));
+
+ await waitFor(() => expect(browserAlert).toHaveBeenCalledWith('contacts.files.download_failed'));
+ expect(nativeAlert).not.toHaveBeenCalled();
+ } finally {
+ window.alert = previousAlert;
+ nativeAlert.mockRestore();
+ jest.restoreAllMocks();
+ }
+ });
+});
diff --git a/src/components/contacts/contact-details-extra.tsx b/src/components/contacts/contact-details-extra.tsx
new file mode 100644
index 00000000..af6f1db0
--- /dev/null
+++ b/src/components/contacts/contact-details-extra.tsx
@@ -0,0 +1,120 @@
+import { LockIcon } from 'lucide-react-native';
+import React, { useCallback, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Linking, View } from 'react-native';
+
+import { ProtectedRevealBar } from '@/components/data-protection/protected-reveal-bar';
+import { HStack } from '@/components/ui/hstack';
+import { HomeIcon, MapPinIcon, SettingsIcon } from '@/components/ui/lucide-icons';
+import { Pressable } from '@/components/ui/pressable';
+import { Text } from '@/components/ui/text';
+import { VStack } from '@/components/ui/vstack';
+import { mapsUrls } from '@/lib/contacts/format';
+import { logger } from '@/lib/logging';
+import { type ContactAddressData, type ContactResultData } from '@/models/v4/contacts/contactResultData';
+import { useContactsStore } from '@/stores/contacts/store';
+
+interface ContactDetailsExtraProps {
+ contact: ContactResultData & { WithheldFields?: string[] };
+ /** Dispatch renders custom fields through its own UDF renderer. */
+ showCustomFields?: boolean;
+}
+
+const openMaps = async (query: string) => {
+ const { app, web } = mapsUrls(query);
+ try {
+ if (app && (await Linking.canOpenURL(app))) {
+ await Linking.openURL(app);
+ return;
+ }
+ await Linking.openURL(web);
+ } catch (error) {
+ logger.warn({ message: 'Could not open maps for contact address', context: { error } });
+ }
+};
+
+const AddressRow = ({ label, address }: { label: string; address: ContactAddressData }) => {
+ const line = address.Formatted || [address.Address1, address.City, address.State, address.PostalCode, address.Country].filter(Boolean).join(', ');
+ if (!line) return null;
+ return (
+ void openMaps(line)} accessibilityRole="link" accessibilityLabel={`${label}, ${line}`} testID="contact-address">
+
+
+
+
+
+ {label}
+ {line}
+
+
+
+ );
+};
+
+// What the slim contact list never carried, for every field app's detail sheet: the resolved physical and
+// mailing addresses (tap to open in maps), the category, the department's mobile-visible custom fields, and
+// — for a protected department — which details were withheld with the reveal that re-reads them.
+export const ContactDetailsExtra: React.FC = ({ contact, showCustomFields = true }) => {
+ const { t } = useTranslation();
+ const contactId = contact.ContactId;
+
+ // Opening a contact reads its notes fresh: a cached copy may predate an unlock and still be redacted.
+ useEffect(() => {
+ void useContactsStore.getState().fetchContactNotes(contactId, true);
+ }, [contactId]);
+
+ const refresh = useCallback(async () => {
+ const store = useContactsStore.getState();
+ await Promise.all([store.fetchContactDetails(contactId), store.fetchContactNotes(contactId, true)]);
+ }, [contactId]);
+
+ const withheld = contact.WithheldFields ?? [];
+ const category = contact.CategoryName || contact.Category?.Name;
+ const customFields = showCustomFields ? (contact.CustomFields ?? []) : [];
+
+ return (
+
+ {contact.IsProtected || withheld.length > 0 ? (
+
+ {withheld.length > 0 ? (
+
+
+ {t('contacts.withheld', { count: withheld.length })}
+
+ ) : null}
+
+
+ ) : null}
+ {category ? (
+
+
+ {t('contacts.categoryLabel', { name: category })}
+
+ ) : null}
+ {contact.PhysicalAddress ? : null}
+ {contact.MailingAddress ? : null}
+ {contact.LocationGeofence ? (
+
+
+
+
+ {t('contacts.hasGeofence')}
+
+ ) : null}
+ {customFields.length > 0 ? (
+
+
+
+ {t('contacts.customFields')}
+
+ {customFields.map((field) => (
+
+ {field.GroupName ? `${field.GroupName} · ${field.Label}` : field.Label}
+ {field.DisplayValue || field.Value}
+
+ ))}
+
+ ) : null}
+
+ );
+};
diff --git a/src/components/contacts/contact-details-sheet.tsx b/src/components/contacts/contact-details-sheet.tsx
index 4febaeac..e98aa67a 100644
--- a/src/components/contacts/contact-details-sheet.tsx
+++ b/src/components/contacts/contact-details-sheet.tsx
@@ -23,6 +23,7 @@ import {
X,
} from '@/components/ui/lucide-icons';
import { useAnalytics } from '@/hooks/use-analytics';
+import { htmlToText, withoutRedacted } from '@/lib/contacts/format';
import { ContactType } from '@/models/v4/contacts/contactResultData';
import { useContactsStore } from '@/stores/contacts/store';
@@ -33,6 +34,7 @@ import { HStack } from '../ui/hstack';
import { Pressable } from '../ui/pressable';
import { Text } from '../ui/text';
import { VStack } from '../ui/vstack';
+import { ContactDetailsExtra } from './contact-details-extra';
import { ContactFilesPanel } from './contact-files-panel';
import { ContactNotesList } from './contact-notes-list';
import { ContactPreplanPanel } from './contact-preplan-panel';
@@ -91,13 +93,17 @@ export const ContactDetailsSheet: React.FC = () => {
const { trackEvent } = useAnalytics();
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
- const { contacts, selectedContactId, isDetailsOpen, closeDetails } = useContactsStore();
+ const { contacts, selectedContactId, selectedContactDetails, isDetailsOpen, closeDetails } = useContactsStore();
const [activeTab, setActiveTab] = useState<'details' | 'notes' | 'preplan' | 'files'>('details');
const selectedContact = React.useMemo(() => {
if (!selectedContactId) return null;
- return contacts.find((contact) => contact.ContactId === selectedContactId);
- }, [contacts, selectedContactId]);
+ // Prefer the full GetContactById record — the list payload is slim (no address, custom fields or
+ // category) — and fall back to the list row while it loads. Withheld values are removed so the
+ // sheet never shows the REDACTED sentinel as data or offers to dial it.
+ const record = selectedContactDetails?.ContactId === selectedContactId ? selectedContactDetails : contacts.find((contact) => contact.ContactId === selectedContactId);
+ return record ? withoutRedacted(record) : null;
+ }, [contacts, selectedContactId, selectedContactDetails]);
// Track when contact details sheet is opened/rendered
React.useEffect(() => {
@@ -203,7 +209,9 @@ export const ContactDetailsSheet: React.FC = () => {
{selectedContact.ContactType === ContactType.Person ? t('contacts.person') : t('contacts.company')}
{selectedContact.OtherName ? ({selectedContact.OtherName}) : null}
- {selectedContact.Category?.Name ? {selectedContact.Category.Name} : null}
+ {selectedContact.CategoryName || selectedContact.Category?.Name ? (
+ {selectedContact.CategoryName || selectedContact.Category?.Name}
+ ) : null}
@@ -235,6 +243,7 @@ export const ContactDetailsSheet: React.FC = () => {
{activeTab === 'details' ? (
+
{/* Contact Information Section */}
{hasContactInfo ? (
}>
@@ -302,9 +311,9 @@ export const ContactDetailsSheet: React.FC = () => {
{hasAdditionalInfo ? (
} defaultExpanded={false}>
-
-
-
+
+
+
) : null}
diff --git a/src/components/contacts/contact-files-list.tsx b/src/components/contacts/contact-files-list.tsx
index 49aab520..104d824f 100644
--- a/src/components/contacts/contact-files-list.tsx
+++ b/src/components/contacts/contact-files-list.tsx
@@ -3,7 +3,7 @@ import * as Sharing from 'expo-sharing';
import { DownloadIcon, FileIcon, LockIcon, PaperclipIcon } from 'lucide-react-native';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Alert } from 'react-native';
+import { Alert, Platform } from 'react-native';
import { getContactFileBase64 } from '@/api/contacts/contactFiles';
import { ProtectedText } from '@/components/data-protection/protected-text';
@@ -73,6 +73,28 @@ export const ContactFilesList: React.FC = ({ files, isLoa
const base64 = await getContactFileBase64(file);
const fallbackName = `contact_file_${file.Id}`;
const fileName = safeFileName(isFieldRedacted(file.RedactedFields, FileFieldIds.fileName, file.FileName) ? null : file.FileName, fallbackName);
+
+ if (Platform.OS === 'web') {
+ // The dispatch console runs in a browser: there is no document directory or share sheet, so the
+ // bytes become a browser download. The object URL is always revoked, even when the click throws.
+ let objectUrl: string | null = null;
+ let link: HTMLAnchorElement | null = null;
+ try {
+ const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
+ objectUrl = URL.createObjectURL(new Blob([bytes], { type: file.Mime || 'application/octet-stream' }));
+ link = document.createElement('a');
+ link.href = objectUrl;
+ link.download = fileName;
+ document.body.appendChild(link);
+ link.click();
+ } finally {
+ link?.remove();
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ }
+ trackEvent('contact_file_download_completed', { contextId: contextId ?? '', fileId: file.Id, wasShared: false });
+ return;
+ }
+
const fileUri = `${FileSystem.documentDirectory}${fileName}`;
await FileSystem.writeAsStringAsync(fileUri, base64, { encoding: FileSystem.EncodingType.Base64 });
@@ -85,7 +107,12 @@ export const ContactFilesList: React.FC = ({ files, isLoa
}
} catch (error) {
logger.error({ message: 'Failed to download contact file', context: { error, fileId: file.Id } });
- Alert.alert(t('contacts.files.download_failed'));
+ if (Platform.OS === 'web') {
+ // React Native Web's Alert.alert does nothing, so the browser reports the failure itself.
+ window.alert(t('contacts.files.download_failed'));
+ } else {
+ Alert.alert(t('contacts.files.download_failed'));
+ }
} finally {
setDownloading((prev) => {
const next = { ...prev };
diff --git a/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx b/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx
new file mode 100644
index 00000000..db3b96f5
--- /dev/null
+++ b/src/components/dispatch-console/__tests__/activity-log-panel-actions.test.tsx
@@ -0,0 +1,114 @@
+import { act, fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
+import { useDashboardViewStore } from '@/stores/dispatch/dashboard-view-store';
+import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store';
+
+import { ActivityLogPanel } from '../activity-log-panel';
+
+jest.mock('expo-router', () => ({ router: { push: jest.fn() } }));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/stores/dispatch/dashboard-view-store', () => {
+ const { create } = require('zustand');
+ const useDashboardViewStore = create((set: any) => ({
+ collapsedCards: {},
+ setCardCollapsed: (card: string, collapsed: boolean) => set((state: any) => ({ collapsedCards: { ...state.collapsedCards, [card]: collapsed } })),
+ }));
+ return { useDashboardViewStore, selectCardCollapsed: (card: string) => (state: any) => state.collapsedCards[card] ?? false };
+});
+
+jest.mock('@/stores/checkIn/store', () => ({
+ useCheckInStore: (selector: (state: unknown) => unknown) => selector({ timerStatuses: [], isLoadingStatuses: false }),
+}));
+
+jest.mock('@/stores/weatherAlerts/store', () => ({
+ useWeatherAlertsStore: (selector: (state: unknown) => unknown) => selector({ alerts: [], settings: null }),
+}));
+
+jest.mock('@/hooks/use-check-in-timer-polling', () => ({ useCheckInTimerPolling: jest.fn() }));
+jest.mock('@/components/checkIn/check-in-bottom-sheet', () => ({ CheckInBottomSheet: () => null }));
+jest.mock('@/components/checkIn/check-in-timer-card', () => ({ CheckInTimerCard: () => null }));
+jest.mock('@/components/ui/icon', () => ({ Icon: () => null }));
+
+jest.mock('../panel-header', () => ({
+ PanelHeader: ({ title }: { title: string }) => {
+ const { Text } = require('react-native');
+ return {title} ;
+ },
+}));
+
+// The actions panels themselves are covered by their own tests; here only which one is shown matters
+jest.mock('../unit-actions-panel', () => ({
+ UnitActionsPanel: ({ unit }: { unit: UnitInfoResultData }) => {
+ const { Text } = require('react-native');
+ return {`unit-actions:${unit.UnitId}`} ;
+ },
+}));
+
+jest.mock('../personnel-actions-panel', () => ({
+ PersonnelActionsPanel: () => null,
+}));
+
+const call = { CallId: '777', Number: '26-777', Name: 'MVA', State: 0 } as CallResultData;
+const engine = { UnitId: 'e1', Name: 'Engine 1' } as UnitInfoResultData;
+
+const baseProps = { entries: [], isLoading: false, isCallFilterActive: true, selectedCallId: '777' };
+
+describe('ActivityLogPanel actions for "+" set-status-for-call', () => {
+ beforeEach(() => {
+ useUnitActionsStore.getState().reset();
+ useDashboardViewStore.setState({ collapsedCards: {} } as any);
+ });
+
+ it('keeps the call context when the unit selection arrives after "+" opened the panel', () => {
+ const view = render( );
+
+ // What the "+" handler does: open the store with the call context, then select the unit
+ act(() => {
+ useUnitActionsStore.getState().openActions(engine, { callContext: call });
+ });
+ view.rerender( );
+
+ expect(useUnitActionsStore.getState().callContext).toBe(call);
+ expect(useUnitActionsStore.getState().statusSelectedCall).toBe(call);
+ expect(screen.getByText('unit-actions:e1')).toBeTruthy();
+ });
+
+ it('surfaces the actions tab (and expands the card) when "+" re-targets the already-selected unit', () => {
+ const view = render( );
+ expect(screen.getByText('unit-actions:e1')).toBeTruthy();
+ const session = useUnitActionsStore.getState().actionsSessionId;
+
+ // Dispatcher goes back to the activity tab, then presses "+" on the same unit
+ fireEvent.press(screen.getByText('dispatch.activity'));
+ expect(screen.queryByText('unit-actions:e1')).toBeNull();
+ act(() => {
+ useDashboardViewStore.getState().setCardCollapsed('activity-log', true);
+ });
+ act(() => {
+ useUnitActionsStore.getState().openActions(engine, { callContext: call });
+ });
+ view.rerender( );
+
+ expect(useUnitActionsStore.getState().actionsSessionId).toBe(session + 1);
+ expect(useDashboardViewStore.getState().collapsedCards['activity-log']).toBe(false);
+ expect(screen.getByText('unit-actions:e1')).toBeTruthy();
+ });
+
+ it('still opens the unit actions (without a call context) on a plain unit selection', () => {
+ const view = render( );
+ view.rerender( );
+
+ const state = useUnitActionsStore.getState();
+ expect(state.isActionsOpen).toBe(true);
+ expect(state.selectedUnit).toBe(engine);
+ expect(state.callContext).toBeNull();
+ expect(screen.getByText('unit-actions:e1')).toBeTruthy();
+ });
+});
diff --git a/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx b/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx
new file mode 100644
index 00000000..38115a75
--- /dev/null
+++ b/src/components/dispatch-console/__tests__/activity-log-panel-link-markers.test.tsx
@@ -0,0 +1,89 @@
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import React from 'react';
+
+import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData';
+
+import { ActivityLogPanel } from '../activity-log-panel';
+
+jest.mock('expo-router', () => ({ router: { push: jest.fn() } }));
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+jest.mock('@/stores/dispatch/dashboard-view-store', () => ({
+ useDashboardViewStore: (selector: (state: unknown) => unknown) => selector({ collapsedCards: {}, setCardCollapsed: jest.fn() }),
+ selectCardCollapsed: () => () => false,
+}));
+
+jest.mock('@/stores/checkIn/store', () => ({
+ useCheckInStore: (selector: (state: unknown) => unknown) => selector({ timerStatuses: [], isLoadingStatuses: false }),
+}));
+
+jest.mock('@/stores/weatherAlerts/store', () => ({
+ useWeatherAlertsStore: (selector: (state: unknown) => unknown) => selector({ alerts: [], settings: null }),
+}));
+
+jest.mock('@/hooks/use-check-in-timer-polling', () => ({ useCheckInTimerPolling: jest.fn() }));
+jest.mock('@/components/checkIn/check-in-bottom-sheet', () => ({ CheckInBottomSheet: () => null }));
+jest.mock('@/components/checkIn/check-in-timer-card', () => ({ CheckInTimerCard: () => null }));
+jest.mock('@/components/ui/icon', () => ({ Icon: () => null }));
+jest.mock('../panel-header', () => ({ PanelHeader: () => null }));
+jest.mock('../unit-actions-panel', () => ({ UnitActionsPanel: () => null }));
+jest.mock('../personnel-actions-panel', () => ({ PersonnelActionsPanel: () => null }));
+
+const activity = (Id: string, StatusText: string, DestinationSource?: number | null): DispatchedEventResultData => ({
+ Id,
+ Timestamp: '2026-09-23 10:00',
+ Type: 'Unit',
+ Name: `Engine ${Id}`,
+ GroupId: '1',
+ Group: 'Station 1',
+ Note: '',
+ StatusId: 2,
+ Location: '',
+ StatusText,
+ StatusColor: '#3b82f6',
+ DestinationSource,
+});
+
+const renderPanel = (callActivity: DispatchedEventResultData[]) => {
+ const view = render( );
+ // Selecting a call jumps the panel to its actions tab; the call's activity lives on the activity tab
+ fireEvent.press(screen.getByText('dispatch.activity'));
+ return view;
+};
+
+describe('ActivityLogPanel call activity link markers', () => {
+ it.each([2, 3, 4])('marks an entry auto-linked for source %p', (source) => {
+ renderPanel([activity('1', 'Responding', source)]);
+
+ expect(screen.getByText('Responding')).toBeTruthy();
+ expect(screen.getByTestId('activity-link-marker-auto')).toBeTruthy();
+ expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull();
+ expect(screen.getByTestId('activity-link-legend')).toBeTruthy();
+ });
+
+ it('marks an entry inferred for source 5', () => {
+ renderPanel([activity('1', 'On Scene', 5)]);
+
+ expect(screen.getByTestId('activity-link-marker-inferred')).toBeTruthy();
+ expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull();
+ });
+
+ it.each([[1], [null], [undefined]])('shows no marker or legend for source %p', (source) => {
+ renderPanel([activity('1', 'Available', source)]);
+
+ expect(screen.getByText('Available')).toBeTruthy();
+ expect(screen.queryByTestId('activity-link-marker-auto')).toBeNull();
+ expect(screen.queryByTestId('activity-link-marker-inferred')).toBeNull();
+ expect(screen.queryByTestId('activity-link-legend')).toBeNull();
+ });
+
+ it('marks only the entries the server linked or inferred in a mixed list', () => {
+ renderPanel([activity('1', 'Dispatched', 1), activity('2', 'Responding', 3), activity('3', 'On Scene', 5), activity('4', 'Available', null)]);
+
+ expect(screen.getAllByTestId('activity-link-marker-auto')).toHaveLength(1);
+ expect(screen.getAllByTestId('activity-link-marker-inferred')).toHaveLength(1);
+ });
+});
diff --git a/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx b/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx
new file mode 100644
index 00000000..da4f6629
--- /dev/null
+++ b/src/components/dispatch-console/__tests__/personnel-actions-panel.test.tsx
@@ -0,0 +1,218 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+import { getAllGroups } from '@/api/groups/groups';
+import { getPois } from '@/api/mapping/mapping';
+import { savePersonsStatuses } from '@/api/personnel/personnelStatuses';
+import { getAllPersonnelStaffings, getAllPersonnelStatuses } from '@/api/satuses';
+import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers';
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
+import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
+import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store';
+import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store';
+
+import { PersonnelActionsPanel } from '../personnel-actions-panel';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string) => (typeof fallback === 'string' ? fallback : key),
+ }),
+}));
+
+jest.mock('@/api/groups/groups', () => ({ getAllGroups: jest.fn() }));
+jest.mock('@/api/mapping/mapping', () => ({ getPois: jest.fn() }));
+jest.mock('@/api/satuses', () => ({ getAllPersonnelStatuses: jest.fn(), getAllPersonnelStaffings: jest.fn() }));
+jest.mock('@/api/personnel/personnelStatuses', () => ({ savePersonsStatuses: jest.fn() }));
+jest.mock('@/api/personnel/personnelStaffing', () => ({ savePersonsStaffings: jest.fn() }));
+
+jest.mock('@/components/calls/udf-fields-renderer', () => ({
+ UdfFieldsRenderer: () => null,
+}));
+
+const mockCalls: CallResultData[] = [
+ { CallId: 'A', Number: '26-A', Name: 'Structure Fire', State: 0 } as CallResultData,
+ { CallId: 'B', Number: '26-B', Name: 'Medical Aid', State: 0 } as CallResultData,
+];
+const mockFetchCalls = jest.fn();
+jest.mock('@/stores/calls/store', () => ({
+ useCallsStore: (selector: (state: { calls: CallResultData[]; fetchCalls: () => void }) => unknown) => selector({ calls: mockCalls, fetchCalls: mockFetchCalls }),
+}));
+
+jest.mock('@/components/ui/actionsheet', () => {
+ const { View } = require('react-native');
+ return {
+ Actionsheet: ({ children, isOpen }: any) => (isOpen ? {children} : null),
+ ActionsheetBackdrop: () => null,
+ ActionsheetContent: ({ children }: any) => {children} ,
+ ActionsheetDragIndicator: () => null,
+ ActionsheetDragIndicatorWrapper: ({ children }: any) => children,
+ };
+});
+
+jest.mock('@/components/ui/button', () => {
+ const { Text, TouchableOpacity } = require('react-native');
+ return {
+ Button: ({ children, onPress, isDisabled }: any) => (
+
+ {children}
+
+ ),
+ ButtonText: ({ children }: any) => {children} ,
+ ButtonSpinner: () => null,
+ };
+});
+
+jest.mock('@/components/ui/icon', () => ({
+ Icon: () => null,
+}));
+
+jest.mock('@/components/ui/spinner', () => ({
+ Spinner: () => null,
+}));
+
+const mockSavePersonsStatuses = savePersonsStatuses as jest.MockedFunction;
+
+const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Type: 1, StateId: 0, Text, BColor: '#123456', Color: '', Gps: false, Note: 0, Detail }) as StatusesResultData;
+const statuses = [status(1, 'Not Responding', CustomStateDetailType.None), status(2, 'Responding', CustomStateDetailType.CallsAndStations), status(3, 'On Scene', CustomStateDetailType.Calls)];
+
+const person = (UserId: string, StatusDestinationId = ''): PersonnelInfoResultData => ({ UserId, FirstName: 'Pat', LastName: UserId, GroupName: 'Station 1', StatusDestinationId }) as PersonnelInfoResultData;
+
+const selectedCallInStore = () => usePersonnelActionsStore.getState().statusSelectedCall?.CallId ?? null;
+
+const pickStatus = async (text: string) => {
+ fireEvent.press(screen.getByText('dispatch.personnel_actions.select_status'));
+ fireEvent.press(await screen.findByText(text));
+};
+
+describe('PersonnelActionsPanel destination defaults', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (getAllPersonnelStatuses as jest.Mock).mockResolvedValue({ Data: statuses });
+ (getAllPersonnelStaffings as jest.Mock).mockResolvedValue({ Data: [] });
+ (getAllGroups as jest.Mock).mockResolvedValue({ Data: [] });
+ (getPois as jest.Mock).mockResolvedValue({ Data: [] });
+ mockSavePersonsStatuses.mockResolvedValue({} as any);
+ usePersonnelActionsStore.getState().reset();
+ useDispatchConsoleStore.setState({ selectedCallId: null, isCallFilterActive: false });
+ });
+
+ it("uses the console's selected call and keeps it sticky across submits", async () => {
+ useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true });
+ const target = person('p1');
+ act(() => {
+ usePersonnelActionsStore.getState().openActions(target);
+ });
+ render( );
+ await waitFor(() => expect(usePersonnelActionsStore.getState().isLoadingOptions).toBe(false));
+ await waitFor(() => expect(selectedCallInStore()).toBe('B'));
+
+ await pickStatus('Responding');
+ expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy();
+ fireEvent.press(screen.getByTestId('update-button'));
+ await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(1));
+ expect(mockSavePersonsStatuses.mock.calls[0][0]).toMatchObject({ UserIds: ['p1'], Type: '2', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call });
+
+ await waitFor(() => expect(usePersonnelActionsStore.getState().selectedStatus).toBeNull());
+ await pickStatus('On Scene');
+ fireEvent.press(screen.getByTestId('update-button'));
+ await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(2));
+ expect(mockSavePersonsStatuses.mock.calls[1][0]).toMatchObject({ Type: '3', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call });
+ });
+
+ it("prefers the person's current destination call and re-initialises for the next person", async () => {
+ useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true });
+ const first = person('p1', 'A');
+ const second = person('p2');
+ act(() => {
+ usePersonnelActionsStore.getState().openActions(first);
+ });
+ const view = render( );
+ await waitFor(() => expect(selectedCallInStore()).toBe('A'));
+
+ view.rerender( );
+ act(() => {
+ usePersonnelActionsStore.getState().openActions(second);
+ });
+ await waitFor(() => expect(selectedCallInStore()).toBe('B'));
+ });
+
+ it("waits for the next person's options before settling on their station", async () => {
+ // The first person's load fails, so their panel finishes loading with no stations at all.
+ (getAllGroups as jest.Mock).mockRejectedValueOnce(new Error('offline')).mockResolvedValue({ Data: [{ GroupId: 'g-5', Name: 'Station 5', GroupType: 'Station' }] });
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+ const first = person('p1');
+ const second = person('p2', 'g-5');
+ act(() => {
+ usePersonnelActionsStore.getState().openActions(first);
+ });
+ const view = render( );
+ await waitFor(() => expect(usePersonnelActionsStore.getState().destinationInitializedSessionId).toBe(usePersonnelActionsStore.getState().actionsSessionId));
+
+ view.rerender( );
+ act(() => {
+ usePersonnelActionsStore.getState().openActions(second);
+ });
+
+ await waitFor(() => expect(usePersonnelActionsStore.getState().statusSelectedStation?.GroupId).toBe('g-5'));
+ consoleError.mockRestore();
+ });
+
+ describe('destination sheet after a status that needs a destination', () => {
+ const openPanelFor = async (target: PersonnelInfoResultData) => {
+ act(() => {
+ usePersonnelActionsStore.getState().openActions(target);
+ });
+ const view = render( );
+ await waitFor(() => expect(usePersonnelActionsStore.getState().destinationInitializedSessionId).toBe(usePersonnelActionsStore.getState().actionsSessionId));
+ return view;
+ };
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('opens a beat after the status is chosen', async () => {
+ await openPanelFor(person('p1'));
+ jest.useFakeTimers();
+
+ await pickStatus('Responding');
+ act(() => {
+ jest.advanceTimersByTime(300);
+ });
+
+ expect(mockFetchCalls).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not open for the next person when the dispatcher switches within that beat', async () => {
+ const view = await openPanelFor(person('p1'));
+ jest.useFakeTimers();
+
+ await pickStatus('Responding');
+ const second = person('p2');
+ view.rerender( );
+ act(() => {
+ usePersonnelActionsStore.getState().openActions(second);
+ });
+ act(() => {
+ jest.advanceTimersByTime(300);
+ });
+
+ expect(mockFetchCalls).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not send a leftover call with a status that does not support calls', async () => {
+ const target = person('p1', 'A');
+ act(() => {
+ usePersonnelActionsStore.getState().openActions(target);
+ });
+ render( );
+ await waitFor(() => expect(selectedCallInStore()).toBe('A'));
+
+ await pickStatus('Not Responding');
+ fireEvent.press(screen.getByTestId('update-button'));
+ await waitFor(() => expect(mockSavePersonsStatuses).toHaveBeenCalledTimes(1));
+ expect(mockSavePersonsStatuses.mock.calls[0][0]).toMatchObject({ Type: '1', RespondingTo: '', RespondingToType: null });
+ });
+});
diff --git a/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx b/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx
new file mode 100644
index 00000000..05cb6fde
--- /dev/null
+++ b/src/components/dispatch-console/__tests__/unit-actions-panel.test.tsx
@@ -0,0 +1,221 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+import { getSetUnitStatusData } from '@/api/dispatch/dispatch';
+import { saveUnitStatus } from '@/api/units/unitStatuses';
+import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers';
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
+import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
+import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store';
+import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store';
+
+import { UnitActionsPanel } from '../unit-actions-panel';
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, fallback?: string) => (typeof fallback === 'string' ? fallback : key),
+ }),
+}));
+
+jest.mock('@/api/dispatch/dispatch', () => ({
+ getSetUnitStatusData: jest.fn(),
+}));
+
+jest.mock('@/api/units/unitStatuses', () => ({
+ saveUnitStatus: jest.fn(),
+}));
+
+jest.mock('@/components/calls/udf-fields-renderer', () => ({
+ UdfFieldsRenderer: () => null,
+}));
+
+// Calls store: a stable array so the panel's memoised active-call list does not churn between renders
+const mockCalls: CallResultData[] = [
+ { CallId: 'A', Number: '26-A', Name: 'Structure Fire', State: 0 } as CallResultData,
+ { CallId: 'B', Number: '26-B', Name: 'Medical Aid', State: 0 } as CallResultData,
+ { CallId: 'CLOSED', Number: '26-C', Name: 'Old Call', State: 4 } as CallResultData,
+];
+const mockFetchCalls = jest.fn();
+jest.mock('@/stores/calls/store', () => ({
+ useCallsStore: (selector: (state: { calls: CallResultData[]; fetchCalls: () => void }) => unknown) => selector({ calls: mockCalls, fetchCalls: mockFetchCalls }),
+}));
+
+jest.mock('@/stores/units/store', () => ({
+ useUnitsStore: {
+ getState: () => ({ unitStatuses: [], fetchUnits: jest.fn() }),
+ },
+}));
+
+jest.mock('@/components/ui/actionsheet', () => {
+ const { View } = require('react-native');
+ return {
+ Actionsheet: ({ children, isOpen }: any) => (isOpen ? {children} : null),
+ ActionsheetBackdrop: () => null,
+ ActionsheetContent: ({ children }: any) => {children} ,
+ ActionsheetDragIndicator: () => null,
+ ActionsheetDragIndicatorWrapper: ({ children }: any) => children,
+ };
+});
+
+jest.mock('@/components/ui/button', () => {
+ const { Text, TouchableOpacity } = require('react-native');
+ return {
+ Button: ({ children, onPress, isDisabled }: any) => (
+
+ {children}
+
+ ),
+ ButtonText: ({ children }: any) => {children} ,
+ ButtonSpinner: () => null,
+ };
+});
+
+jest.mock('@/components/ui/icon', () => ({
+ Icon: () => null,
+}));
+
+jest.mock('@/components/ui/spinner', () => ({
+ Spinner: () => null,
+}));
+
+const mockGetSetUnitStatusData = getSetUnitStatusData as jest.MockedFunction;
+const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction;
+
+const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Type: 3, StateId: 0, Text, BColor: '#123456', Color: '', Gps: false, Note: 0, Detail }) as StatusesResultData;
+const statuses = [status(0, 'Available', CustomStateDetailType.Stations), status(3, 'Responding', CustomStateDetailType.Calls), status(6, 'On Scene', CustomStateDetailType.Calls), status(8, 'Out of Service', CustomStateDetailType.None)];
+
+const unit = (UnitId: string, CurrentDestinationId = ''): UnitInfoResultData => ({ UnitId, Name: `Unit ${UnitId}`, Type: 'Engine', CustomStatusSetId: '', CurrentDestinationId }) as UnitInfoResultData;
+
+const selectedCallInStore = () => useUnitActionsStore.getState().statusSelectedCall?.CallId ?? null;
+
+const renderOpenPanel = async (target: UnitInfoResultData) => {
+ act(() => {
+ useUnitActionsStore.getState().openActions(target);
+ });
+ const view = render( );
+ await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false));
+ return view;
+};
+
+const pickStatus = async (text: string) => {
+ fireEvent.press(screen.getByText('dispatch.unit_actions_panel.select_status'));
+ fireEvent.press(await screen.findByText(text));
+};
+
+describe('UnitActionsPanel destination defaults', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetSetUnitStatusData.mockResolvedValue({ Data: { Statuses: statuses, Calls: [], Stations: [], DestinationPois: [] } } as any);
+ mockSaveUnitStatus.mockResolvedValue({} as any);
+ useUnitActionsStore.getState().reset();
+ useDispatchConsoleStore.setState({ selectedCallId: null, isCallFilterActive: false });
+ });
+
+ it("defaults to the unit's current destination when it is an active call", async () => {
+ useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true });
+ await renderOpenPanel(unit('u1', 'A'));
+
+ await waitFor(() => expect(selectedCallInStore()).toBe('A'));
+ });
+
+ it("falls back to the console's selected call when the unit's destination is not an active call", async () => {
+ useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true });
+ await renderOpenPanel(unit('u1', 'CLOSED'));
+
+ await waitFor(() => expect(selectedCallInStore()).toBe('B'));
+
+ // The panel shows the defaulted call once a status that accepts calls is picked
+ await pickStatus('Responding');
+ expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy();
+ });
+
+ it('defaults to no destination when neither the unit destination nor a selected call is active', async () => {
+ await renderOpenPanel(unit('u1', 'CLOSED'));
+
+ await waitFor(() => expect(useUnitActionsStore.getState().destinationInitializedSessionId).toBe(useUnitActionsStore.getState().actionsSessionId));
+ expect(useUnitActionsStore.getState().statusDestinationType).toBe('none');
+ });
+
+ it('shows no destination for a status that does not support the defaulted call', async () => {
+ useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true });
+ await renderOpenPanel(unit('u1'));
+ await waitFor(() => expect(selectedCallInStore()).toBe('B'));
+
+ await pickStatus('Available');
+ await waitFor(() => expect(screen.getAllByText('dispatch.unit_actions_panel.no_destination').length).toBeGreaterThan(0));
+ expect(screen.queryByText('#26-B - Medical Aid')).toBeNull();
+ });
+
+ it('keeps the destination after a successful submit and does not re-apply the default', async () => {
+ await renderOpenPanel(unit('u1', 'A'));
+ await waitFor(() => expect(selectedCallInStore()).toBe('A'));
+
+ await pickStatus('Responding');
+ fireEvent.press(screen.getByTestId('update-status-button'));
+ await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1));
+ expect(mockSaveUnitStatus.mock.calls[0][0]).toMatchObject({ Id: 'u1', Type: '3', RespondingTo: 'A', RespondingToType: DestinationEntityType.Call });
+
+ // Follow-up status for the same unit still goes to call A
+ await waitFor(() => expect(useUnitActionsStore.getState().selectedStatus).toBeNull());
+ expect(selectedCallInStore()).toBe('A');
+ await pickStatus('On Scene');
+ expect(screen.getByText('#26-A - Structure Fire')).toBeTruthy();
+ fireEvent.press(screen.getByTestId('update-status-button'));
+ await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(2));
+ expect(mockSaveUnitStatus.mock.calls[1][0]).toMatchObject({ Id: 'u1', Type: '6', RespondingTo: 'A', RespondingToType: DestinationEntityType.Call });
+ });
+
+ it('re-initialises the default destination when the selected unit changes', async () => {
+ useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true });
+ const first = unit('u1', 'A');
+ const second = unit('u2');
+ const view = await renderOpenPanel(first);
+ await waitFor(() => expect(selectedCallInStore()).toBe('A'));
+
+ // The dashboard re-renders the panel with the new unit before its parent re-opens the store for it
+ view.rerender( );
+ act(() => {
+ useUnitActionsStore.getState().openActions(second);
+ });
+
+ await waitFor(() => expect(selectedCallInStore()).toBe('B'));
+ expect(useUnitActionsStore.getState().selectedUnit?.UnitId).toBe('u2');
+ });
+
+ it('does not re-apply the default when the panel remounts within the same open session', async () => {
+ useDispatchConsoleStore.setState({ selectedCallId: 'B', isCallFilterActive: true });
+ const target = unit('u1', 'A');
+ const view = await renderOpenPanel(target);
+ await waitFor(() => expect(selectedCallInStore()).toBe('A'));
+
+ // The dispatcher changes the destination, then switches tabs away and back (unmount/remount)
+ act(() => {
+ useUnitActionsStore.getState().setStatusDestinationType('none');
+ });
+ view.unmount();
+ render( );
+ await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false));
+
+ expect(useUnitActionsStore.getState().statusDestinationType).toBe('none');
+ });
+
+ it('shows and sends the explicit call context even for a status whose Detail lists no destinations', async () => {
+ const target = unit('u1', 'A');
+ act(() => {
+ useUnitActionsStore.getState().openActions(target, { callContext: mockCalls[1] });
+ });
+ render( );
+ await waitFor(() => expect(useUnitActionsStore.getState().isLoadingOptions).toBe(false));
+
+ // The explicit context wins over the unit's current destination
+ expect(selectedCallInStore()).toBe('B');
+
+ await pickStatus('Out of Service');
+ expect(screen.getByText('#26-B - Medical Aid')).toBeTruthy();
+
+ fireEvent.press(screen.getByTestId('update-status-button'));
+ await waitFor(() => expect(mockSaveUnitStatus).toHaveBeenCalledTimes(1));
+ expect(mockSaveUnitStatus.mock.calls[0][0]).toMatchObject({ Id: 'u1', Type: '8', RespondingTo: 'B', RespondingToType: DestinationEntityType.Call });
+ });
+});
diff --git a/src/components/dispatch-console/activity-log-panel.tsx b/src/components/dispatch-console/activity-log-panel.tsx
index 1f8b032b..46e69ac0 100644
--- a/src/components/dispatch-console/activity-log-panel.tsx
+++ b/src/components/dispatch-console/activity-log-panel.tsx
@@ -24,6 +24,7 @@ import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-st
import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store';
import { useWeatherAlertsStore } from '@/stores/weatherAlerts/store';
+import { ActivityLinkLegend, ActivityLinkMarker } from '../calls/activity-link-marker';
import { CheckInBottomSheet } from '../checkIn/check-in-bottom-sheet';
import { CheckInTimerCard } from '../checkIn/check-in-timer-card';
import { PanelHeader } from './panel-header';
@@ -188,6 +189,7 @@ const CallActivityItem: React.FC<{ activity: DispatchedEventResultData }> = Reac
) : null}
+
{activity.Note ? (
{activity.Note}
@@ -332,6 +334,12 @@ const ActivityLogPanelComponent: React.FC = ({
// Track previous call selection to avoid unnecessary tab switches
const prevSelectedCallIdRef = useRef(undefined);
+ // Actions-panel open sessions — a "+" set-status-for-call open (with a call context) must surface the
+ // actions tab even when the unit/person was already selected and the selection itself did not change.
+ const unitActionsSessionId = useUnitActionsStore((s) => s.actionsSessionId);
+ const personnelActionsSessionId = usePersonnelActionsStore((s) => s.actionsSessionId);
+ const prevActionsSessionIdsRef = useRef({ unit: unitActionsSessionId, personnel: personnelActionsSessionId });
+
// Reset to activity tab if check-ins tab is active but call filter is cleared
useEffect(() => {
if (!isCallFilterActive && activeTab === 'checkins') {
@@ -364,7 +372,11 @@ const ActivityLogPanelComponent: React.FC = ({
prevSelectedPersonnelIdRef.current = currentId;
if (selectedPersonnel && currentId) {
- openPersonnelActions(selectedPersonnel);
+ // Don't re-open (and wipe) a panel already opened for this person, e.g. by "+" with a call context
+ const personnelActions = usePersonnelActionsStore.getState();
+ if (!personnelActions.isActionsOpen || personnelActions.selectedPersonnel?.UserId !== currentId) {
+ openPersonnelActions(selectedPersonnel);
+ }
setActiveTab('actions'); // Automatically switch to actions tab
} else if (prevId && !currentId) {
// Only close if we had a selection before and now we don't
@@ -383,7 +395,11 @@ const ActivityLogPanelComponent: React.FC = ({
prevSelectedUnitIdRef.current = currentId;
if (selectedUnit && currentId) {
- openUnitActions(selectedUnit);
+ // Don't re-open (and wipe) a panel already opened for this unit, e.g. by "+" with a call context
+ const unitActions = useUnitActionsStore.getState();
+ if (!unitActions.isActionsOpen || unitActions.selectedUnit?.UnitId !== currentId) {
+ openUnitActions(selectedUnit);
+ }
setActiveTab('actions'); // Automatically switch to actions tab
} else if (prevId && !currentId) {
// Only close if we had a selection before and now we don't
@@ -392,6 +408,21 @@ const ActivityLogPanelComponent: React.FC = ({
}
}, [selectedUnit, selectedUnitId, openUnitActions, closeUnitActions]);
+ // Surface the actions panel when it is opened from an explicit call context ("+" set-status-for-call)
+ useEffect(() => {
+ const prev = prevActionsSessionIdsRef.current;
+ if (prev.unit === unitActionsSessionId && prev.personnel === personnelActionsSessionId) return;
+ prevActionsSessionIdsRef.current = { unit: unitActionsSessionId, personnel: personnelActionsSessionId };
+
+ const openedForCall = (prev.unit !== unitActionsSessionId && !!useUnitActionsStore.getState().callContext) || (prev.personnel !== personnelActionsSessionId && !!usePersonnelActionsStore.getState().callContext);
+ if (!openedForCall) return;
+
+ setActiveTab('actions');
+ if (isCollapsed) {
+ setCardCollapsed('activity-log', false);
+ }
+ }, [unitActionsSessionId, personnelActionsSessionId, isCollapsed, setCardCollapsed]);
+
// Filter entries when call filter is active
const filteredEntries = useMemo(
() => (isCallFilterActive && selectedCallId ? entries.filter((entry) => entry.metadata?.callId === selectedCallId || entry.type === 'system') : entries),
@@ -496,6 +527,7 @@ const ActivityLogPanelComponent: React.FC = ({
maxToRenderPerBatch={15}
windowSize={7}
removeClippedSubviews={Platform.OS !== 'web'}
+ ListFooterComponent={ }
/>
);
}
diff --git a/src/components/dispatch-console/personnel-actions-panel.tsx b/src/components/dispatch-console/personnel-actions-panel.tsx
index 2208d1f9..036a1d1b 100644
--- a/src/components/dispatch-console/personnel-actions-panel.tsx
+++ b/src/components/dispatch-console/personnel-actions-panel.tsx
@@ -15,7 +15,7 @@ import { Icon } from '@/components/ui/icon';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
-import { type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities } from '@/lib/destination-helpers';
+import { type DestinationTab, getDefaultDestinationTab, getEffectiveDestinationType, getStatusDestinationCapabilities, resolveDefaultDestinationCall } from '@/lib/destination-helpers';
import { getPoiSelectionLabel } from '@/lib/poi-display';
import { invertColor, isCallActive } from '@/lib/utils';
import { type CallResultData } from '@/models/v4/calls/callResultData';
@@ -24,6 +24,7 @@ import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
import { useCallsStore } from '@/stores/calls/store';
+import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store';
import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store';
interface PersonnelActionsPanelProps {
@@ -115,6 +116,8 @@ export const PersonnelActionsPanel: React.FC = ({ pe
const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false);
const [isStaffingSheetOpen, setIsStaffingSheetOpen] = useState(false);
const [isDestinationSheetOpen, setIsDestinationSheetOpen] = useState(false);
+ // The destination sheet opens a beat after the status sheet closes.
+ const destinationSheetTimerRef = useRef | null>(null);
const [destinationTab, setDestinationTab] = useState('calls');
const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false);
@@ -122,9 +125,19 @@ export const PersonnelActionsPanel: React.FC = ({ pe
const [localSelectedStatus, setLocalSelectedStatus] = useState(null);
const [localSelectedStaffing, setLocalSelectedStaffing] = useState(null);
+ // The console's selected call — a default destination when the person's own destination is not an active call
+ const selectedCallId = useDispatchConsoleStore((state) => state.selectedCallId);
+
+ // Whether the destination options (stations/POIs) have finished loading for the given person
+ const [optionsLoadedForUserId, setOptionsLoadedForUserId] = useState(null);
+
// Store state
const {
selectedPersonnel: storeSelectedPersonnel,
+ callContext,
+ actionsSessionId,
+ destinationInitializedSessionId,
+ markDestinationInitialized,
selectedStatus: storeSelectedStatus,
statusDestinationType,
statusSelectedCall,
@@ -194,12 +207,30 @@ export const PersonnelActionsPanel: React.FC = ({ pe
// Use prop if available, fallback to store
const selectedPersonnel = personnelProp ?? storeSelectedPersonnel;
+ // A pending open belongs to the session it was chosen in: it dies with the panel, and with a switch to
+ // another person or a new session, so it never opens the sheet over what the dispatcher is now doing.
+ useEffect(
+ () => () => {
+ if (destinationSheetTimerRef.current) {
+ clearTimeout(destinationSheetTimerRef.current);
+ destinationSheetTimerRef.current = null;
+ }
+ },
+ [actionsSessionId, selectedPersonnel?.UserId]
+ );
+
// Load options when panel opens
useEffect(() => {
+ if (!selectedPersonnel) return;
+
+ let cancelled = false;
+
const loadOptions = async () => {
setIsLoadingOptions(true);
try {
const [statusesResult, staffingsResult, groupsResult, poisResult] = await Promise.all([getAllPersonnelStatuses(), getAllPersonnelStaffings(), getAllGroups(), getPois({ destinationOnly: true })]);
+ // A newer load superseded this one (or the panel unmounted) — don't apply stale options.
+ if (cancelled) return;
if (statusesResult?.Data) {
setAvailableStatuses(statusesResult.Data);
@@ -217,77 +248,82 @@ export const PersonnelActionsPanel: React.FC = ({ pe
} catch (error) {
console.error('Failed to load personnel action options:', error);
} finally {
- setIsLoadingOptions(false);
+ if (!cancelled) {
+ setIsLoadingOptions(false);
+ setOptionsLoadedForUserId(selectedPersonnel.UserId);
+ }
}
};
- if (selectedPersonnel) {
- loadOptions();
- }
+ loadOptions();
+
+ return () => {
+ cancelled = true;
+ };
}, [selectedPersonnel, setAvailableStatuses, setAvailableStaffings, setAvailableStations, setAvailablePois, setIsLoadingOptions]);
+ // Active calls for destination selection and default-destination resolution
+ const activeCalls = useMemo(() => {
+ return calls.filter((c) => isCallActive(c.State));
+ }, [calls]);
+
// Update available calls from calls store
useEffect(() => {
- const activeCalls = calls.filter((c) => isCallActive(c.State));
setAvailableCalls(activeCalls);
- }, [calls, setAvailableCalls]);
+ }, [activeCalls, setAvailableCalls]);
- // Track the last personnel ID we initialized destination for
- const lastInitializedPersonnelIdRef = useRef(null);
-
- // Initialize destination from selected personnel's current destination (only once per personnel)
+ // Apply the default destination once per open of the actions panel (openActions starts a new session:
+ // a personnel selection, a re-selection or a "+" set-status-for-call). A successful submit does not start
+ // a new session, so the destination stays sticky for follow-up statuses of the same person. The applied
+ // session lives in the store so remounting the panel (e.g. switching tabs) does not re-apply it.
useEffect(() => {
- if (!selectedPersonnel) {
- lastInitializedPersonnelIdRef.current = null;
- return;
- }
-
- // Only initialize once per personnel - skip if we already initialized for this person
- if (lastInitializedPersonnelIdRef.current === selectedPersonnel.UserId) {
+ if (!selectedPersonnel) return;
+ // Wait until the store has been opened for this person (the parent opens it after selection changes).
+ if (storeSelectedPersonnel?.UserId !== selectedPersonnel.UserId) return;
+ if (destinationInitializedSessionId === actionsSessionId) return;
+
+ // (a) explicit call context, (b) the person's current destination if it is an active call, (c) the console's selected call
+ const defaultCall = resolveDefaultDestinationCall({
+ callContext,
+ currentDestinationId: selectedPersonnel.StatusDestinationId,
+ selectedCallId,
+ activeCalls,
+ });
+ if (defaultCall) {
+ setStatusSelectedCall(defaultCall);
+ markDestinationInitialized(actionsSessionId);
return;
}
- // If no destination set, just mark as initialized
- if (!selectedPersonnel.StatusDestinationId) {
- lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId;
- return;
- }
+ // Stations and POIs come from the options load; wait for it before settling on a default.
+ if (optionsLoadedForUserId !== selectedPersonnel.UserId) return;
const destinationId = selectedPersonnel.StatusDestinationId;
+ const matchingStation = destinationId ? availableStations.find((s) => s.GroupId === destinationId) : undefined;
+ const matchingPoi = destinationId && !matchingStation ? availablePois.find((poi) => poi.PoiId.toString() === destinationId) : undefined;
- // Check if the destination is a call (check available calls)
- const matchingCall = calls.find((c) => c.CallId === destinationId);
- if (matchingCall) {
- setStatusDestinationType('call');
- setStatusSelectedCall(matchingCall);
- setStatusSelectedStation(null);
- lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId;
- return;
- }
-
- // Check if the destination is a station (check available stations)
- const matchingStation = availableStations.find((s) => s.GroupId === destinationId);
if (matchingStation) {
- setStatusDestinationType('station');
setStatusSelectedStation(matchingStation);
- setStatusSelectedCall(null);
- lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId;
- return;
- }
-
- const matchingPoi = availablePois.find((poi) => poi.PoiId.toString() === destinationId);
- if (matchingPoi) {
- setStatusDestinationType('poi');
+ } else if (matchingPoi) {
setStatusSelectedPoi(matchingPoi);
- lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId;
- return;
}
-
- // If we couldn't match but have data loaded, mark as initialized anyway
- if (calls.length > 0 || availableStations.length > 0 || availablePois.length > 0) {
- lastInitializedPersonnelIdRef.current = selectedPersonnel.UserId;
- }
- }, [selectedPersonnel, calls, availableStations, availablePois, setStatusDestinationType, setStatusSelectedCall, setStatusSelectedStation, setStatusSelectedPoi]);
+ markDestinationInitialized(actionsSessionId);
+ }, [
+ selectedPersonnel,
+ storeSelectedPersonnel,
+ callContext,
+ actionsSessionId,
+ destinationInitializedSessionId,
+ selectedCallId,
+ activeCalls,
+ optionsLoadedForUserId,
+ availableStations,
+ availablePois,
+ markDestinationInitialized,
+ setStatusSelectedCall,
+ setStatusSelectedStation,
+ setStatusSelectedPoi,
+ ]);
const handleSubmitStatus = useCallback(async () => {
// Pass current personnel and status directly to avoid state sync issues
@@ -313,23 +349,40 @@ export const PersonnelActionsPanel: React.FC = ({ pe
}
}, [storeSubmitStaffing, selectedPersonnel, localSelectedStaffing, onStaffingUpdated]);
+ // An explicit call context ("+" set-status-for-call) may carry its call with any status
+ const hasCallContext = !!callContext;
+
+ const destinationSelection = useMemo(
+ () => ({
+ selectedDestinationType: statusDestinationType,
+ selectedCall: statusSelectedCall,
+ selectedStation: statusSelectedStation,
+ selectedPoi: statusSelectedPoi,
+ }),
+ [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi]
+ );
+
+ // The destination that will actually be sent with the selected status (a destination the status
+ // does not support is dropped), so the panel shows exactly what will be saved.
+ const effectiveDestinationType = useMemo(() => getEffectiveDestinationType(destinationSelection, selectedStatus?.Detail, hasCallContext), [destinationSelection, selectedStatus, hasCallContext]);
+
// Get destination display text
const getDestinationDisplay = useMemo(() => {
- if (statusDestinationType === 'call' && statusSelectedCall) {
+ if (effectiveDestinationType === 'call' && statusSelectedCall) {
return `#${statusSelectedCall.Number} - ${statusSelectedCall.Name}`;
}
- if (statusDestinationType === 'station' && statusSelectedStation) {
+ if (effectiveDestinationType === 'station' && statusSelectedStation) {
return statusSelectedStation.Name;
}
- if (statusDestinationType === 'poi' && statusSelectedPoi) {
+ if (effectiveDestinationType === 'poi' && statusSelectedPoi) {
return getPoiSelectionLabel(statusSelectedPoi);
}
return t('dispatch.personnel_actions.no_destination');
- }, [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]);
+ }, [effectiveDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]);
const destinationConfig = useMemo(() => {
- return getDestinationCapabilities(selectedStatus?.Detail);
- }, [selectedStatus]);
+ return getStatusDestinationCapabilities(selectedStatus?.Detail, hasCallContext);
+ }, [selectedStatus, hasCallContext]);
// Check note requirement based on Note field
// Note: 0 = No note, 1 = Optional, 2 = Required
@@ -365,22 +418,11 @@ export const PersonnelActionsPanel: React.FC = ({ pe
return true;
}, [selectedStaffing, staffingNote]);
- // Active calls for destination selection
- const activeCalls = useMemo(() => {
- const filtered = calls.filter((c) => isCallActive(c.State));
- console.log('[PersonnelActionsPanel] Active calls:', {
- totalCalls: calls.length,
- activeCalls: filtered.length,
- allStates: calls.map((c) => c.State),
- });
- return filtered;
- }, [calls]);
-
useEffect(() => {
if (selectedStatus) {
- setDestinationTab(getDefaultDestinationTab(selectedStatus.Detail));
+ setDestinationTab(hasCallContext ? 'calls' : getDefaultDestinationTab(selectedStatus.Detail));
}
- }, [selectedStatus]);
+ }, [selectedStatus, hasCallContext]);
// Refresh calls when destination sheet opens
useEffect(() => {
@@ -393,9 +435,15 @@ export const PersonnelActionsPanel: React.FC = ({ pe
const handleStatusSelect = (status: StatusesResultData) => {
setSelectedStatus(status);
setIsStatusSheetOpen(false);
- // If status requires destination, open destination sheet
- if (getDestinationCapabilities(status.Detail).supportsDestination) {
- setTimeout(() => setIsDestinationSheetOpen(true), 300);
+ // If the status takes a destination and none applies yet, open the destination sheet. A preset or
+ // sticky destination the status supports (e.g. the call) is kept and shown instead.
+ const supportsDestination = getStatusDestinationCapabilities(status.Detail, hasCallContext).supportsDestination;
+ if (supportsDestination && getEffectiveDestinationType(destinationSelection, status.Detail, hasCallContext) === 'none') {
+ if (destinationSheetTimerRef.current) clearTimeout(destinationSheetTimerRef.current);
+ destinationSheetTimerRef.current = setTimeout(() => {
+ destinationSheetTimerRef.current = null;
+ setIsDestinationSheetOpen(true);
+ }, 300);
}
};
@@ -408,10 +456,8 @@ export const PersonnelActionsPanel: React.FC = ({ pe
// Handle destination selection
const handleDestinationSelect = (type: 'none' | 'call' | 'station' | 'poi', item?: CallResultData | GroupResultData | PoiResultData) => {
if (type === 'none') {
+ // Clears the call, station and POI selections too
setStatusDestinationType('none');
- setStatusSelectedCall(null);
- setStatusSelectedStation(null);
- setStatusSelectedPoi(null);
} else if (type === 'call' && item) {
setStatusSelectedCall(item as CallResultData);
} else if (type === 'station' && item) {
@@ -484,7 +530,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe
setIsDestinationSheetOpen(true)}>
-
+
{t('dispatch.personnel_actions.destination')}
@@ -633,7 +679,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe
{t('dispatch.personnel_actions.destination')}
{/* No Destination Option */}
- handleDestinationSelect('none')} label={t('dispatch.personnel_actions.no_destination')} />
+ handleDestinationSelect('none')} label={t('dispatch.personnel_actions.no_destination')} />
{/* Tabs for Calls and Stations */}
{destinationConfig.showCalls || destinationConfig.showStations || destinationConfig.showPois ? (
@@ -673,7 +719,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe
key={call.CallId}
type="call"
item={call}
- isSelected={statusDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId}
+ isSelected={effectiveDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId}
onSelect={() => handleDestinationSelect('call', call)}
/>
))
@@ -692,7 +738,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe
key={station.GroupId}
type="station"
item={station}
- isSelected={statusDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId}
+ isSelected={effectiveDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId}
onSelect={() => handleDestinationSelect('station', station)}
/>
))
@@ -710,7 +756,7 @@ export const PersonnelActionsPanel: React.FC = ({ pe
key={poi.PoiId}
type="poi"
item={poi}
- isSelected={statusDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId}
+ isSelected={effectiveDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId}
onSelect={() => handleDestinationSelect('poi', poi)}
/>
))
diff --git a/src/components/dispatch-console/unit-actions-panel.tsx b/src/components/dispatch-console/unit-actions-panel.tsx
index 101c355b..f588746d 100644
--- a/src/components/dispatch-console/unit-actions-panel.tsx
+++ b/src/components/dispatch-console/unit-actions-panel.tsx
@@ -13,7 +13,7 @@ import { Icon } from '@/components/ui/icon';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
-import { type DestinationTab, getDefaultDestinationTab, getDestinationCapabilities } from '@/lib/destination-helpers';
+import { type DestinationTab, getDefaultDestinationTab, getEffectiveDestinationType, getStatusDestinationCapabilities, resolveDefaultDestinationCall } from '@/lib/destination-helpers';
import { getPoiSelectionLabel } from '@/lib/poi-display';
import { resolveUnitStatusOptions } from '@/lib/unit-status-helpers';
import { invertColor, isCallActive } from '@/lib/utils';
@@ -23,6 +23,7 @@ import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
import { useCallsStore } from '@/stores/calls/store';
+import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store';
import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store';
import { useUnitsStore } from '@/stores/units/store';
@@ -118,15 +119,27 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
// Local state for action sheets
const [isStatusSheetOpen, setIsStatusSheetOpen] = useState(false);
const [isDestinationSheetOpen, setIsDestinationSheetOpen] = useState(false);
+ // The destination sheet opens a beat after the status sheet closes.
+ const destinationSheetTimerRef = useRef | null>(null);
const [isAdditionalFieldsExpanded, setIsAdditionalFieldsExpanded] = useState(false);
const [destinationTab, setDestinationTab] = useState('calls');
// Local state for selected status (to fix synchronization issues)
const [localSelectedStatus, setLocalSelectedStatus] = useState(null);
+ // The console's selected call — a default destination when the unit's own destination is not an active call
+ const selectedCallId = useDispatchConsoleStore((state) => state.selectedCallId);
+
+ // Whether the per-unit options (stations/POIs) have finished loading for the given unit
+ const [optionsLoadedForUnitId, setOptionsLoadedForUnitId] = useState(null);
+
// Store state
const {
selectedUnit: storeSelectedUnit,
+ callContext,
+ actionsSessionId,
+ destinationInitializedSessionId,
+ markDestinationInitialized,
selectedStatus: storeSelectedStatus,
statusDestinationType,
statusSelectedCall,
@@ -174,10 +187,24 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
// Use prop if available, fallback to store
const selectedUnit = unitProp ?? storeSelectedUnit;
+ // A pending open belongs to the session it was chosen in: it dies with the panel, and with a switch to
+ // another unit or a new session, so it never opens the sheet over what the dispatcher is now doing.
+ useEffect(
+ () => () => {
+ if (destinationSheetTimerRef.current) {
+ clearTimeout(destinationSheetTimerRef.current);
+ destinationSheetTimerRef.current = null;
+ }
+ },
+ [actionsSessionId, selectedUnit?.UnitId]
+ );
+
// Load options when panel opens
useEffect(() => {
if (!selectedUnit) return;
+ let cancelled = false;
+
const loadOptions = async () => {
setIsLoadingOptions(true);
try {
@@ -188,6 +215,8 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
await useUnitsStore.getState().fetchUnits();
groups = useUnitsStore.getState().unitStatuses;
}
+ // A newer unit was selected while this load was in flight — don't overwrite its options.
+ if (cancelled) return;
const serverStatuses = (unitStatusData?.Data?.Statuses as unknown as StatusesResultData[]) || [];
setAvailableStatuses(resolveUnitStatusOptions(selectedUnit, groups, serverStatuses));
setAvailableCalls(unitStatusData?.Data?.Calls || []);
@@ -196,77 +225,82 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
} catch (error) {
console.error('Failed to load unit action options:', error);
} finally {
- setIsLoadingOptions(false);
+ if (!cancelled) {
+ setIsLoadingOptions(false);
+ setOptionsLoadedForUnitId(selectedUnit.UnitId);
+ }
}
};
- if (selectedUnit) {
- loadOptions();
- }
+ loadOptions();
+
+ return () => {
+ cancelled = true;
+ };
}, [selectedUnit, setAvailableStatuses, setAvailableCalls, setAvailableStations, setAvailablePois, setIsLoadingOptions]);
+ // Active calls for destination selection and default-destination resolution
+ const activeCalls = useMemo(() => {
+ return calls.filter((c) => isCallActive(c.State));
+ }, [calls]);
+
// Update available calls from calls store
useEffect(() => {
- const activeCalls = calls.filter((c) => isCallActive(c.State));
setAvailableCalls(activeCalls);
- }, [calls, setAvailableCalls]);
+ }, [activeCalls, setAvailableCalls]);
- // Track the last unit ID we initialized destination for
- const lastInitializedUnitIdRef = useRef(null);
-
- // Initialize destination from selected unit's current destination (only once per unit)
+ // Apply the default destination once per open of the actions panel (openActions starts a new session:
+ // a unit selection, a re-selection or a "+" set-status-for-call). A successful submit does not start a
+ // new session, so the destination stays sticky for follow-up statuses of the same unit. The applied
+ // session lives in the store so remounting the panel (e.g. switching tabs) does not re-apply it.
useEffect(() => {
- if (!selectedUnit) {
- lastInitializedUnitIdRef.current = null;
- return;
- }
-
- // Only initialize once per unit - skip if we already initialized for this unit
- if (lastInitializedUnitIdRef.current === selectedUnit.UnitId) {
+ if (!selectedUnit) return;
+ // Wait until the store has been opened for this unit (the parent opens it after selection changes).
+ if (storeSelectedUnit?.UnitId !== selectedUnit.UnitId) return;
+ if (destinationInitializedSessionId === actionsSessionId) return;
+
+ // (a) explicit call context, (b) the unit's current destination if it is an active call, (c) the console's selected call
+ const defaultCall = resolveDefaultDestinationCall({
+ callContext,
+ currentDestinationId: selectedUnit.CurrentDestinationId,
+ selectedCallId,
+ activeCalls,
+ });
+ if (defaultCall) {
+ setStatusSelectedCall(defaultCall);
+ markDestinationInitialized(actionsSessionId);
return;
}
- // If no destination set, just mark as initialized
- if (!selectedUnit.CurrentDestinationId) {
- lastInitializedUnitIdRef.current = selectedUnit.UnitId;
- return;
- }
+ // Stations and POIs come from the per-unit options load; wait for it before settling on a default.
+ if (optionsLoadedForUnitId !== selectedUnit.UnitId) return;
const destinationId = selectedUnit.CurrentDestinationId;
+ const matchingStation = destinationId ? availableStations.find((s) => s.GroupId === destinationId) : undefined;
+ const matchingPoi = destinationId && !matchingStation ? availablePois.find((poi) => poi.PoiId.toString() === destinationId) : undefined;
- // Check if the destination is a call (check available calls)
- const matchingCall = calls.find((c) => c.CallId === destinationId);
- if (matchingCall) {
- setStatusDestinationType('call');
- setStatusSelectedCall(matchingCall);
- setStatusSelectedStation(null);
- lastInitializedUnitIdRef.current = selectedUnit.UnitId;
- return;
- }
-
- // Check if the destination is a station (check available stations)
- const matchingStation = availableStations.find((s) => s.GroupId === destinationId);
if (matchingStation) {
- setStatusDestinationType('station');
setStatusSelectedStation(matchingStation);
- setStatusSelectedCall(null);
- lastInitializedUnitIdRef.current = selectedUnit.UnitId;
- return;
- }
-
- const matchingPoi = availablePois.find((poi) => poi.PoiId.toString() === destinationId);
- if (matchingPoi) {
- setStatusDestinationType('poi');
+ } else if (matchingPoi) {
setStatusSelectedPoi(matchingPoi);
- lastInitializedUnitIdRef.current = selectedUnit.UnitId;
- return;
- }
-
- // If we couldn't match but have data loaded, mark as initialized anyway
- if (calls.length > 0 || availableStations.length > 0 || availablePois.length > 0) {
- lastInitializedUnitIdRef.current = selectedUnit.UnitId;
}
- }, [selectedUnit, calls, availableStations, availablePois, setStatusDestinationType, setStatusSelectedCall, setStatusSelectedStation, setStatusSelectedPoi]);
+ markDestinationInitialized(actionsSessionId);
+ }, [
+ selectedUnit,
+ storeSelectedUnit,
+ callContext,
+ actionsSessionId,
+ destinationInitializedSessionId,
+ selectedCallId,
+ activeCalls,
+ optionsLoadedForUnitId,
+ availableStations,
+ availablePois,
+ markDestinationInitialized,
+ setStatusSelectedCall,
+ setStatusSelectedStation,
+ setStatusSelectedPoi,
+ ]);
const handleSubmitStatus = useCallback(async () => {
// Pass current unit and status directly to avoid state sync issues
@@ -280,24 +314,41 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
}
}, [storeSubmitStatus, selectedUnit, localSelectedStatus, onStatusUpdated]);
+ // An explicit call context ("+" set-status-for-call) may carry its call with any status
+ const hasCallContext = !!callContext;
+
+ const destinationSelection = useMemo(
+ () => ({
+ selectedDestinationType: statusDestinationType,
+ selectedCall: statusSelectedCall,
+ selectedStation: statusSelectedStation,
+ selectedPoi: statusSelectedPoi,
+ }),
+ [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi]
+ );
+
+ // The destination that will actually be sent with the selected status (a destination the status
+ // does not support is dropped), so the panel shows exactly what will be saved.
+ const effectiveDestinationType = useMemo(() => getEffectiveDestinationType(destinationSelection, selectedStatus?.Detail, hasCallContext), [destinationSelection, selectedStatus, hasCallContext]);
+
// Get destination display text
const getDestinationDisplay = useMemo(() => {
- if (statusDestinationType === 'call' && statusSelectedCall) {
+ if (effectiveDestinationType === 'call' && statusSelectedCall) {
return `#${statusSelectedCall.Number} - ${statusSelectedCall.Name}`;
}
- if (statusDestinationType === 'station' && statusSelectedStation) {
+ if (effectiveDestinationType === 'station' && statusSelectedStation) {
return statusSelectedStation.Name;
}
- if (statusDestinationType === 'poi' && statusSelectedPoi) {
+ if (effectiveDestinationType === 'poi' && statusSelectedPoi) {
return getPoiSelectionLabel(statusSelectedPoi);
}
return t('dispatch.unit_actions_panel.no_destination');
- }, [statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]);
+ }, [effectiveDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, t]);
// Check destination type allowed based on Detail
const destinationConfig = useMemo(() => {
- return getDestinationCapabilities(selectedStatus?.Detail);
- }, [selectedStatus]);
+ return getStatusDestinationCapabilities(selectedStatus?.Detail, hasCallContext);
+ }, [selectedStatus, hasCallContext]);
// Check note requirement based on Note field
// Note: 0 = No note, 1 = Optional, 2 = Required
@@ -317,16 +368,11 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
return true;
}, [selectedStatus, statusNote]);
- // Active calls for destination selection
- const activeCalls = useMemo(() => {
- return calls.filter((c) => isCallActive(c.State));
- }, [calls]);
-
useEffect(() => {
if (selectedStatus) {
- setDestinationTab(getDefaultDestinationTab(selectedStatus.Detail));
+ setDestinationTab(hasCallContext ? 'calls' : getDefaultDestinationTab(selectedStatus.Detail));
}
- }, [selectedStatus]);
+ }, [selectedStatus, hasCallContext]);
// Refresh calls when destination sheet opens
useEffect(() => {
@@ -339,19 +385,23 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
const handleStatusSelect = (status: StatusesResultData) => {
setSelectedStatus(status);
setIsStatusSheetOpen(false);
- // If status requires destination, open destination sheet
- if (getDestinationCapabilities(status.Detail).supportsDestination) {
- setTimeout(() => setIsDestinationSheetOpen(true), 300);
+ // If the status takes a destination and none applies yet, open the destination sheet. A preset or
+ // sticky destination the status supports (e.g. the call) is kept and shown instead.
+ const supportsDestination = getStatusDestinationCapabilities(status.Detail, hasCallContext).supportsDestination;
+ if (supportsDestination && getEffectiveDestinationType(destinationSelection, status.Detail, hasCallContext) === 'none') {
+ if (destinationSheetTimerRef.current) clearTimeout(destinationSheetTimerRef.current);
+ destinationSheetTimerRef.current = setTimeout(() => {
+ destinationSheetTimerRef.current = null;
+ setIsDestinationSheetOpen(true);
+ }, 300);
}
};
// Handle destination selection
const handleDestinationSelect = (type: 'none' | 'call' | 'station' | 'poi', item?: CallResultData | GroupResultData | PoiResultData) => {
if (type === 'none') {
+ // Clears the call, station and POI selections too
setStatusDestinationType('none');
- setStatusSelectedCall(null);
- setStatusSelectedStation(null);
- setStatusSelectedPoi(null);
} else if (type === 'call' && item) {
setStatusSelectedCall(item as CallResultData);
} else if (type === 'station' && item) {
@@ -424,7 +474,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
setIsDestinationSheetOpen(true)}>
-
+
{t('dispatch.unit_actions_panel.destination')}
@@ -506,7 +556,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
{t('dispatch.unit_actions_panel.destination')}
{/* No Destination Option */}
- handleDestinationSelect('none')} label={t('dispatch.unit_actions_panel.no_destination')} />
+ handleDestinationSelect('none')} label={t('dispatch.unit_actions_panel.no_destination')} />
{/* Tabs for Calls and Stations */}
{destinationConfig.showCalls || destinationConfig.showStations || destinationConfig.showPois ? (
@@ -546,7 +596,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
key={call.CallId}
type="call"
item={call}
- isSelected={statusDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId}
+ isSelected={effectiveDestinationType === 'call' && statusSelectedCall?.CallId === call.CallId}
onSelect={() => handleDestinationSelect('call', call)}
/>
))
@@ -565,7 +615,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
key={station.GroupId}
type="station"
item={station}
- isSelected={statusDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId}
+ isSelected={effectiveDestinationType === 'station' && statusSelectedStation?.GroupId === station.GroupId}
onSelect={() => handleDestinationSelect('station', station)}
/>
))
@@ -583,7 +633,7 @@ export const UnitActionsPanel: React.FC = ({ unit: unitPr
key={poi.PoiId}
type="poi"
item={poi}
- isSelected={statusDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId}
+ isSelected={effectiveDestinationType === 'poi' && statusSelectedPoi?.PoiId === poi.PoiId}
onSelect={() => handleDestinationSelect('poi', poi)}
/>
))
diff --git a/src/components/maps/__tests__/pin-actions.test.tsx b/src/components/maps/__tests__/pin-actions.test.tsx
index af932f22..921f7df7 100644
--- a/src/components/maps/__tests__/pin-actions.test.tsx
+++ b/src/components/maps/__tests__/pin-actions.test.tsx
@@ -144,8 +144,9 @@ jest.mock('@/stores/toast/store', () => ({
}),
}));
+// Pin ids and types as the Core map API sends them (calls are Type 0 with a `c` prefix).
const mockCallPin = {
- Id: '123',
+ Id: 'c123',
Title: 'Medical Emergency',
Latitude: 40.7128,
Longitude: -74.0060,
@@ -165,12 +166,12 @@ const mockCallPin = {
};
const mockUnitPin = {
- Id: '456',
+ Id: 'u456',
Title: 'Engine 1',
Latitude: 40.7580,
Longitude: -73.9855,
ImagePath: 'engine_available',
- Type: 2,
+ Type: 1,
InfoWindowContent: 'Engine 1 available',
Color: '#00ff00',
zIndex: 1,
@@ -382,6 +383,29 @@ describe('Pin Actions Integration Tests', () => {
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
+ it('should navigate with a legacy unprefixed call pin id unchanged', () => {
+ render( );
+
+ fireEvent.press(screen.getByText('map.view_call_details'));
+
+ expect(mockRouter.push).toHaveBeenCalledWith('/call/123');
+ });
+
+ it('should open POI details with the bare POI id', () => {
+ render(
+
+ );
+
+ fireEvent.press(screen.getByText('map.view_poi_details'));
+
+ expect(mockRouter.push).toHaveBeenCalledWith('/poi/9');
+ });
+
it('should not show call details button for non-call pins', () => {
render(
void>;
+ flyTo: jest.Mock;
+}
+const mockMarkers: MockMarker[] = [];
+const mockMaps: MockMap[] = [];
+
+jest.mock('mapbox-gl', () => {
+ class Popup {
+ setHTML = jest.fn(() => this);
+ }
+ class Marker {
+ lngLat: [number, number] = [0, 0];
+ popup: Popup | undefined;
+ setLngLat = jest.fn((lngLat: [number, number]) => {
+ this.lngLat = lngLat;
+ return this;
+ });
+ remove = jest.fn();
+ constructor() {
+ mockMarkers.push(this as unknown as MockMarker);
+ }
+ setPopup(popup: Popup) {
+ this.popup = popup;
+ return this;
+ }
+ getPopup() {
+ return this.popup;
+ }
+ addTo() {
+ return this;
+ }
+ }
+ class MapboxMap {
+ handlers: Record void> = {};
+ flyTo = jest.fn();
+ constructor() {
+ mockMaps.push(this as unknown as MockMap);
+ }
+ addControl() {}
+ on(event: string, handler: () => void) {
+ this.handlers[event] = handler;
+ }
+ once() {}
+ off() {}
+ remove() {}
+ setStyle() {}
+ getLayer() {
+ return undefined;
+ }
+ getSource() {
+ return undefined;
+ }
+ addSource() {}
+ addLayer() {}
+ }
+ class Control {}
+ return { __esModule: true, default: { Map: MapboxMap, Marker, Popup, NavigationControl: Control, GeolocateControl: Control, accessToken: '' } };
+});
+
+jest.mock('@/stores/signalr/signalr-store', () => {
+ const { create } = jest.requireActual('zustand');
+ return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) };
+});
+
+jest.mock('@/api/mapping/mapping', () => ({ getMapDataAndMarkers: jest.fn() }));
+jest.mock('@/lib/map-markers-web', () => ({ buildMapPinPopupHtml: jest.fn(() => '
'), createMapMarkerElement: jest.fn(() => ({})) }));
+jest.mock('@/lib/map-center', () => ({ getDepartmentMapCenter: () => ({ latitude: 39, longitude: -119, zoomLevel: 9 }) }));
+jest.mock('@/lib/env', () => ({ Env: { MAPBOX_PUBKEY: 'pk.test' } }));
+jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() } }));
+jest.mock('@/stores/app/location-store', () => ({ useLocationStore: { getState: () => ({ latitude: null, longitude: null }) } }));
+jest.mock('nativewind', () => ({ useColorScheme: () => ({ colorScheme: 'light' }) }));
+
+const mockGetMapDataAndMarkers = getMapDataAndMarkers as jest.MockedFunction;
+
+const makePin = (id: string, type: number, latitude: number, longitude: number) => ({ Id: id, Type: type, Latitude: latitude, Longitude: longitude, Title: id }) as MapMakerInfoData;
+
+const mapResponse = (pins: MapMakerInfoData[]) => ({ Data: { MapMakerInfos: pins, CenterLat: '39.5', CenterLon: '-119.8', ZoomLevel: '11', PoiLayers: [] } }) as any;
+
+const REST_PINS = [makePin('c1', 0, 39.1, -119.1), makePin('u12', 1, 39.2, -119.2), makePin('pABC', 3, 39.3, -119.3)];
+
+describe('UnifiedMapView (web) realtime locations', () => {
+ const originalDocument = (global as any).document;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ mockMarkers.length = 0;
+ mockMaps.length = 0;
+ mockGetMapDataAndMarkers.mockReset();
+ mockGetMapDataAndMarkers.mockResolvedValue(mapResponse(REST_PINS));
+ useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 });
+ // Only the stylesheet injection touches document; report it as already present.
+ (global as any).document = { getElementById: () => ({}) };
+ });
+
+ afterEach(() => {
+ (global as any).document = originalDocument;
+ jest.useRealTimers();
+ });
+
+ const renderLoadedMap = async () => {
+ const view = render( , { createNodeMock: () => ({}) });
+ act(() => mockMaps[0].handlers.load());
+ await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length));
+ return view;
+ };
+
+ const markerAt = (latitude: number, longitude: number) => mockMarkers.find((marker) => marker.lngLat[0] === longitude && marker.lngLat[1] === latitude);
+
+ it('moves the pushed pin in place without rebuilding markers or moving the camera', async () => {
+ await renderLoadedMap();
+ const unitMarker = markerAt(39.2, -119.2)!;
+ expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ useSignalRStore.setState({ liveLocations: { u12: { pinId: 'u12', latitude: 40, longitude: -120, timestamp: null, receivedAt: Date.now() } } });
+ });
+
+ expect(unitMarker.setLngLat).toHaveBeenLastCalledWith([-120, 40]);
+ expect(mockMarkers).toHaveLength(REST_PINS.length);
+ mockMarkers.forEach((marker) => expect(marker.remove).not.toHaveBeenCalled());
+ expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1);
+ });
+
+ it('refetches in the background for a pin it does not have, without moving the camera', async () => {
+ await renderLoadedMap();
+ mockGetMapDataAndMarkers.mockResolvedValue(mapResponse([...REST_PINS, makePin('u99', 1, 38, -118)]));
+
+ act(() => {
+ useSignalRStore.setState({ liveLocations: { u99: { pinId: 'u99', latitude: 38.5, longitude: -118.5, timestamp: null, receivedAt: Date.now() } } });
+ });
+ expect(mockMarkers).toHaveLength(REST_PINS.length);
+
+ await act(async () => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS);
+ });
+
+ await waitFor(() => expect(mockMarkers).toHaveLength(REST_PINS.length + 1));
+ expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(2);
+ // The push arrived before the refetch started, so the fresh REST position stands.
+ expect(markerAt(38, -118)).toBeDefined();
+ expect(mockMaps[0].flyTo).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/maps/pin-detail-modal.tsx b/src/components/maps/pin-detail-modal.tsx
index fbf0b2ab..0ad11e60 100644
--- a/src/components/maps/pin-detail-modal.tsx
+++ b/src/components/maps/pin-detail-modal.tsx
@@ -15,6 +15,7 @@ import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { isCallMarker, isPoiMarker } from '@/lib/destination-helpers';
import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers';
+import { getPinEntityId } from '@/lib/map-pin-ids';
import { openMapsWithDirections } from '@/lib/navigation';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { useLocationStore } from '@/stores/app/location-store';
@@ -67,15 +68,19 @@ export const PinDetailModal: React.FC = ({ pin, isOpen, onC
};
const handleViewCallDetails = () => {
- if (isCallPin && pin.Id) {
- router.push(`/call/${pin.Id}` as Href);
+ const callId = getPinEntityId(pin);
+
+ if (isCallPin && callId) {
+ router.push(`/call/${callId}` as Href);
onClose();
}
};
const handleViewPoiDetails = () => {
- if (isPoiPin && pin.Id) {
- router.push(`/poi/${pin.Id}` as Href);
+ const poiId = getPinEntityId(pin);
+
+ if (isPoiPin && poiId) {
+ router.push(`/poi/${poiId}` as Href);
onClose();
}
};
diff --git a/src/components/maps/unified-map-view.tsx b/src/components/maps/unified-map-view.tsx
index b1f37c07..cd2fb6dd 100644
--- a/src/components/maps/unified-map-view.tsx
+++ b/src/components/maps/unified-map-view.tsx
@@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { StyleSheet, View } from 'react-native';
import { getMapDataAndMarkers } from '@/api/mapping/mapping';
+import { useMapLiveLocations } from '@/hooks/use-map-live-locations';
import { logger } from '@/lib/logging';
import { getDepartmentMapCenter } from '@/lib/map-center';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
@@ -66,6 +67,12 @@ const UnifiedMapViewComponent: React.FC = ({
// Use external pins if provided, otherwise use internal pins
const mapPins = externalPins ?? internalPins;
+ // Realtime unit/personnel positions from the geolocation hub move the pins this view fetched itself; a parent
+ // that passes `pins` owns them. Refetch requests reuse refreshPins below.
+ const requestPinsRefreshRef = useRef<(() => void) | null>(null);
+ const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []);
+ const { applyToFetchedPins } = useMapLiveLocations({ pins: internalPins, setPins: setInternalPins, requestRefresh: requestPinsRefresh, enabled: autoFetchPins && externalPins === undefined });
+
// Get map style based on current theme
const getMapStyle = useCallback(() => {
return colorScheme === 'dark' ? Mapbox.StyleURL.Dark : Mapbox.StyleURL.Street;
@@ -101,11 +108,12 @@ const UnifiedMapViewComponent: React.FC = ({
const fetchMapDataAndMarkers = async () => {
try {
+ const fetchStartedAt = Date.now();
const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal);
if (mapDataAndMarkers?.Data) {
const markers = mapDataAndMarkers.Data.MapMakerInfos;
- setInternalPins(markers);
+ setInternalPins(applyToFetchedPins(markers, fetchStartedAt));
// Center map on the data center if provided
if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && cameraRef.current) {
@@ -150,7 +158,45 @@ const UnifiedMapViewComponent: React.FC = ({
return () => {
abortController.abort();
};
- }, [autoFetchPins]);
+ }, [autoFetchPins, applyToFetchedPins]);
+
+ // Background refetch of the pins (update-hub changes, realtime location requests). Never moves the camera.
+ const refreshPins = useCallback(async () => {
+ // Abort any in-flight refresh
+ if (signalRAbortController.current) {
+ signalRAbortController.current.abort();
+ }
+ const controller = new AbortController();
+ signalRAbortController.current = controller;
+
+ try {
+ logger.debug({
+ message: 'Refreshing map pins in the background',
+ });
+
+ const fetchStartedAt = Date.now();
+ const mapDataAndMarkers = await getMapDataAndMarkers(controller.signal);
+ if (!controller.signal.aborted && mapDataAndMarkers?.Data) {
+ setInternalPins(applyToFetchedPins(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt));
+ }
+ } catch (error) {
+ if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) {
+ return;
+ }
+ logger.error({
+ message: 'Failed to refresh map pins',
+ context: { error },
+ });
+ }
+ }, [applyToFetchedPins]);
+
+ useEffect(() => {
+ requestPinsRefreshRef.current = autoFetchPins
+ ? () => {
+ void refreshPins();
+ }
+ : null;
+ }, [autoFetchPins, refreshPins]);
// Refresh pins when SignalR updates come in (only when autoFetchPins is enabled)
useEffect(() => {
@@ -162,33 +208,8 @@ const UnifiedMapViewComponent: React.FC = ({
}
// Debounce to prevent rapid consecutive API calls from multiple SignalR events
- signalRDebounceTimer.current = setTimeout(async () => {
- // Abort any in-flight SignalR-triggered fetch
- if (signalRAbortController.current) {
- signalRAbortController.current.abort();
- }
- const controller = new AbortController();
- signalRAbortController.current = controller;
-
- try {
- logger.debug({
- message: 'Refreshing map pins from SignalR update',
- context: { timestamp: lastUpdateTimestamp },
- });
-
- const mapDataAndMarkers = await getMapDataAndMarkers(controller.signal);
- if (!controller.signal.aborted && mapDataAndMarkers?.Data) {
- setInternalPins(mapDataAndMarkers.Data.MapMakerInfos);
- }
- } catch (error) {
- if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) {
- return;
- }
- logger.error({
- message: 'Failed to refresh map pins from SignalR update',
- context: { error },
- });
- }
+ signalRDebounceTimer.current = setTimeout(() => {
+ void refreshPins();
}, 1500);
return () => {
@@ -199,7 +220,14 @@ const UnifiedMapViewComponent: React.FC = ({
signalRAbortController.current.abort();
}
};
- }, [autoFetchPins, lastUpdateTimestamp]);
+ }, [autoFetchPins, lastUpdateTimestamp, refreshPins]);
+
+ // Abort a background refresh still in flight when the view unmounts.
+ useEffect(() => {
+ return () => {
+ signalRAbortController.current?.abort();
+ };
+ }, []);
// Helper function to get layer style based on type
const getLayerStyle = (layer: GetMapLayersData): FillLayerStyle | LineLayerStyle | CircleLayerStyle => {
diff --git a/src/components/maps/unified-map-view.web.tsx b/src/components/maps/unified-map-view.web.tsx
index 53de5479..5ef0bdcb 100644
--- a/src/components/maps/unified-map-view.web.tsx
+++ b/src/components/maps/unified-map-view.web.tsx
@@ -5,11 +5,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { StyleSheet, View } from 'react-native';
import { getMapDataAndMarkers } from '@/api/mapping/mapping';
+import { useMapLiveLocations } from '@/hooks/use-map-live-locations';
import { Env } from '@/lib/env';
import { logger } from '@/lib/logging';
import { getDepartmentMapCenter } from '@/lib/map-center';
-import { getMapPinSummary, hasValidMapCoordinates } from '@/lib/map-markers';
-import { createMapMarkerElement } from '@/lib/map-markers-web';
+import { hasValidMapCoordinates } from '@/lib/map-markers';
+import { buildMapPinPopupHtml, createMapMarkerElement } from '@/lib/map-markers-web';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
import { type GetMapLayersData } from '@/models/v4/mapping/getMapLayersResultData';
import { useLocationStore } from '@/stores/app/location-store';
@@ -47,6 +48,20 @@ interface UnifiedMapViewProps {
testID?: string;
}
+// Helper function to calculate center from markers
+const calculateCenterFromMarkers = (markers: MapMakerInfoData[]): { lat: number; lon: number } | null => {
+ const validMarkers = markers.filter((m) => m.Latitude && m.Longitude);
+ if (validMarkers.length === 0) return null;
+
+ const sumLat = validMarkers.reduce((sum, m) => sum + m.Latitude, 0);
+ const sumLon = validMarkers.reduce((sum, m) => sum + m.Longitude, 0);
+
+ return {
+ lat: sumLat / validMarkers.length,
+ lon: sumLon / validMarkers.length,
+ };
+};
+
/**
* Unified Map View component for Web using mapbox-gl-js.
* Supports pins, layers, and user location.
@@ -66,7 +81,7 @@ const UnifiedMapViewComponent: React.FC = ({
const mapContainer = useRef(null);
const map = useRef(null);
const markersRef = useRef>(new Map());
- const markerMetaRef = useRef>(new Map());
+ const markerMetaRef = useRef>(new Map());
const onPinPressRef = useRef(onPinPress);
const layerIdsRef = useRef([]);
const sourceIdsRef = useRef([]);
@@ -77,6 +92,12 @@ const UnifiedMapViewComponent: React.FC = ({
// Use external pins if provided, otherwise use internal pins
const mapPins = externalPins ?? internalPins;
+ // Realtime unit/personnel positions from the geolocation hub move the pins this view fetched itself; a parent
+ // that passes `pins` owns them. Refetch requests reuse fetchPins below.
+ const requestPinsRefreshRef = useRef<(() => void) | null>(null);
+ const requestPinsRefresh = useCallback(() => requestPinsRefreshRef.current?.(), []);
+ const { applyToFetchedPins } = useMapLiveLocations({ pins: internalPins, setPins: setInternalPins, requestRefresh: requestPinsRefresh, enabled: autoFetchPins && externalPins === undefined });
+
// Get map style based on current theme
const getMapStyle = useCallback(() => {
return colorScheme === 'dark' ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/streets-v12';
@@ -152,33 +173,29 @@ const UnifiedMapViewComponent: React.FC = ({
}
}, [colorScheme, getMapStyle, isMapReady]);
- // Helper function to calculate center from markers
- const calculateCenterFromMarkers = (markers: MapMakerInfoData[]): { lat: number; lon: number } | null => {
- const validMarkers = markers.filter((m) => m.Latitude && m.Longitude);
- if (validMarkers.length === 0) return null;
-
- const sumLat = validMarkers.reduce((sum, m) => sum + m.Latitude, 0);
- const sumLon = validMarkers.reduce((sum, m) => sum + m.Longitude, 0);
-
- return {
- lat: sumLat / validMarkers.length,
- lon: sumLon / validMarkers.length,
- };
- };
+ // Fetches the pins this view shows when auto-fetching: once on mount, and again in the background when the
+ // realtime location feed asks for it. Only the first successful load may move the camera.
+ const fetchAbortRef = useRef(null);
+ const hasCenteredOnDataRef = useRef(false);
+ const fetchPins = useCallback(async () => {
+ fetchAbortRef.current?.abort();
+ const abortController = new AbortController();
+ fetchAbortRef.current = abortController;
- // Auto-fetch pins if enabled
- useEffect(() => {
- if (!autoFetchPins) return;
+ try {
+ const fetchStartedAt = Date.now();
+ const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal);
- const abortController = new AbortController();
+ if (abortController.signal.aborted) {
+ return;
+ }
- const fetchMapDataAndMarkers = async () => {
- try {
- const mapDataAndMarkers = await getMapDataAndMarkers(abortController.signal);
+ if (mapDataAndMarkers?.Data) {
+ const markers = mapDataAndMarkers.Data.MapMakerInfos;
+ setInternalPins(applyToFetchedPins(markers, fetchStartedAt));
- if (mapDataAndMarkers?.Data) {
- const markers = mapDataAndMarkers.Data.MapMakerInfos;
- setInternalPins(markers);
+ if (!hasCenteredOnDataRef.current) {
+ hasCenteredOnDataRef.current = true;
// Center map on the data center if provided
if (mapDataAndMarkers.Data.CenterLat && mapDataAndMarkers.Data.CenterLon && map.current) {
@@ -206,24 +223,42 @@ const UnifiedMapViewComponent: React.FC = ({
}
}
}
- } catch (error) {
- if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) {
- return;
- }
+ }
+ } catch (error) {
+ if (error instanceof Error && (error.name === 'AbortError' || error.message === 'canceled')) {
+ return;
+ }
- logger.error({
- message: 'Failed to fetch map data',
- context: { error },
- });
+ logger.error({
+ message: 'Failed to fetch map data',
+ context: { error },
+ });
+ } finally {
+ if (fetchAbortRef.current === abortController) {
+ fetchAbortRef.current = null;
}
- };
+ }
+ }, [applyToFetchedPins]);
- fetchMapDataAndMarkers();
+ useEffect(() => {
+ requestPinsRefreshRef.current = autoFetchPins
+ ? () => {
+ void fetchPins();
+ }
+ : null;
+ }, [autoFetchPins, fetchPins]);
+
+ // Auto-fetch pins if enabled
+ useEffect(() => {
+ if (!autoFetchPins) return;
+
+ void fetchPins();
return () => {
- abortController.abort();
+ fetchAbortRef.current?.abort();
+ fetchAbortRef.current = null;
};
- }, [autoFetchPins]);
+ }, [autoFetchPins, fetchPins]);
// Keep a ref to the latest onPinPress so marker click handlers always call the
// current callback without forcing a marker rebuild when its identity changes.
@@ -238,24 +273,20 @@ const UnifiedMapViewComponent: React.FC = ({
const theme = colorScheme === 'dark' ? 'dark' : 'light';
const seenPinIds = new Set();
- const buildPopupHtml = (pin: MapMakerInfoData) =>
- `
-
${pin.Title}
- ${getMapPinSummary(pin) ? `
${getMapPinSummary(pin)}
` : ''}
-
- ${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)}
-
-
`;
-
mapPins.forEach((pin) => {
if (!hasValidMapCoordinates(pin)) return;
seenPinIds.add(pin.Id);
- const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`;
const existing = markersRef.current.get(pin.Id);
+ const currentMeta = markerMetaRef.current.get(pin.Id);
+ // Live location moves replace only the moved pins' objects, so an unchanged pin is skipped outright
+ // instead of re-deriving its signature on every realtime update.
+ if (existing && currentMeta && currentMeta.pin === pin && currentMeta.signature.startsWith(`${theme}:`)) return;
+
+ const signature = `${theme}:${pin.Title}:${pin.Type}:${pin.PoiTypeId}:${pin.LayerId}:${pin.ImagePath}:${pin.PoiImage}:${pin.Marker}:${pin.Color}:${pin.Address}:${pin.Note}:${pin.PoiTypeName}:${pin.InfoWindowContent}`;
if (existing) {
- const meta = markerMetaRef.current.get(pin.Id);
+ const meta = currentMeta;
const moved = !meta || meta.latitude !== pin.Latitude || meta.longitude !== pin.Longitude;
if (moved) {
@@ -264,9 +295,9 @@ const UnifiedMapViewComponent: React.FC = ({
if (meta && meta.signature === signature) {
if (moved) {
- existing.getPopup()?.setHTML(buildPopupHtml(pin));
- markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude });
+ existing.getPopup()?.setHTML(buildMapPinPopupHtml(pin));
}
+ markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude, pin });
return;
}
@@ -274,18 +305,20 @@ const UnifiedMapViewComponent: React.FC = ({
markersRef.current.delete(pin.Id);
}
- // Create custom marker element using shared utility
+ // Create custom marker element using shared utility. The click handler looks the pin up by id so a
+ // marker that has since moved reports its current data, not the pin it was created from.
+ const pinId = pin.Id;
const el = createMapMarkerElement(pin, theme, () => {
- onPinPressRef.current?.(pin);
+ onPinPressRef.current?.(markerMetaRef.current.get(pinId)?.pin ?? pin);
});
// Create popup
- const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(buildPopupHtml(pin));
+ const popup = new mapboxgl.Popup({ offset: 25 }).setHTML(buildMapPinPopupHtml(pin));
const marker = new mapboxgl.Marker({ element: el }).setLngLat([pin.Longitude, pin.Latitude]).setPopup(popup).addTo(map.current!);
markersRef.current.set(pin.Id, marker);
- markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude });
+ markerMetaRef.current.set(pin.Id, { signature, latitude: pin.Latitude, longitude: pin.Longitude, pin });
});
// Remove stale markers
diff --git a/src/components/notifications/NotificationDetail.tsx b/src/components/notifications/NotificationDetail.tsx
index c3cea735..18dd3feb 100644
--- a/src/components/notifications/NotificationDetail.tsx
+++ b/src/components/notifications/NotificationDetail.tsx
@@ -1,9 +1,11 @@
import { useNotifications } from '@novu/react-native';
import { useColorScheme } from 'nativewind';
import React, { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
import { Animated, Dimensions, Platform, Pressable, SafeAreaView, StatusBar, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { ArrowLeft, Calendar, ExternalLink, Trash2 } from '@/components/ui/lucide-icons';
+import { hasReferenceRoute } from '@/lib/notifications/inbox-reference';
// Define the interface directly in this file
interface NotificationPayload {
@@ -30,6 +32,7 @@ const SIDEBAR_WIDTH = Math.min(width * 0.85, 400);
const STATUS_BAR_HEIGHT = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 0;
export const NotificationDetail = ({ notification, onClose, onDelete, onNavigateToReference }: NotificationDetailProps) => {
+ const { t } = useTranslation();
const styles = useStyles();
const { refetch } = useNotifications();
const slideAnim = React.useRef(new Animated.Value(SIDEBAR_WIDTH)).current;
@@ -111,7 +114,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate
- Notification
+ {t('notifications.detail_title')}
@@ -140,7 +143,7 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate
{notification.metadata && Object.keys(notification.metadata).length > 0 ? (
- Additional Information
+ {t('notifications.additional_info')}
{Object.entries(notification.metadata).map(([key, value]) => (
{formatKey(key)}:
@@ -150,10 +153,12 @@ export const NotificationDetail = ({ notification, onClose, onDelete, onNavigate
) : null}
- {notification.referenceType && notification.referenceId ? (
-
+ {hasReferenceRoute(notification.referenceType, notification.referenceId) ? (
+
- View {notification.referenceType}
+
+ {notification.referenceType === 'call' ? t('notifications.view_call') : notification.referenceType === 'chat' ? t('notifications.view_chat') : t('notifications.open_reference')}
+
) : null}
diff --git a/src/components/notifications/NotificationInbox.tsx b/src/components/notifications/NotificationInbox.tsx
index e0e4cd87..d08d8ffc 100644
--- a/src/components/notifications/NotificationInbox.tsx
+++ b/src/components/notifications/NotificationInbox.tsx
@@ -1,4 +1,5 @@
import { useNotifications } from '@novu/react-native';
+import { router } from 'expo-router';
import { useColorScheme } from 'nativewind';
import React, { useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Animated, Dimensions, Platform, Pressable, RefreshControl, SafeAreaView, StatusBar, StyleSheet, View } from 'react-native';
@@ -10,6 +11,8 @@ import { FlatList } from '@/components/ui/flat-list';
import { CheckCircle, ChevronRight, Circle, ExternalLink, MoreVertical, Trash2, X } from '@/components/ui/lucide-icons';
import { Modal, ModalBackdrop, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/ui/modal';
import { Text } from '@/components/ui/text';
+import { useAuthStore } from '@/lib/auth';
+import { hasReferenceRoute, referenceFromEventCode, referenceHref } from '@/lib/notifications/inbox-reference';
import { useCoreStore } from '@/stores/app/core-store';
import { useToastStore } from '@/stores/toast/store';
import { type NotificationPayload } from '@/types/notification';
@@ -24,9 +27,42 @@ interface NotificationInboxProps {
onClose: () => void;
}
+/** The notification item shape returned by Novu's useNotifications hook. */
+type NovuNotification = NonNullable['notifications']>[number];
+
+const REFERENCE_TYPES = ['call', 'message', 'status', 'note', 'chat', 'other'] as const;
+
+const asString = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
+
+const asReferenceType = (value: unknown): NotificationPayload['referenceType'] => REFERENCE_TYPES.find((candidate) => candidate === value);
+
+/**
+ * Maps a Novu inbox item (@novu/js v3: subject, isRead, data). The v2 names this inbox used to read
+ * (title, read, payload) are undefined or, for `read`, a method, so every row looked read, had no title
+ * and never carried a reference. The Novu bridge puts the push event code in `data`, from which the call
+ * or chat the notification is about is derived; the code itself is routing, not "Additional Information".
+ */
+export const mapNovuNotification = (item: NovuNotification): NotificationPayload => {
+ const data = item.data;
+ const reference = referenceFromEventCode(data?.eventCode);
+
+ return {
+ id: item.id,
+ title: item.subject,
+ body: item.body,
+ createdAt: item.createdAt,
+ read: item.isRead,
+ type: asString(data?.type),
+ referenceId: reference?.referenceId ?? asString(data?.referenceId),
+ referenceType: reference?.referenceType ?? asReferenceType(data?.referenceType),
+ metadata: data ? Object.fromEntries(Object.entries(data).filter(([key]) => key !== 'eventCode')) : undefined,
+ };
+};
+
export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) => {
const styles = useStyles();
- const activeUnitId = useCoreStore((state) => state.activeUnitId);
+ // The inbox is the dispatcher's own ({code}_User_{id}, the NovuProvider subscriber), not a unit's.
+ const userId = useAuthStore((state) => state.userId);
const config = useCoreStore((state: any) => state.config);
const { notifications, isLoading, fetchMore, hasMore, refetch } = useNotifications();
const showToast = useToastStore((state) => state.showToast);
@@ -155,23 +191,17 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
);
const handleNavigateToReference = (referenceType: string, referenceId: string) => {
- // TODO: Implement navigation based on reference type
- console.log('Navigate to:', referenceType, referenceId);
+ const href = referenceHref(referenceType, referenceId);
+ // Nothing to open: stay in the inbox rather than closing onto the screen the dispatcher was already on.
+ if (!href) return;
+ setSelectedNotification(null);
onClose();
+ router.push(href);
};
- const renderItem = ({ item }: { item: any }) => {
- const notification: NotificationPayload = {
- id: item.id,
- title: item.title,
- body: item.body,
- createdAt: item.createdAt,
- read: item.read,
- type: item.type,
- referenceId: item.payload?.referenceId,
- referenceType: item.payload?.referenceType,
- metadata: item.payload?.metadata,
- };
+ const renderItem = ({ item }: { item: NovuNotification }) => {
+ const notification = mapNovuNotification(item);
+ const unread = !notification.read;
const isSelected = selectedNotificationIds.has(notification.id);
@@ -184,9 +214,9 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
toggleNotificationSelection(notification.id);
}
}}
- style={[styles.notificationItem, !item.read ? styles.unreadNotificationItem : {}, isSelected ? styles.selectedNotificationItem : {}]}
+ style={[styles.notificationItem, unread ? styles.unreadNotificationItem : {}, isSelected ? styles.selectedNotificationItem : {}]}
>
- {!item.read ? : null}
+ {unread ? : null}
{isSelectionMode ? (
@@ -195,16 +225,16 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
) : null}
- {notification.body}
+ {notification.body}
{new Date(notification.createdAt).toLocaleDateString()} {new Date(notification.createdAt).toLocaleTimeString()}
{!isSelectionMode ? (
- notification.referenceType && notification.referenceId ? (
+ hasReferenceRoute(notification.referenceType, notification.referenceId) ? (
- handleNavigateToReference(notification.referenceType!, notification.referenceId!)} variant="outline" className="size-8 p-0">
+ handleNavigateToReference(notification.referenceType!, notification.referenceId!)} variant="outline" className="size-8 p-0" testID={`notification-reference-${notification.id}`}>
@@ -238,7 +268,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
}
// Additional safety check to prevent rendering overlay without proper config
- if (!activeUnitId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl) {
+ if (!userId || !config || !config.NovuApplicationId || !config.NovuBackendApiUrl || !config.NovuSocketUrl) {
return null;
}
@@ -293,7 +323,7 @@ export const NotificationInbox = ({ isOpen, onClose }: NotificationInboxProps) =
- ) : !activeUnitId || !config ? (
+ ) : !userId || !config ? (
Unable to load notifications
diff --git a/src/components/notifications/__tests__/notification-references.test.tsx b/src/components/notifications/__tests__/notification-references.test.tsx
new file mode 100644
index 00000000..4f7b7d72
--- /dev/null
+++ b/src/components/notifications/__tests__/notification-references.test.tsx
@@ -0,0 +1,122 @@
+/**
+ * The real inbox and detail with their data sources mocked (the sibling NotificationInbox.test mocks
+ * the inbox itself). Covers the Novu v2 -> v3 field fix (the inbox read `title`/`read`/`payload`, so rows
+ * had no title, all looked read and never carried a reference), the inbox opening for a dispatcher who
+ * has no active unit, and call/chat links derived from the push event code the Novu bridge puts in `data`.
+ */
+import { fireEvent, render, screen } from '@testing-library/react-native';
+import { router } from 'expo-router';
+import React from 'react';
+
+jest.mock('expo-router', () => ({ router: { push: jest.fn() } }));
+jest.mock('@novu/react-native', () => ({ useNotifications: jest.fn() }));
+jest.mock('@/stores/app/core-store', () => ({ useCoreStore: jest.fn() }));
+jest.mock('@/stores/toast/store', () => ({ useToastStore: jest.fn() }));
+jest.mock('@/lib/auth', () => ({ useAuthStore: jest.fn() }));
+jest.mock('@/api/novu/inbox', () => ({ deleteMessage: jest.fn().mockResolvedValue(undefined) }));
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => ({ 'notifications.view_call': 'View Call', 'notifications.view_chat': 'View Chat', 'notifications.open_reference': 'Open', 'notifications.detail_title': 'Notification' })[key] ?? key,
+ }),
+}));
+
+import { useNotifications } from '@novu/react-native';
+
+import { useAuthStore } from '@/lib/auth';
+import { useCoreStore } from '@/stores/app/core-store';
+import { useToastStore } from '@/stores/toast/store';
+
+import { NotificationDetail } from '../NotificationDetail';
+import { mapNovuNotification, NotificationInbox } from '../NotificationInbox';
+
+type NovuItem = Parameters[0];
+// `read` is a method on a real @novu/js v3 Notification; the old mapping read it as the flag.
+const item = (data: Record | undefined, id = 'n-1', isRead = false) =>
+ ({ id, subject: 'Structure Fire', body: 'Engine 6 respond', createdAt: '2026-09-22T10:00:00Z', isRead, read: () => undefined, data }) as unknown as NovuItem;
+
+const withItems = (items: NovuItem[]) => (useNotifications as jest.Mock).mockReturnValue({ notifications: items, isLoading: false, fetchMore: jest.fn(), hasMore: false, refetch: jest.fn() });
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ // A dispatcher: signed in, no active unit.
+ (useCoreStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) =>
+ selector({ activeUnitId: null, config: { NovuApplicationId: 'app', NovuBackendApiUrl: 'api', NovuSocketUrl: 'socket' } })
+ );
+ (useAuthStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) => selector({ userId: 'dispatcher-1' }));
+ (useToastStore as unknown as jest.Mock).mockImplementation((selector: (state: unknown) => unknown) => selector({ showToast: jest.fn() }));
+});
+
+describe('mapNovuNotification', () => {
+ it('reads the v3 fields and derives call and chat links from the event code', () => {
+ expect(mapNovuNotification(item({ eventCode: 'C1234' }))).toEqual(expect.objectContaining({ title: 'Structure Fire', read: false, referenceType: 'call', referenceId: '1234', metadata: {} }));
+ expect(mapNovuNotification(item({ eventCode: 'g:7f1c' }, 'n-2', true))).toEqual(expect.objectContaining({ read: true, referenceType: 'chat', referenceId: '7f1c' }));
+ // Work orders open only in the Responder app.
+ expect(mapNovuNotification(item({ eventCode: 'NWO:0b7c3e52-2f4a-4d0e-9a57-1f7a0c9d6e11' })).referenceType).toBeUndefined();
+ });
+});
+
+describe('NotificationInbox', () => {
+ it('opens for a signed-in dispatcher without an active unit', () => {
+ withItems([item({ eventCode: '' }, 'plain-1')]);
+
+ render( );
+
+ expect(screen.getByText('Engine 6 respond')).toBeTruthy();
+ expect(screen.queryByTestId('notification-reference-plain-1')).toBeNull();
+ });
+
+ it('opens the dispatched call and closes the inbox', () => {
+ withItems([item({ eventCode: 'C1234' }, 'call-1')]);
+ const onClose = jest.fn();
+
+ render( );
+ fireEvent.press(screen.getByTestId('notification-reference-call-1'));
+
+ expect(router.push).toHaveBeenCalledWith({ pathname: '/call/[id]', params: { id: '1234' } });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('offers no link for a reference type this app cannot open', () => {
+ withItems([item({ referenceType: 'note', referenceId: '55' }, 'note-1')]);
+
+ render( );
+
+ expect(screen.getByText('Engine 6 respond')).toBeTruthy();
+ expect(screen.queryByTestId('notification-reference-note-1')).toBeNull();
+ });
+
+ it('opens the chat conversation', () => {
+ withItems([item({ eventCode: 't:9a2b' }, 'chat-1')]);
+
+ render( );
+ fireEvent.press(screen.getByTestId('notification-reference-chat-1'));
+
+ expect(router.push).toHaveBeenCalledWith({ pathname: '/chat/[channelId]', params: { channelId: '9a2b' } });
+ });
+});
+
+describe('NotificationDetail reference button', () => {
+ const base = { id: 'n-1', title: 'Structure Fire', body: 'Engine 6 respond', createdAt: '2026-09-22T10:00:00Z', read: true };
+
+ it.each([
+ ['call', 'View Call'],
+ ['chat', 'View Chat'],
+ ])('labels a %s reference as %s', (referenceType, label) => {
+ withItems([]);
+ const onNavigateToReference = jest.fn();
+ render( );
+
+ expect(screen.getByText(label)).toBeTruthy();
+ expect(screen.getByText('Notification')).toBeTruthy();
+ fireEvent.press(screen.getByTestId('notification-detail-reference'));
+ expect(onNavigateToReference).toHaveBeenCalledWith(referenceType, 'ref-1');
+ });
+
+ it('offers no link for a reference this app has no screen for', () => {
+ withItems([]);
+ render( );
+
+ expect(screen.getByText('Notification')).toBeTruthy();
+ expect(screen.queryByTestId('notification-detail-reference')).toBeNull();
+ });
+});
diff --git a/src/components/operations/expenses-panel.tsx b/src/components/operations/expenses-panel.tsx
new file mode 100644
index 00000000..36b75fdc
--- /dev/null
+++ b/src/components/operations/expenses-panel.tsx
@@ -0,0 +1,154 @@
+import React, { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert } from 'react-native';
+
+import { OptionSelect } from '@/components/operations/option-select';
+import { Button, ButtonText } from '@/components/ui/button';
+import { HStack } from '@/components/ui/hstack';
+import { Input, InputField } from '@/components/ui/input';
+import { Text } from '@/components/ui/text';
+import { VStack } from '@/components/ui/vstack';
+import { type CapturedPhoto, capturePhoto, discardPhoto, PhotoPermissionError } from '@/lib/media/photo';
+import { dateOf } from '@/lib/operations/time';
+import type { Expense, ExpenseInput } from '@/models/v4/operations';
+import { ExpenseType } from '@/models/v4/operations';
+
+interface ExpensesPanelProps {
+ dateKey: string;
+ currency?: string | null;
+ expenses: Expense[];
+ /** The open report the expense belongs to, when the person may act on it. */
+ reportId: string | null;
+ userId: string | null;
+ canManage: boolean;
+ canAdd: boolean;
+ busy: boolean;
+ onAdd: (input: Omit) => Promise;
+ onRemove: (id: string) => Promise;
+}
+
+const amountOf = (text: string) => {
+ const value = Number(text.replace(',', '.'));
+ return Number.isFinite(value) && value >= 0 ? Math.round(value * 100) / 100 : null;
+};
+
+// Field expenses against the deployment (fuel, meals, lodging, restock) with a receipt photo. The receipt is
+// re-encoded (no GPS or device metadata) and filed by the server as a Receipt attachment; a member removes
+// only the expenses they added.
+export const ExpensesPanel = ({ dateKey, currency, expenses, reportId, userId, canManage, canAdd, busy, onAdd, onRemove }: ExpensesPanelProps) => {
+ const { t } = useTranslation();
+ const [type, setType] = useState(String(ExpenseType.Fuel));
+ const [amount, setAmount] = useState('');
+ const [description, setDescription] = useState('');
+ const [city, setCity] = useState('');
+ const [receipt, setReceipt] = useState(null);
+ const [message, setMessage] = useState(null);
+ const typeOptions = Object.values(ExpenseType).map((value) => ({ value: String(value), label: t(`operations.expenses.type.${value}`) }));
+ const day = expenses.filter((expense) => dateOf(expense.ExpenseDate) === dateKey);
+ const others = expenses.filter((expense) => dateOf(expense.ExpenseDate) !== dateKey);
+
+ const pick = async (source: 'camera' | 'library') => {
+ setMessage(null);
+ try {
+ const photo = await capturePhoto(source, `receipt-${dateKey}.jpg`);
+ if (photo) {
+ await discardPhoto(receipt);
+ setReceipt(photo);
+ }
+ } catch (error) {
+ setMessage(error instanceof PhotoPermissionError ? t('operations.expenses.photoDenied') : t('operations.errors.retry'));
+ }
+ };
+
+ const add = async () => {
+ const value = amountOf(amount);
+ if (value == null) {
+ setMessage(t('operations.expenses.amountInvalid'));
+ return;
+ }
+ const ok = await onAdd({
+ ExpenseDate: dateKey,
+ ExpenseType: Number(type),
+ Amount: value,
+ Currency: currency ?? null,
+ Description: description.trim() || null,
+ City: city.trim() || null,
+ TimeReportId: reportId,
+ ReceiptData: receipt?.base64 ?? null,
+ ReceiptFileName: receipt?.name ?? null,
+ ReceiptContentType: receipt?.contentType ?? null,
+ });
+ if (ok) {
+ await discardPhoto(receipt);
+ setReceipt(null);
+ setAmount('');
+ setDescription('');
+ setCity('');
+ setMessage(t('operations.expenses.saved'));
+ }
+ };
+
+ const confirmRemove = (expense: Expense) =>
+ Alert.alert(t('operations.expenses.removeTitle'), t('operations.expenses.removeBody'), [
+ { text: t('operations.expenses.cancel'), style: 'cancel' },
+ { text: t('operations.expenses.remove'), style: 'destructive', onPress: () => void onRemove(expense.Id) },
+ ]);
+
+ const row = (expense: Expense) => (
+
+
+
+ {t(`operations.expenses.type.${expense.ExpenseType}`)} · {expense.Amount.toFixed(2)} {expense.Currency ?? ''}
+
+
+ {[dateOf(expense.ExpenseDate), expense.Description, expense.City, expense.ReceiptAttachmentId ? t('operations.expenses.hasReceipt') : null].filter(Boolean).join(' · ')}
+
+
+ {canManage || (!!userId && expense.AddedByUserId === userId) ? (
+ confirmRemove(expense)} isDisabled={busy} testID={`operations-expense-remove-${expense.Id}`}>
+ {t('operations.expenses.remove')}
+
+ ) : null}
+
+ );
+
+ return (
+
+ {day.length === 0 ? {t('operations.expenses.noneToday')} : day.map(row)}
+ {canAdd ? (
+
+ {t('operations.expenses.add')}
+
+
+
+
+
+
+
+
+
+
+
+ void pick('camera')} testID="operations-expense-camera">
+ {t('operations.expenses.takePhoto')}
+
+ void pick('library')} testID="operations-expense-library">
+ {t('operations.expenses.choosePhoto')}
+
+
+ {receipt ? {t('operations.expenses.receiptAttached')} : null}
+ void add()} isDisabled={busy || !amount.trim()} testID="operations-expense-save">
+ {t('operations.expenses.save')}
+
+
+ ) : null}
+ {message ? {message} : null}
+ {others.length > 0 ? (
+
+ {t('operations.expenses.otherDays')}
+ {others.map(row)}
+
+ ) : null}
+
+ );
+};
diff --git a/src/components/operations/scope-picker.tsx b/src/components/operations/scope-picker.tsx
new file mode 100644
index 00000000..7345ef26
--- /dev/null
+++ b/src/components/operations/scope-picker.tsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { ScrollView } from 'react-native';
+
+import { Button, ButtonText } from '@/components/ui/button';
+import { HStack } from '@/components/ui/hstack';
+import { Pressable } from '@/components/ui/pressable';
+import { Text } from '@/components/ui/text';
+import { VStack } from '@/components/ui/vstack';
+import { dateOf, reportScope, scopeKey, scopeName, sumHours, type TimeScope } from '@/lib/operations/time';
+import type { Deployment, TimeReport } from '@/models/v4/operations';
+
+export const useScopeLabel = () => {
+ const { t } = useTranslation();
+ return (deployment: Deployment, scope: TimeScope | null) => {
+ if (!scope) return '';
+ if (scope.kind === 'deployment') return t('operations.scope.deployment');
+ if (scope.kind === 'individual' && scope.personnelId === deployment.TimeAccess?.PersonnelId) return t('operations.scope.mine');
+ return scope.kind === 'crew' ? t('operations.scope.crew', { name: scopeName(deployment, scope) }) : t('operations.scope.individual', { name: scopeName(deployment, scope) });
+ };
+};
+
+interface ScopePickerProps {
+ deployment: Deployment;
+ scopes: TimeScope[];
+ current: TimeScope | null;
+ onPick: (scope: TimeScope) => void;
+}
+
+// Which report the person is working on: their crew's Crew Time Report, their own time, or — for a manager —
+// the deployment-wide DTR. Only the scopes the server's TimeAccess grants are offered.
+export const ScopePicker = ({ deployment, scopes, current, onPick }: ScopePickerProps) => {
+ const label = useScopeLabel();
+ if (scopes.length < 2) return null;
+ return (
+
+ {scopes.map((scope) => (
+ onPick(scope)} testID={`operations-scope-${scopeKey(scope)}`}>
+ {label(deployment, scope)}
+
+ ))}
+
+ );
+};
+
+interface DayReportsProps {
+ deployment: Deployment;
+ reports: TimeReport[];
+ openId: string | null;
+ onOpen: (report: TimeReport) => void;
+}
+
+// Every live report on the day (managers and approvers): who it covers, its status and hours, so a supervisor
+// can open each crew's report to review and approve it.
+export const DayReports = ({ deployment, reports, openId, onOpen }: DayReportsProps) => {
+ const { t } = useTranslation();
+ const label = useScopeLabel();
+ if (reports.length === 0) return null;
+ return (
+
+ {t('operations.time.dayReports')}
+ {reports.map((report) => (
+ onOpen(report)} testID={`operations-day-report-${report.Id}`} className={`rounded-lg border p-2 ${report.Id === openId ? 'border-primary-500' : 'border-outline-200'}`}>
+
+
+ #{report.ReportNumber} · {label(deployment, reportScope(report))}
+
+
+ {t(`operations.reportStatus.${report.Status}`)} · {t('operations.time.hours', { hours: sumHours(report.Entries) })}
+
+
+ {dateOf(report.ReportDate)}
+
+ ))}
+
+ );
+};
+
+interface AwaitingApprovalProps {
+ deployment: Deployment;
+ reports: TimeReport[];
+ onOpen: (report: TimeReport) => void;
+}
+
+/** Submitted reports on any day, oldest first: the approver's queue for this deployment. */
+export const AwaitingApproval = ({ deployment, reports, onOpen }: AwaitingApprovalProps) => {
+ const { t } = useTranslation();
+ const label = useScopeLabel();
+ if (reports.length === 0) return null;
+ return (
+
+ {t('operations.time.awaitingApproval', { count: reports.length })}
+ {reports.map((report) => (
+ onOpen(report)} testID={`operations-awaiting-${report.Id}`}>
+
+ {dateOf(report.ReportDate)} · #{report.ReportNumber} · {label(deployment, reportScope(report))}
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/operations/time-report-editor.tsx b/src/components/operations/time-report-editor.tsx
index 9b25d5b6..bed50fc4 100644
--- a/src/components/operations/time-report-editor.tsx
+++ b/src/components/operations/time-report-editor.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { OptionSelect } from '@/components/operations/option-select';
@@ -9,29 +9,41 @@ import { Switch } from '@/components/ui/switch';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import type { EntrySubject } from '@/lib/operations/time';
-import { combineDateTime, entryHours, isClockTime, isReportEditable, newEntry, subjectLabel, sumHours, timeOf } from '@/lib/operations/time';
+import { applyToCrew, combineDateTime, dateOf, endLocalFor, entryHours, hoursBySubject, isClockTime, isReportEditable, newEntry, subjectIdOf, sumHours, timeOf } from '@/lib/operations/time';
import type { TimeEntry, TimeReport, TimeReportIssue } from '@/models/v4/operations';
-import { TimeEntryType } from '@/models/v4/operations';
+import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations';
interface TimeReportEditorProps {
dateKey: string;
report: TimeReport | null;
entries: TimeEntry[];
+ /** Subjects the person may add rows for in this report's scope. */
subjects: EntrySubject[];
+ /** Roster names by subject id, including released members, for stored rows. */
+ names: Record;
+ /** Short name of the crew or person the report covers ("Engine 41", "A. Smith"); empty for a deployment DTR. */
+ scopeLabel: string;
+ isCrew: boolean;
+ /** Set when this person's or crew's time for the day already sits on another report. */
+ coveredBy: TimeReport | null;
issues: TimeReportIssue[];
warnings: TimeReportIssue[];
- canEdit: boolean;
+ canCreate: boolean;
+ canApprove: boolean;
+ canWrite: (subjectId: string) => boolean;
dirty: boolean;
busy: boolean;
onStart: () => void;
onChange: (entries: TimeEntry[]) => void;
onSave: () => void;
onSubmit: () => void;
+ onSign: (crewBossSigned: boolean, customerSignerName?: string | null) => void;
+ onApprove: () => void;
}
// Keeps the keystrokes local and only commits a complete HH:mm, so a half-typed time never lands
// in the entry as an unparseable value.
-const ClockField = ({ value, onCommit, isDisabled, testID }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string }) => {
+const ClockField = ({ value, onCommit, isDisabled, testID, label }: { value: string; onCommit: (clock: string) => void; isDisabled: boolean; testID: string; label: string }) => {
const [text, setText] = useState(value);
useEffect(() => {
setText(value);
@@ -40,6 +52,7 @@ const ClockField = ({ value, onCommit, isDisabled, testID }: { value: string; on
{
+const typeRank = (entry: TimeEntry) => (entry.SubjectType === TimeSubjectType.Unit ? 0 : entry.SubjectType === TimeSubjectType.Personnel ? 1 : 2);
+
+// A crew or individual time report for one day (the paper CTR / OF-288 analog). On a crew report the unit's
+// row leads; "Apply to crew" copies its shift to every crew member and piece of equipment on the same entry
+// type, which is how most crews fill the form, and rows that differ are edited one by one. The crew boss
+// signs, the agency signer's name can be recorded, and the report is submitted for approval.
+export const TimeReportEditor = ({
+ dateKey,
+ report,
+ entries,
+ subjects,
+ names,
+ scopeLabel,
+ isCrew,
+ coveredBy,
+ issues,
+ warnings,
+ canCreate,
+ canApprove,
+ canWrite,
+ dirty,
+ busy,
+ onStart,
+ onChange,
+ onSave,
+ onSubmit,
+ onSign,
+ onApprove,
+}: TimeReportEditorProps) => {
const { t } = useTranslation();
- const editable = canEdit && isReportEditable(report);
+ const [customerSigner, setCustomerSigner] = useState('');
+ const editable = isReportEditable(report);
const typeOptions = [
{ value: String(TimeEntryType.Deployment), label: t('operations.entryType.0') },
{ value: String(TimeEntryType.Standby), label: t('operations.entryType.1') },
{ value: String(TimeEntryType.Travel), label: t('operations.entryType.2') },
];
const subjectOptions = subjects.map((subject) => ({ value: `${subject.type}:${subject.id}`, label: subject.label }));
+ // Unit first, then crew, then equipment; indexes still point into the saved order.
+ const order = useMemo(() => entries.map((entry, index) => ({ entry, index })).sort((a, b) => typeRank(a.entry) - typeRank(b.entry) || a.index - b.index), [entries]);
+ const perSubject = useMemo(() => hoursBySubject(entries), [entries]);
const update = (index: number, patch: Partial) => onChange(entries.map((entry, i) => (i === index ? { ...entry, ...patch } : entry)));
const remove = (index: number) => onChange(entries.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, SortOrder: i })));
const add = () => {
@@ -74,101 +119,152 @@ export const TimeReportEditor = ({ dateKey, report, entries, subjects, issues, w
const blank = newEntry(subject, dateKey, index);
update(index, { SubjectType: blank.SubjectType, DeploymentPersonnelId: blank.DeploymentPersonnelId, DeploymentUnitId: blank.DeploymentUnitId, DeploymentEquipmentId: blank.DeploymentEquipmentId });
};
+ const setStart = (index: number, clock: string) => {
+ const entry = entries[index];
+ const startLocal = combineDateTime(dateOf(entry.StartLocal) || dateKey, clock);
+ update(index, { StartLocal: startLocal, EndLocal: endLocalFor(startLocal, timeOf(entry.EndLocal) || clock) });
+ };
const issueFor = (index: number) => issues.filter((issue) => issue.EntryId === String(index) || (!!entries[index]?.Id && issue.EntryId === entries[index]?.Id));
+ const issueText = (issue: TimeReportIssue) => t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code });
+ const title = !report
+ ? ''
+ : report.Scope === TimeReportScope.Crew
+ ? t('operations.time.crewReport', { number: report.ReportNumber, name: scopeLabel })
+ : report.Scope === TimeReportScope.Individual
+ ? t('operations.time.individualReport', { number: report.ReportNumber, name: scopeLabel })
+ : t('operations.time.report', { number: report.ReportNumber });
if (!report) {
return (
- {t('operations.time.noReport')}
- {canEdit ? (
+ {isCrew ? t('operations.time.noCrewReport', { name: scopeLabel }) : t('operations.time.noReport')}
+ {coveredBy ? {t('operations.time.coveredBy', { number: coveredBy.ReportNumber })} : null}
+ {canCreate && !coveredBy ? (
- {t('operations.time.start')}
+ {isCrew ? t('operations.time.startCrew') : t('operations.time.start')}
) : null}
);
}
+ const signable = report.CanAct && (report.Status === TimeReportStatus.Draft || report.Status === TimeReportStatus.Submitted);
+
return (
- {t('operations.time.report', { number: report.ReportNumber })}
+ {title}
{t(`operations.reportStatus.${report.Status}`)}
+ {!report.CanAct ? {t('operations.time.readOnly')} : null}
{entries.length === 0 ? {t('operations.time.noEntries')} : null}
- {entries.map((entry, index) => (
-
- setSubject(index, value)}
- isDisabled={!editable}
- testID={`operations-entry-subject-${index}`}
- />
- update(index, { EntryType: Number(value) })}
- isDisabled={!editable}
- testID={`operations-entry-type-${index}`}
- />
-
- {t('operations.time.from')}
- update(index, { StartTime: combineDateTime(dateKey, clock) })} isDisabled={!editable} testID={`operations-entry-start-${index}`} />
- {t('operations.time.to')}
- update(index, { EndTime: combineDateTime(dateKey, clock) })} isDisabled={!editable} testID={`operations-entry-end-${index}`} />
-
-
- {t('operations.time.unpaidBreak')}
-
- update(index, { UnpaidBreakMinutes: Math.max(0, Number(text) || 0) })}
- testID={`operations-entry-break-${index}`}
+ {order.map(({ entry, index }) => {
+ const subjectId = subjectIdOf(entry);
+ const rowEditable = editable && canWrite(subjectId);
+ const label = names[subjectId] ?? '';
+ return (
+
+ {entry.Id || !rowEditable ? (
+ {label || t('operations.time.subject')}
+ ) : (
+ setSubject(index, value)}
+ testID={`operations-entry-subject-${index}`}
+ />
+ )}
+ update(index, { EntryType: Number(value) })}
+ isDisabled={!rowEditable}
+ testID={`operations-entry-type-${index}`}
+ />
+
+ {t('operations.time.from')}
+ setStart(index, clock)} isDisabled={!rowEditable} testID={`operations-entry-start-${index}`} />
+ {t('operations.time.to')}
+ update(index, { EndLocal: endLocalFor(entry.StartLocal, clock) })}
+ isDisabled={!rowEditable}
+ testID={`operations-entry-end-${index}`}
/>
+
+ {dateOf(entry.EndLocal) > dateOf(entry.StartLocal) ? {t('operations.time.nextDay')} : null}
+
+ {t('operations.time.unpaidBreak')}
+
+ update(index, { UnpaidBreakMinutes: Math.max(0, Number(text) || 0) })}
+ testID={`operations-entry-break-${index}`}
+ />
+
+
+
+ {t('operations.time.meals')}
+ update(index, { AgencySuppliedMeals: value })} isDisabled={!rowEditable} />
+
+
+ {t('operations.time.accommodation')}
+ update(index, { AgencySuppliedAccommodation: value })} isDisabled={!rowEditable} />
+
+
+ update(index, { Notes: text || null })} testID={`operations-entry-notes-${index}`} />
-
-
- {t('operations.time.meals')}
- update(index, { AgencySuppliedMeals: value })} isDisabled={!editable} />
-
-
- {t('operations.time.accommodation')}
- update(index, { AgencySuppliedAccommodation: value })} isDisabled={!editable} />
-
-
- update(index, { Notes: text || null })} testID={`operations-entry-notes-${index}`} />
-
-
- {t('operations.time.hours', { hours: entryHours(entry) })}
- {editable ? (
- remove(index)} testID={`operations-entry-remove-${index}`}>
- {t('operations.time.remove')}
-
- ) : null}
-
- {issueFor(index).map((issue, i) => (
-
- {t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code })}
-
- ))}
-
- ))}
+
+ {t('operations.time.hours', { hours: entryHours(entry) })}
+ {rowEditable ? (
+
+ {isCrew && entries.length > 1 ? (
+ onChange(applyToCrew(entries, index, canWrite))} testID={`operations-entry-apply-${index}`}>
+ {t('operations.time.applyToCrew')}
+
+ ) : null}
+ remove(index)} testID={`operations-entry-remove-${index}`}>
+ {t('operations.time.remove')}
+
+
+ ) : null}
+
+ {issueFor(index).map((issue, i) => (
+
+ {issueText(issue)}
+
+ ))}
+
+ );
+ })}
{issues
.filter((issue) => !issue.EntryId || !entries.some((entry, index) => issue.EntryId === String(index) || issue.EntryId === entry.Id))
.map((issue, i) => (
- {t(`operations.issues.${issue.Code}`, { defaultValue: issue.Detail ?? issue.Code })}
+ {issueText(issue)}
+ {issue.SubjectId && names[issue.SubjectId] ? ` — ${names[issue.SubjectId]}` : ''}
))}
{warnings.map((warning, i) => (
- {t(`operations.issues.${warning.Code}`, { defaultValue: warning.Detail ?? warning.Code })}
+ {issueText(warning)}
+ {warning.SubjectId && names[warning.SubjectId] ? ` — ${names[warning.SubjectId]}` : ''}
))}
+ {Object.keys(perSubject).length > 1 ? (
+
+ {Object.entries(perSubject).map(([subjectId, hours]) => (
+
+ {names[subjectId] ?? subjectId}
+ {t('operations.time.hours', { hours })}
+
+ ))}
+
+ ) : null}
{t('operations.time.total', { hours: sumHours(entries) })}
{editable ? (
@@ -183,6 +279,43 @@ export const TimeReportEditor = ({ dateKey, report, entries, subjects, issues, w
) : null}
+ {signable || report.ContractorSignedOn || report.CustomerSignedOn ? (
+
+ {t('operations.sign.title')}
+
+ {report.ContractorSignedOn ? t('operations.sign.crewBossSigned', { when: String(report.ContractorSignedOn).slice(0, 16).replace('T', ' ') }) : t('operations.sign.crewBossPending')}
+
+ {report.CustomerSignedOn ? t('operations.sign.customerSigned', { name: report.CustomerSignerName ?? '' }) : t('operations.sign.customerPending')}
+ {signable ? (
+ <>
+ {!report.ContractorSignedOn ? (
+ onSign(true)} isDisabled={busy || dirty} testID="operations-sign-crew-boss">
+ {t('operations.sign.asCrewBoss')}
+
+ ) : null}
+
+
+
+ {
+ onSign(false, customerSigner.trim());
+ setCustomerSigner('');
+ }}
+ isDisabled={busy || dirty || !customerSigner.trim()}
+ testID="operations-sign-customer"
+ >
+ {t('operations.sign.recordCustomer')}
+
+ >
+ ) : null}
+
+ ) : null}
+ {canApprove && report.Status === TimeReportStatus.Submitted ? (
+
+ {t('operations.time.approve')}
+
+ ) : null}
);
};
diff --git a/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx
index 4035edc8..fe49c999 100644
--- a/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx
+++ b/src/components/settings/__tests__/login-info-bottom-sheet-simple.test.tsx
@@ -215,7 +215,7 @@ describe('LoginInfoBottomSheet', () => {
it('calls onClose when cancel button is pressed', () => {
render( );
- const cancelButton = screen.getByText('common.cancel').parent;
+ const cancelButton = screen.getByText('common.cancel').parent!;
fireEvent.press(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
diff --git a/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx b/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx
index 4035edc8..fe49c999 100644
--- a/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx
+++ b/src/components/settings/__tests__/login-info-bottom-sheet.test.tsx
@@ -215,7 +215,7 @@ describe('LoginInfoBottomSheet', () => {
it('calls onClose when cancel button is pressed', () => {
render( );
- const cancelButton = screen.getByText('common.cancel').parent;
+ const cancelButton = screen.getByText('common.cancel').parent!;
fireEvent.press(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
diff --git a/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx
index 514176db..47217758 100644
--- a/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx
+++ b/src/components/settings/__tests__/server-url-bottom-sheet-simple.test.tsx
@@ -29,6 +29,8 @@ jest.mock('react-hook-form', () => ({
control: {},
handleSubmit: jest.fn(),
setValue: jest.fn(),
+ setError: jest.fn(),
+ clearErrors: jest.fn(),
formState: { errors: {} },
}),
Controller: ({ render }: any) => render({ field: { onChange: jest.fn(), value: '' } }),
@@ -42,7 +44,7 @@ jest.mock('@/stores/app/server-url-store', () => ({
}));
jest.mock('@/lib/env', () => ({ Env: { API_VERSION: 'v4' } }));
-jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn() } }));
+jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } }));
jest.mock('@/api/config', () => ({
getSystemConfig: jest.fn().mockResolvedValue({ Data: { Locations: [] } }),
}));
diff --git a/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx
new file mode 100644
index 00000000..d2e07e81
--- /dev/null
+++ b/src/components/settings/__tests__/server-url-bottom-sheet.test.tsx
@@ -0,0 +1,242 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
+import React from 'react';
+
+import { ServerUrlBottomSheet } from '../server-url-bottom-sheet';
+
+const mockGetSystemConfig = jest.fn();
+const mockGetUrl = jest.fn();
+const mockSetUrl = jest.fn();
+let mockOnValueChange: ((value: string) => void) | undefined;
+
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+jest.mock('nativewind', () => ({
+ styled: jest.fn((Component: any) => Component),
+ useColorScheme: () => ({ colorScheme: 'light' }),
+}));
+jest.mock('@/api/config', () => ({
+ getSystemConfig: () => mockGetSystemConfig(),
+}));
+jest.mock('@/stores/app/server-url-store', () => ({
+ useServerUrlStore: () => ({ getUrl: mockGetUrl, setUrl: mockSetUrl }),
+}));
+jest.mock('@/lib/env', () => ({ Env: { API_VERSION: 'v4' } }));
+jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } }));
+jest.mock('lucide-react-native', () => ({ ChevronDownIcon: 'ChevronDownIcon' }));
+
+jest.mock('../../ui/actionsheet', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ const Passthrough = ({ children }: any) => React.createElement(View, {}, children);
+ return {
+ Actionsheet: ({ children, isOpen }: any) => (isOpen ? React.createElement(View, { testID: 'actionsheet' }, children) : null),
+ ActionsheetBackdrop: Passthrough,
+ ActionsheetContent: Passthrough,
+ ActionsheetDragIndicator: Passthrough,
+ ActionsheetDragIndicatorWrapper: Passthrough,
+ };
+});
+jest.mock('../../ui/button', () => {
+ const React = require('react');
+ const { Text, TouchableOpacity, View } = require('react-native');
+ return {
+ Button: ({ children, onPress, disabled }: any) => React.createElement(TouchableOpacity, { onPress, disabled }, children),
+ ButtonText: ({ children }: any) => React.createElement(Text, {}, children),
+ ButtonSpinner: () => React.createElement(View, { testID: 'button-spinner' }),
+ };
+});
+jest.mock('../../ui/form-control', () => {
+ const React = require('react');
+ const { Text, View } = require('react-native');
+ const Passthrough = ({ children }: any) => React.createElement(View, {}, children);
+ return {
+ FormControl: Passthrough,
+ FormControlLabel: Passthrough,
+ FormControlLabelText: ({ children }: any) => React.createElement(Text, {}, children),
+ FormControlHelperText: Passthrough,
+ FormControlError: Passthrough,
+ FormControlErrorText: ({ children }: any) => React.createElement(Text, {}, children),
+ };
+});
+jest.mock('../../ui/select', () => {
+ const React = require('react');
+ const { Text, View } = require('react-native');
+ const Passthrough = ({ children }: any) => React.createElement(View, {}, children);
+ return {
+ Select: ({ children, onValueChange }: any) => {
+ mockOnValueChange = onValueChange;
+ return React.createElement(View, {}, children);
+ },
+ SelectBackdrop: Passthrough,
+ SelectContent: Passthrough,
+ SelectDragIndicator: Passthrough,
+ SelectDragIndicatorWrapper: Passthrough,
+ SelectIcon: Passthrough,
+ SelectInput: ({ value }: any) => React.createElement(Text, { testID: 'select-input' }, value),
+ SelectItem: ({ label, value }: any) => React.createElement(Text, { testID: `select-item-${value}` }, label),
+ SelectPortal: Passthrough,
+ SelectTrigger: Passthrough,
+ };
+});
+jest.mock('../../ui/input', () => {
+ const React = require('react');
+ const { TextInput, View } = require('react-native');
+ return {
+ Input: ({ children }: any) => React.createElement(View, {}, children),
+ InputField: (props: any) => React.createElement(TextInput, { testID: 'server-url-input', ...props }),
+ };
+});
+jest.mock('../../ui/center', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return { Center: ({ children }: any) => React.createElement(View, {}, children) };
+});
+jest.mock('../../ui/hstack', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return { HStack: ({ children }: any) => React.createElement(View, {}, children) };
+});
+jest.mock('../../ui/vstack', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return { VStack: ({ children }: any) => React.createElement(View, {}, children) };
+});
+jest.mock('../../ui/text', () => {
+ const React = require('react');
+ const { Text } = require('react-native');
+ return { Text: ({ children }: any) => React.createElement(Text, {}, children) };
+});
+jest.mock('../../ui/spinner', () => {
+ const React = require('react');
+ const { View } = require('react-native');
+ return { Spinner: () => React.createElement(View, { testID: 'spinner' }) };
+});
+
+const hostedConfig = {
+ Data: {
+ Locations: [
+ { Name: 'US-West', DisplayName: 'Resgrid North America (Global)', LocationInfo: '', IsDefault: true, ApiUrl: 'https://api.resgrid.com', AllowsFreeAccounts: true },
+ { Name: 'EU-Central', DisplayName: 'Resgrid Europe', LocationInfo: '', IsDefault: false, ApiUrl: 'https://api-eu-central.resgrid.com', AllowsFreeAccounts: false },
+ ],
+ },
+};
+
+describe('ServerUrlBottomSheet', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockOnValueChange = undefined;
+ mockGetSystemConfig.mockResolvedValue(hostedConfig);
+ mockGetUrl.mockResolvedValue('https://api.resgrid.com/api/v4');
+ mockSetUrl.mockResolvedValue(undefined);
+ });
+
+ const renderSheet = (props: Partial> = {}) => render( );
+
+ it('lists the US-West and EU-Central sites plus a Custom option', async () => {
+ renderSheet();
+
+ expect(await screen.findByTestId('select-item-US-West')).toHaveTextContent('US-West');
+ expect(screen.getByTestId('select-item-EU-Central')).toHaveTextContent('EU-Central');
+ expect(screen.getByTestId('select-item-__custom__')).toHaveTextContent('settings.custom');
+ });
+
+ it('still lists the hosted sites when the configured server is unreachable', async () => {
+ mockGetSystemConfig.mockRejectedValue(new Error('Network Error'));
+ mockGetUrl.mockResolvedValue('https://offline.example.org/api/v4');
+
+ renderSheet();
+
+ expect(await screen.findByTestId('select-item-US-West')).toBeTruthy();
+ expect(screen.getByTestId('select-item-EU-Central')).toBeTruthy();
+ expect(screen.getByTestId('select-input')).toHaveTextContent('settings.custom');
+ expect(screen.getByTestId('server-url-input').props.value).toBe('https://offline.example.org');
+ });
+
+ it('preselects the hosted site matching the saved url', async () => {
+ mockGetUrl.mockResolvedValue('https://api-eu-central.resgrid.com/api/v4');
+
+ renderSheet();
+
+ await waitFor(() => expect(screen.getByTestId('select-input')).toHaveTextContent('EU-Central'));
+ expect(screen.getByTestId('server-url-input').props.value).toBe('https://api-eu-central.resgrid.com');
+ expect(screen.getByTestId('server-url-input').props.editable).toBe(false);
+ });
+
+ it('saves the selected hosted site and reports the server change', async () => {
+ const onClose = jest.fn();
+ const onUrlChanged = jest.fn().mockResolvedValue(undefined);
+ renderSheet({ onClose, onUrlChanged });
+ await screen.findByTestId('select-item-EU-Central');
+
+ act(() => mockOnValueChange?.('EU-Central'));
+ fireEvent.press(screen.getByText('common.save'));
+
+ await waitFor(() => expect(mockSetUrl).toHaveBeenCalledWith('https://api-eu-central.resgrid.com/api/v4'));
+ await waitFor(() => expect(onUrlChanged).toHaveBeenCalledTimes(1));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('restores the previous url when signing out of the old server fails, so a retry signs out again', async () => {
+ const onClose = jest.fn();
+ const onUrlChanged = jest.fn().mockRejectedValueOnce(new Error('sign out failed')).mockResolvedValue(undefined);
+ let savedUrl = 'https://api.resgrid.com/api/v4';
+ mockGetUrl.mockImplementation(async () => savedUrl);
+ mockSetUrl.mockImplementation(async (url: string) => {
+ savedUrl = url;
+ });
+ renderSheet({ onClose, onUrlChanged });
+ await screen.findByTestId('select-item-EU-Central');
+
+ act(() => mockOnValueChange?.('EU-Central'));
+ fireEvent.press(screen.getByText('common.save'));
+
+ await waitFor(() => expect(mockSetUrl).toHaveBeenLastCalledWith('https://api.resgrid.com/api/v4'));
+ expect(mockSetUrl).toHaveBeenCalledWith('https://api-eu-central.resgrid.com/api/v4');
+ expect(await screen.findByText('sign out failed')).toBeTruthy();
+ expect(onClose).not.toHaveBeenCalled();
+
+ fireEvent.press(screen.getByText('common.save'));
+
+ await waitFor(() => expect(onUrlChanged).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+ });
+
+ it('does not report a change when the same server is saved again', async () => {
+ const onClose = jest.fn();
+ const onUrlChanged = jest.fn();
+ renderSheet({ onClose, onUrlChanged });
+ await waitFor(() => expect(screen.getByTestId('select-input')).toHaveTextContent('US-West'));
+
+ fireEvent.press(screen.getByText('common.save'));
+
+ await waitFor(() => expect(onClose).toHaveBeenCalled());
+ expect(mockSetUrl).toHaveBeenCalledWith('https://api.resgrid.com/api/v4');
+ expect(onUrlChanged).not.toHaveBeenCalled();
+ });
+
+ it('saves a custom url with the api suffix', async () => {
+ const onClose = jest.fn();
+ renderSheet({ onClose });
+ await screen.findByTestId('select-item-__custom__');
+
+ act(() => mockOnValueChange?.('__custom__'));
+ fireEvent.changeText(await screen.findByTestId('server-url-input'), 'https://resgrid.example.org/');
+ fireEvent.press(screen.getByText('common.save'));
+
+ await waitFor(() => expect(mockSetUrl).toHaveBeenCalledWith('https://resgrid.example.org/api/v4'));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('rejects a non-https custom url', async () => {
+ renderSheet();
+ await screen.findByTestId('select-item-__custom__');
+
+ act(() => mockOnValueChange?.('__custom__'));
+ fireEvent.changeText(await screen.findByTestId('server-url-input'), 'http://resgrid.example.org');
+ fireEvent.press(screen.getByText('common.save'));
+
+ expect(await screen.findByText('form.invalid_url')).toBeTruthy();
+ expect(mockSetUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/settings/server-url-bottom-sheet.tsx b/src/components/settings/server-url-bottom-sheet.tsx
index 20147d94..fb27fdf0 100644
--- a/src/components/settings/server-url-bottom-sheet.tsx
+++ b/src/components/settings/server-url-bottom-sheet.tsx
@@ -5,9 +5,8 @@ import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { Platform, ScrollView } from 'react-native';
-import { getSystemConfig } from '@/api/config';
import { logger } from '@/lib/logging';
-import { buildApiUrl, CUSTOM_SERVER_VALUE, toBaseUrl, URL_PATTERN } from '@/lib/server-url';
+import { buildApiUrl, CUSTOM_SERVER_VALUE, findLocationByUrl, isSameServerUrl, loadServerLocations, toBaseUrl, URL_PATTERN } from '@/lib/server-url';
import { type ResgridSystemLocation } from '@/models/v4/configs/getSystemConfigResultData';
import { useServerUrlStore } from '@/stores/app/server-url-store';
@@ -21,6 +20,7 @@ import { Select, SelectBackdrop, SelectContent, SelectDragIndicator, SelectDragI
import { Spinner } from '../ui/spinner';
import { Text } from '../ui/text';
import { VStack } from '../ui/vstack';
+
interface ServerUrlForm {
url: string;
}
@@ -28,9 +28,12 @@ interface ServerUrlForm {
interface ServerUrlBottomSheetProps {
isOpen: boolean;
onClose: () => void;
+ // Called after a save that switched to a different server. The session's tokens belong to
+ // the previous server, so a signed-in caller should log out (as Responder does).
+ onUrlChanged?: () => Promise | void;
}
-export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetProps) {
+export function ServerUrlBottomSheet({ isOpen, onClose, onUrlChanged }: ServerUrlBottomSheetProps) {
const { t } = useTranslation();
const { colorScheme } = useColorScheme();
const [isLoading, setIsLoading] = React.useState(false);
@@ -43,6 +46,8 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
control,
handleSubmit,
setValue,
+ setError,
+ clearErrors,
formState: { errors },
} = useForm();
@@ -57,25 +62,15 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
const loadServers = async () => {
setIsLoadingServers(true);
+ clearErrors();
try {
// The persisted value includes the /api/vX suffix; reduce it to a bare base so we
// can match it against a hosted site or show it for editing in the custom field.
const currentUrl = await getUrl();
const currentBaseUrl = toBaseUrl(currentUrl);
-
- let fetchedLocations: ResgridSystemLocation[] = [];
- try {
- const result = await getSystemConfig();
- fetchedLocations = result?.Data?.Locations ?? [];
- } catch (error) {
- // The list of hosted sites is best-effort; on failure the user can still enter a
- // custom URL manually, so we degrade gracefully instead of blocking the sheet.
- logger.error({
- message: 'Failed to load Resgrid hosted sites',
- context: { error },
- });
- }
+ // Always includes the Resgrid hosted sites, even if the current server can't be reached.
+ const fetchedLocations = await loadServerLocations();
if (isCancelled) {
return;
@@ -85,7 +80,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
// Preselect the hosted site whose API URL matches the persisted URL; otherwise fall
// back to the Custom option and show the persisted URL so the user can edit it.
- const matchedLocation = fetchedLocations.find((location) => toBaseUrl(location.ApiUrl) === currentBaseUrl);
+ const matchedLocation = findLocationByUrl(fetchedLocations, currentUrl);
if (matchedLocation) {
setSelectedServer(matchedLocation.Name);
setValue('url', toBaseUrl(matchedLocation.ApiUrl));
@@ -105,7 +100,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
return () => {
isCancelled = true;
};
- }, [isOpen, getUrl, setValue]);
+ }, [isOpen, getUrl, setValue, clearErrors]);
const handleServerChange = React.useCallback(
(nextServer: string) => {
@@ -130,24 +125,38 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
// the full API URL (with the /api/vX suffix) that the API client expects.
const resolvedBaseUrl = isCustomSelected ? data.url : (location?.ApiUrl ?? data.url);
const apiUrl = buildApiUrl(resolvedBaseUrl);
+ const previousUrl = await getUrl();
await setUrl(apiUrl);
logger.info({
message: 'Server URL updated successfully',
context: { url: apiUrl, server: selectedServer },
});
+
+ if (onUrlChanged && !isSameServerUrl(previousUrl, apiUrl)) {
+ try {
+ await onUrlChanged();
+ } catch (error) {
+ // The session still belongs to the previous server. Put its URL back so a retry sees the change
+ // and signs out again, instead of finding the new URL already saved and closing.
+ await setUrl(previousUrl);
+ throw error;
+ }
+ }
+
onClose();
} catch (error) {
logger.error({
message: 'Failed to update server URL',
context: { error },
});
+ setError('root', { message: error instanceof Error ? error.message : t('common.error') });
} finally {
setIsLoading(false);
}
};
const selectedLocation = locations.find((location) => location.Name === selectedServer);
- const selectedServerLabel = isCustomSelected ? t('settings.custom') : selectedLocation?.DisplayName || selectedLocation?.Name;
+ const selectedServerLabel = isCustomSelected ? t('settings.custom') : selectedLocation?.Name;
return (
@@ -183,7 +192,7 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
{locations.map((location) => (
-
+
))}
@@ -242,6 +251,12 @@ export function ServerUrlBottomSheet({ isOpen, onClose }: ServerUrlBottomSheetPr
+ {errors.root?.message ? (
+
+ {errors.root.message}
+
+ ) : null}
+
{t('common.cancel')}
diff --git a/src/components/status/__tests__/status-bottom-sheet.test.tsx b/src/components/status/__tests__/status-bottom-sheet.test.tsx
index b348550e..ffe1234c 100644
--- a/src/components/status/__tests__/status-bottom-sheet.test.tsx
+++ b/src/components/status/__tests__/status-bottom-sheet.test.tsx
@@ -767,7 +767,7 @@ describe('StatusBottomSheet', () => {
const buttons = screen.getAllByTestId('button');
const submitButton = buttons.find(button => {
try {
- const textElements = button.findAllByType('Text');
+ const textElements = button.findAll((node: any) => node.type === 'Text');
return textElements.some((text: any) => text.props.children === 'Submit');
} catch (e) {
return false;
@@ -798,7 +798,7 @@ describe('StatusBottomSheet', () => {
const buttons = screen.getAllByTestId('button');
const submitButton = buttons.find(button => {
try {
- const textElements = button.findAllByType('Text');
+ const textElements = button.findAll((node: any) => node.type === 'Text');
return textElements.some((text: any) => text.props.children === 'Submit');
} catch (e) {
return false;
diff --git a/src/components/ui/alert-dialog/index.tsx b/src/components/ui/alert-dialog/index.tsx
index 08021c3e..ccd10908 100644
--- a/src/components/ui/alert-dialog/index.tsx
+++ b/src/components/ui/alert-dialog/index.tsx
@@ -94,7 +94,6 @@ const AlertDialogContent = React.forwardRef {
// Only call platform-specific methods when they are supported
if (Platform.OS === 'android') {
try {
- // Make both status bar and navigation bar transparent for edge-to-edge experience
+ // Make both status bar and navigation bar transparent
StatusBar.setBackgroundColor('transparent');
StatusBar.setTranslucent(true);
- // Set navigation bar to be transparent and use overlay behavior
- NavigationBar.setBackgroundColorAsync('transparent')
- .then(() => NavigationBar.setBehaviorAsync('overlay-swipe'))
- .catch(() => {
- // Fallback to hiding navigation bar if overlay behavior is not supported
- NavigationBar.setVisibilityAsync('hidden').catch(() => {
- // Silently handle errors if NavigationBar API is not available
- });
- });
+ // Hide navigation bar only on Android
+ NavigationBar.setVisibilityAsync('hidden').catch(() => {
+ // Silently handle errors if NavigationBar API is not available
+ });
// Set the system UI flags to hide navigation bar
if (hidden) {
diff --git a/src/components/ui/gluestack-ui-provider/index.tsx b/src/components/ui/gluestack-ui-provider/index.tsx
index a585733f..c545cae4 100644
--- a/src/components/ui/gluestack-ui-provider/index.tsx
+++ b/src/components/ui/gluestack-ui-provider/index.tsx
@@ -10,7 +10,7 @@ export function GluestackUIProvider({ mode = 'light', ...props }: { mode?: ModeT
// Both the tokens (--color-*) and the `dark:` variant flip through the prefers-color-scheme media
// query, which react-native-css drives from Appearance — hence the override below.
useLayoutEffect(() => {
- Appearance.setColorScheme(mode === 'system' ? null : mode);
+ Appearance.setColorScheme(mode === 'system' ? 'unspecified' : mode);
}, [mode]);
// This View deliberately carries NO className. It wraps the entire app, and react-native-css wraps
diff --git a/src/components/ui/menu/index.tsx b/src/components/ui/menu/index.tsx
index c3e806d7..89d8142c 100644
--- a/src/components/ui/menu/index.tsx
+++ b/src/components/ui/menu/index.tsx
@@ -118,7 +118,6 @@ const Menu = React.forwardRef, IMenuProps>(fun
ref={ref}
entering={ZoomIn.duration(200).withInitialValues({
transform: [{ scale: 0.9 }],
- opacity: 0,
})}
exiting={FadeOut.duration(200)}
{...props}
diff --git a/src/hooks/__tests__/use-map-live-locations.test.tsx b/src/hooks/__tests__/use-map-live-locations.test.tsx
new file mode 100644
index 00000000..ba0ed264
--- /dev/null
+++ b/src/hooks/__tests__/use-map-live-locations.test.tsx
@@ -0,0 +1,260 @@
+import { act, renderHook } from '@testing-library/react-native';
+import { useState } from 'react';
+
+import { type LiveLocation, type LiveLocations } from '@/lib/live-locations';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+import { useSignalRStore } from '@/stores/signalr/signalr-store';
+
+import { LIVE_LOCATION_REFRESH_DELAY_MS, UNKNOWN_PIN_REFRESH_COOLDOWN_MS, useMapLiveLocations } from '../use-map-live-locations';
+
+// A real zustand store with just the slice the hook reads, so subscriptions behave as in the app.
+jest.mock('@/stores/signalr/signalr-store', () => {
+ const { create } = jest.requireActual('zustand');
+ return { useSignalRStore: create(() => ({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 })) };
+});
+
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
+}));
+
+const T0 = new Date('2026-09-25T14:00:00.000Z').getTime();
+
+const makePin = (id: string, type: number, latitude = 10, longitude = 20): MapMakerInfoData => ({
+ Id: id,
+ Latitude: latitude,
+ Longitude: longitude,
+ Title: id,
+ zIndex: 0,
+ ImagePath: '',
+ InfoWindowContent: '',
+ Color: '',
+ Type: type,
+ PoiImage: '',
+ Marker: '',
+ PoiTypeId: null,
+ PoiTypeName: '',
+ Address: '',
+ Note: '',
+ LayerId: '',
+ LayerName: '',
+});
+
+const REST_PINS = [makePin('c1', 0), makePin('u12', 1), makePin('pABC', 3), makePin('s4', 2)];
+
+const location = (pinId: string, latitude: number, longitude: number, receivedAt = Date.now()): LiveLocation => ({ pinId, latitude, longitude, timestamp: null, receivedAt });
+
+/** Mimics a store push: an immutable update that replaces only the changed entries. */
+const push = (...locations: LiveLocation[]) => {
+ act(() => {
+ const current = useSignalRStore.getState().liveLocations as LiveLocations;
+ const next = { ...current };
+ locations.forEach((entry) => {
+ next[entry.pinId] = entry;
+ });
+ useSignalRStore.setState({ liveLocations: next });
+ });
+};
+
+const renderMap = (requestRefresh: jest.Mock = jest.fn(), enabled = true) => {
+ const hook = renderHook(() => {
+ const [pins, setPins] = useState([]);
+ const live = useMapLiveLocations({ pins, setPins, requestRefresh, enabled });
+ return { pins, setPins, ...live };
+ });
+
+ /** Runs a REST load the way the maps do: note the start, fetch, re-apply live positions, store. */
+ const load = (fetchedPins: MapMakerInfoData[], fetchStartedAt: number) => {
+ act(() => {
+ hook.result.current.setPins(hook.result.current.applyToFetchedPins(fetchedPins, fetchStartedAt));
+ });
+ };
+
+ return { ...hook, load, requestRefresh };
+};
+
+describe('useMapLiveLocations', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(T0);
+ useSignalRStore.setState({ liveLocations: {}, lastGeolocationJoinTimestamp: 0 });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('moves a unit pin in place and keeps every other pin object', () => {
+ const { result, load } = renderMap();
+ load(REST_PINS, Date.now());
+ const before = result.current.pins;
+
+ push(location('u12', 11, 21));
+
+ expect(result.current.pins).not.toBe(before);
+ expect(result.current.pins[1]).toMatchObject({ Id: 'u12', Latitude: 11, Longitude: 21 });
+ expect(result.current.pins[0]).toBe(before[0]);
+ expect(result.current.pins[2]).toBe(before[2]);
+ expect(result.current.pins[3]).toBe(before[3]);
+ });
+
+ it('matches personnel pins case-insensitively', () => {
+ const { result, load } = renderMap();
+ load(REST_PINS, Date.now());
+
+ push(location('pabc', 12, 22));
+
+ expect(result.current.pins[2]).toMatchObject({ Id: 'pABC', Latitude: 12, Longitude: 22 });
+ });
+
+ it('keeps the same array when a push moves nothing on this map', () => {
+ const { result, load } = renderMap();
+ load(REST_PINS, Date.now());
+ const before = result.current.pins;
+
+ push(location('u12', 10, 20));
+
+ expect(result.current.pins).toBe(before);
+ });
+
+ it('never creates a pin from a push and asks for one coalesced refetch instead', () => {
+ const { result, load, requestRefresh } = renderMap();
+ load(REST_PINS, Date.now());
+
+ push(location('u99', 1, 1));
+ push(location('u98', 2, 2));
+
+ expect(result.current.pins).toHaveLength(REST_PINS.length);
+ expect(requestRefresh).not.toHaveBeenCalled();
+
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS);
+ });
+
+ expect(requestRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('lets an unknown pin request a refetch at most once per cooldown', () => {
+ const { load, requestRefresh } = renderMap();
+ load(REST_PINS, Date.now());
+
+ push(location('u99', 1, 1));
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS);
+ });
+ expect(requestRefresh).toHaveBeenCalledTimes(1);
+
+ // Still not visible to this viewer after the refetch: its pushes keep coming.
+ push(location('u99', 1.1, 1.1));
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS * 2);
+ });
+ expect(requestRefresh).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ jest.advanceTimersByTime(UNKNOWN_PIN_REFRESH_COOLDOWN_MS);
+ });
+ push(location('u99', 1.2, 1.2));
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS);
+ });
+ expect(requestRefresh).toHaveBeenCalledTimes(2);
+ });
+
+ it('does not treat pushes before the first load as unknown pins', () => {
+ const { requestRefresh } = renderMap();
+
+ push(location('u12', 11, 21));
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS * 2);
+ });
+
+ expect(requestRefresh).not.toHaveBeenCalled();
+ });
+
+ it('re-applies positions pushed while a refetch was in flight, but not older ones', () => {
+ const { result, load } = renderMap();
+ load(REST_PINS, Date.now());
+
+ // Received before the refetch started: the REST snapshot supersedes it.
+ push(location('pabc', 30, 30));
+ jest.setSystemTime(T0 + 1000);
+ const fetchStartedAt = Date.now();
+ jest.setSystemTime(T0 + 2000);
+ // Received while the request was in flight: newer than what it will return.
+ push(location('u12', 40, 40));
+
+ load(REST_PINS, fetchStartedAt);
+
+ expect(result.current.pins[1]).toMatchObject({ Id: 'u12', Latitude: 40, Longitude: 40 });
+ expect(result.current.pins[2]).toBe(REST_PINS[2]);
+ });
+
+ it('catches up with one refetch after the geolocation hub re-joins', () => {
+ const { load, requestRefresh } = renderMap();
+ load(REST_PINS, Date.now());
+
+ jest.setSystemTime(T0 + 60_000);
+ act(() => useSignalRStore.setState({ lastGeolocationJoinTimestamp: Date.now() }));
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS);
+ });
+
+ expect(requestRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('skips the catch-up when the displayed data was fetched after the join', () => {
+ const { load, requestRefresh } = renderMap();
+ act(() => useSignalRStore.setState({ lastGeolocationJoinTimestamp: Date.now() }));
+ jest.setSystemTime(T0 + 500);
+ load(REST_PINS, Date.now());
+
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS * 2);
+ });
+
+ expect(requestRefresh).not.toHaveBeenCalled();
+ });
+
+ it('catches up when the join landed while the first load was in flight', () => {
+ const { load, requestRefresh } = renderMap();
+ const fetchStartedAt = Date.now();
+ jest.setSystemTime(T0 + 500);
+ act(() => useSignalRStore.setState({ lastGeolocationJoinTimestamp: Date.now() }));
+
+ load(REST_PINS, fetchStartedAt);
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS);
+ });
+
+ expect(requestRefresh).toHaveBeenCalledTimes(1);
+ });
+
+ it('does nothing when disabled', () => {
+ const { result, requestRefresh } = renderMap(jest.fn(), false);
+ act(() => {
+ result.current.setPins(REST_PINS);
+ });
+
+ push(location('u12', 11, 21), location('u99', 1, 1));
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS * 2);
+ });
+
+ expect(result.current.pins).toBe(REST_PINS);
+ expect(result.current.applyToFetchedPins(REST_PINS, 0)).toBe(REST_PINS);
+ expect(requestRefresh).not.toHaveBeenCalled();
+ });
+
+ it('drops a pending refetch when the map unmounts', () => {
+ const { load, requestRefresh, unmount } = renderMap();
+ load(REST_PINS, Date.now());
+ push(location('u99', 1, 1));
+
+ unmount();
+ act(() => {
+ jest.advanceTimersByTime(LIVE_LOCATION_REFRESH_DELAY_MS * 2);
+ });
+
+ expect(requestRefresh).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/__tests__/use-map-signalr-updates.test.ts b/src/hooks/__tests__/use-map-signalr-updates.test.ts
index c991673a..01c15dcd 100644
--- a/src/hooks/__tests__/use-map-signalr-updates.test.ts
+++ b/src/hooks/__tests__/use-map-signalr-updates.test.ts
@@ -100,7 +100,7 @@ describe('useMapSignalRUpdates', () => {
expect(mockGetMapDataAndMarkers).toHaveBeenCalledWith(expect.objectContaining({ aborted: false }));
});
- expect(mockOnMarkersUpdate).toHaveBeenCalledWith(mockMapData.Data.MapMakerInfos);
+ expect(mockOnMarkersUpdate).toHaveBeenCalledWith(mockMapData.Data.MapMakerInfos, expect.any(Number));
});
it('should debounce multiple rapid timestamp changes', async () => {
@@ -176,7 +176,7 @@ describe('useMapSignalRUpdates', () => {
resolveFirstCall!(mockMapData);
await waitFor(() => {
- expect(mockOnMarkersUpdate).toHaveBeenCalledWith(mockMapData.Data.MapMakerInfos);
+ expect(mockOnMarkersUpdate).toHaveBeenCalledWith(mockMapData.Data.MapMakerInfos, expect.any(Number));
});
// Wait for the queued call to be processed
@@ -236,7 +236,7 @@ describe('useMapSignalRUpdates', () => {
resolveFirstCall!(mockMapData);
await waitFor(() => {
- expect(mockOnMarkersUpdate).toHaveBeenCalledWith(mockMapData.Data.MapMakerInfos);
+ expect(mockOnMarkersUpdate).toHaveBeenCalledWith(mockMapData.Data.MapMakerInfos, expect.any(Number));
});
// Wait for the queued call to be processed (should be timestamp3, not timestamp2)
@@ -387,7 +387,7 @@ describe('useMapSignalRUpdates', () => {
expect(mockGetMapDataAndMarkers).toHaveBeenCalledWith(expect.objectContaining({ aborted: false }));
});
- expect(mockOnMarkersUpdate).toHaveBeenCalledWith([]);
+ expect(mockOnMarkersUpdate).toHaveBeenCalledWith([], expect.any(Number));
});
it('should handle null API response', async () => {
@@ -526,4 +526,22 @@ describe('useMapSignalRUpdates', () => {
});
});
});
+
+ it('fetches on demand through a stable requestRefresh and reports when the fetch started', async () => {
+ mockUseSignalRStore.mockReturnValue(0);
+ const startedNoEarlierThan = Date.now();
+
+ const { result, rerender } = renderHook(() => useMapSignalRUpdates(mockOnMarkersUpdate));
+ const { requestRefresh } = result.current;
+ rerender({});
+ expect(result.current.requestRefresh).toBe(requestRefresh);
+
+ requestRefresh();
+
+ await waitFor(() => {
+ expect(mockGetMapDataAndMarkers).toHaveBeenCalledTimes(1);
+ expect(mockOnMarkersUpdate).toHaveBeenCalledWith(mockMapData.Data.MapMakerInfos, expect.any(Number));
+ });
+ expect(mockOnMarkersUpdate.mock.calls[0][1]).toBeGreaterThanOrEqual(startedNoEarlierThan);
+ });
});
diff --git a/src/hooks/__tests__/use-ptt.test.ts b/src/hooks/__tests__/use-ptt.test.ts
index c0622c82..ab698f58 100644
--- a/src/hooks/__tests__/use-ptt.test.ts
+++ b/src/hooks/__tests__/use-ptt.test.ts
@@ -19,12 +19,6 @@ jest.mock('@/stores/app/livekit-store', () => ({
})),
}));
-jest.mock('expo-av', () => ({
- Audio: {
- setAudioModeAsync: jest.fn().mockResolvedValue(undefined),
- },
-}));
-
jest.mock('@/lib/logging', () => ({
logger: {
debug: jest.fn(),
diff --git a/src/hooks/__tests__/use-set-status-for-call.test.ts b/src/hooks/__tests__/use-set-status-for-call.test.ts
new file mode 100644
index 00000000..401bebc8
--- /dev/null
+++ b/src/hooks/__tests__/use-set-status-for-call.test.ts
@@ -0,0 +1,144 @@
+import { act, renderHook } from '@testing-library/react-native';
+
+import { savePersonsStatuses } from '@/api/personnel/personnelStatuses';
+import { saveUnitStatus } from '@/api/units/unitStatuses';
+import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers';
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
+import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
+import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
+import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store';
+import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store';
+import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store';
+
+import { useSetStatusForCall } from '../use-set-status-for-call';
+
+jest.mock('@/api/units/unitStatuses', () => ({
+ saveUnitStatus: jest.fn(),
+}));
+
+jest.mock('@/api/personnel/personnelStatuses', () => ({
+ savePersonsStatuses: jest.fn(),
+}));
+
+jest.mock('@/api/personnel/personnelStaffing', () => ({
+ savePersonsStaffings: jest.fn(),
+}));
+
+const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction;
+const mockSavePersonsStatuses = savePersonsStatuses as jest.MockedFunction;
+
+const selectedCall = { CallId: '777', Number: '26-777', Name: 'MVA', State: 0 } as CallResultData;
+const otherCall = { CallId: '888', Number: '26-888', Name: 'Alarm', State: 0 } as CallResultData;
+const engine = { UnitId: 'e1', Name: 'Engine 1', CurrentDestinationId: '' } as UnitInfoResultData;
+const medic = { UnitId: 'm1', Name: 'Medic 1', CurrentDestinationId: '' } as UnitInfoResultData;
+const firefighter = { UserId: 'u-ff', FirstName: 'Sam', LastName: 'Smith', StatusDestinationId: '' } as PersonnelInfoResultData;
+const onScene = { Id: 6, Text: 'On Scene', Detail: CustomStateDetailType.Calls, Note: 0 } as StatusesResultData;
+// A status whose Detail lists no destinations — the explicit call context still carries the call
+const available = { Id: 0, Text: 'Available', Detail: CustomStateDetailType.None, Note: 0 } as StatusesResultData;
+
+describe('useSetStatusForCall', () => {
+ const setSelectedUnitData = jest.fn();
+ const setSelectedPersonnelData = jest.fn();
+
+ const renderHandlers = () =>
+ renderHook(() =>
+ useSetStatusForCall({
+ calls: [otherCall, selectedCall],
+ units: [engine, medic],
+ personnel: [firefighter],
+ selectedCallId: selectedCall.CallId,
+ setSelectedUnitData,
+ setSelectedPersonnelData,
+ })
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSaveUnitStatus.mockResolvedValue({} as any);
+ mockSavePersonsStatuses.mockResolvedValue({} as any);
+ useUnitActionsStore.getState().reset();
+ usePersonnelActionsStore.getState().reset();
+ useDispatchConsoleStore.setState({ selectedUnitId: null, selectedPersonnelId: null });
+ });
+
+ it('"+" on a unit selects it and opens the unit actions panel with the selected call preset', async () => {
+ const { result } = renderHandlers();
+
+ act(() => {
+ result.current.handleSetUnitStatusForCall('m1');
+ });
+
+ expect(useDispatchConsoleStore.getState().selectedUnitId).toBe('m1');
+ expect(useDispatchConsoleStore.getState().selectedPersonnelId).toBeNull();
+ expect(setSelectedUnitData).toHaveBeenCalledWith(medic);
+ expect(setSelectedPersonnelData).toHaveBeenCalledWith(null);
+
+ const unitActions = useUnitActionsStore.getState();
+ expect(unitActions.isActionsOpen).toBe(true);
+ expect(unitActions.selectedUnit).toBe(medic);
+ expect(unitActions.callContext).toBe(selectedCall);
+ expect(unitActions.statusDestinationType).toBe('call');
+ expect(unitActions.statusSelectedCall).toBe(selectedCall);
+
+ // The dispatcher picks the status and saves through the normal path
+ await act(async () => {
+ await useUnitActionsStore.getState().submitStatus({ status: available });
+ });
+ expect(mockSaveUnitStatus).toHaveBeenCalledWith(expect.objectContaining({ Id: 'm1', Type: '0', RespondingTo: '777', RespondingToType: DestinationEntityType.Call }));
+
+ await act(async () => {
+ await useUnitActionsStore.getState().submitStatus({ status: onScene });
+ });
+ expect(mockSaveUnitStatus).toHaveBeenLastCalledWith(expect.objectContaining({ Id: 'm1', Type: '6', RespondingTo: '777', RespondingToType: DestinationEntityType.Call }));
+ });
+
+ it('"+" on a person selects them and opens the personnel actions panel with the selected call preset', async () => {
+ const { result } = renderHandlers();
+
+ act(() => {
+ result.current.handleSetPersonnelStatusForCall('u-ff');
+ });
+
+ expect(useDispatchConsoleStore.getState().selectedPersonnelId).toBe('u-ff');
+ expect(useDispatchConsoleStore.getState().selectedUnitId).toBeNull();
+ expect(setSelectedPersonnelData).toHaveBeenCalledWith(firefighter);
+ expect(setSelectedUnitData).toHaveBeenCalledWith(null);
+
+ const personnelActions = usePersonnelActionsStore.getState();
+ expect(personnelActions.isActionsOpen).toBe(true);
+ expect(personnelActions.callContext).toBe(selectedCall);
+ expect(personnelActions.statusSelectedCall).toBe(selectedCall);
+
+ await act(async () => {
+ await usePersonnelActionsStore.getState().submitStatus({ status: onScene });
+ });
+ expect(mockSavePersonsStatuses).toHaveBeenCalledWith(expect.objectContaining({ UserIds: ['u-ff'], Type: '6', RespondingTo: '777', RespondingToType: DestinationEntityType.Call }));
+ });
+
+ it('re-targets an already-open panel for the same unit with a new session and the call preset', () => {
+ useUnitActionsStore.getState().openActions(engine);
+ const session = useUnitActionsStore.getState().actionsSessionId;
+ const { result } = renderHandlers();
+
+ act(() => {
+ result.current.handleSetUnitStatusForCall('e1');
+ });
+
+ expect(useUnitActionsStore.getState().actionsSessionId).toBe(session + 1);
+ expect(useUnitActionsStore.getState().callContext).toBe(selectedCall);
+ });
+
+ it('ignores an unknown unit or person', () => {
+ const { result } = renderHandlers();
+
+ act(() => {
+ result.current.handleSetUnitStatusForCall('missing');
+ result.current.handleSetPersonnelStatusForCall('missing');
+ });
+
+ expect(useUnitActionsStore.getState().isActionsOpen).toBe(false);
+ expect(usePersonnelActionsStore.getState().isActionsOpen).toBe(false);
+ expect(setSelectedUnitData).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/hooks/use-map-live-locations.ts b/src/hooks/use-map-live-locations.ts
new file mode 100644
index 00000000..c729f598
--- /dev/null
+++ b/src/hooks/use-map-live-locations.ts
@@ -0,0 +1,170 @@
+import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef } from 'react';
+
+import { applyLiveLocations, type LiveLocations } from '@/lib/live-locations';
+import { logger } from '@/lib/logging';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+import { useSignalRStore } from '@/stores/signalr/signalr-store';
+
+/** Unknown pins and reconnect catch-ups arriving within this window share one background refetch. */
+export const LIVE_LOCATION_REFRESH_DELAY_MS = 4000;
+
+/**
+ * An unknown pin id may request a refetch at most once in this window. The realtime feed covers every entity in
+ * the department, including ones this viewer is not allowed to see on the map; without the cooldown each of
+ * their pushes would refetch the map.
+ */
+export const UNKNOWN_PIN_REFRESH_COOLDOWN_MS = 5 * 60 * 1000;
+
+interface UseMapLiveLocationsOptions {
+ /** The map's current pins (the state `setPins` writes). */
+ pins: MapMakerInfoData[];
+ /** The map's pin state setter; live moves are applied through a functional update. */
+ setPins: Dispatch>;
+ /** Re-runs the map's REST fetch in the background, without moving the camera. */
+ requestRefresh?: () => void;
+ /** Off for maps whose pins are owned by a parent. */
+ enabled?: boolean;
+}
+
+interface UseMapLiveLocationsResult {
+ /**
+ * Call with every REST result before storing it. Re-applies live positions received since that fetch started,
+ * because a push that arrived while the request was in flight is newer than the snapshot it returned.
+ */
+ applyToFetchedPins: (fetchedPins: MapMakerInfoData[], fetchStartedAt: number) => MapMakerInfoData[];
+}
+
+const getChangedPinIds = (next: LiveLocations, previous: LiveLocations): string[] => Object.keys(next).filter((pinId) => next[pinId] !== previous[pinId]);
+
+/**
+ * Moves a map's unit and personnel pins with the geolocation hub's realtime feed.
+ *
+ * Subscribes to the store outside React rendering, so a push costs one functional `setPins` (a no-op that keeps
+ * the same array when nothing on this map moved) rather than a re-render of the whole map screen. Pushes for
+ * pins the map does not have ask for one coalesced background refetch, rate-limited per pin, and so does a
+ * geolocation re-join whose outage this map's data predates.
+ */
+export function useMapLiveLocations({ pins, setPins, requestRefresh, enabled = true }: UseMapLiveLocationsOptions): UseMapLiveLocationsResult {
+ const pinsRef = useRef(pins);
+ const requestRefreshRef = useRef(requestRefresh);
+ const enabledRef = useRef(enabled);
+ // Whether a REST snapshot has landed; before that every push would look like an unknown pin.
+ const hasLoadedRef = useRef(false);
+ // When the fetch behind the displayed snapshot started.
+ const snapshotStartedAtRef = useRef(0);
+ // A join acknowledged before the first snapshot landed, checked against that snapshot's start.
+ const pendingJoinAtRef = useRef(0);
+ const unknownRequestedAtRef = useRef(new Map());
+ const refreshTimerRef = useRef | null>(null);
+
+ useEffect(() => {
+ pinsRef.current = pins;
+ }, [pins]);
+
+ useEffect(() => {
+ requestRefreshRef.current = requestRefresh;
+ }, [requestRefresh]);
+
+ useEffect(() => {
+ enabledRef.current = enabled;
+ }, [enabled]);
+
+ const scheduleRefresh = useCallback((reason: string) => {
+ if (!requestRefreshRef.current || refreshTimerRef.current) {
+ return;
+ }
+ refreshTimerRef.current = setTimeout(() => {
+ refreshTimerRef.current = null;
+ logger.debug({ message: 'Refreshing map pins for realtime locations', context: { reason } });
+ requestRefreshRef.current?.();
+ }, LIVE_LOCATION_REFRESH_DELAY_MS);
+ }, []);
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+
+ const handleLiveLocationsChanged = (next: LiveLocations, previous: LiveLocations) => {
+ const changedPinIds = getChangedPinIds(next, previous);
+ if (changedPinIds.length === 0) {
+ return;
+ }
+
+ setPins((currentPins) => applyLiveLocations(currentPins, next, { onlyPinIds: changedPinIds }).pins);
+
+ if (!hasLoadedRef.current) {
+ return;
+ }
+ const { unknownPinIds } = applyLiveLocations(pinsRef.current, next, { onlyPinIds: changedPinIds });
+ if (unknownPinIds.length === 0) {
+ return;
+ }
+
+ const now = Date.now();
+ let shouldRefresh = false;
+ unknownPinIds.forEach((pinId) => {
+ const lastRequestedAt = unknownRequestedAtRef.current.get(pinId);
+ if (lastRequestedAt === undefined || now - lastRequestedAt >= UNKNOWN_PIN_REFRESH_COOLDOWN_MS) {
+ unknownRequestedAtRef.current.set(pinId, now);
+ shouldRefresh = true;
+ }
+ });
+ if (shouldRefresh) {
+ scheduleRefresh('unknown-pin');
+ }
+ };
+
+ // Positions pushed while the hub was away were never received, so a map whose data predates the
+ // join catches up with one refetch.
+ const handleGeolocationJoined = (joinedAt: number) => {
+ if (!hasLoadedRef.current) {
+ pendingJoinAtRef.current = joinedAt;
+ return;
+ }
+ if (snapshotStartedAtRef.current < joinedAt) {
+ scheduleRefresh('geolocation-join');
+ }
+ };
+
+ const unsubscribe = useSignalRStore.subscribe((state, previousState) => {
+ if (state.liveLocations !== previousState.liveLocations) {
+ handleLiveLocationsChanged(state.liveLocations, previousState.liveLocations);
+ }
+ if (state.lastGeolocationJoinTimestamp !== previousState.lastGeolocationJoinTimestamp && state.lastGeolocationJoinTimestamp > 0) {
+ handleGeolocationJoined(state.lastGeolocationJoinTimestamp);
+ }
+ });
+
+ return () => {
+ unsubscribe();
+ if (refreshTimerRef.current) {
+ clearTimeout(refreshTimerRef.current);
+ refreshTimerRef.current = null;
+ }
+ };
+ }, [enabled, setPins, scheduleRefresh]);
+
+ const applyToFetchedPins = useCallback(
+ (fetchedPins: MapMakerInfoData[], fetchStartedAt: number): MapMakerInfoData[] => {
+ if (!enabledRef.current) {
+ return fetchedPins;
+ }
+
+ hasLoadedRef.current = true;
+ snapshotStartedAtRef.current = fetchStartedAt;
+
+ const pendingJoinAt = pendingJoinAtRef.current;
+ pendingJoinAtRef.current = 0;
+ if (pendingJoinAt > fetchStartedAt) {
+ scheduleRefresh('geolocation-join');
+ }
+
+ // Only positions received after this request went out can be newer than what it returned.
+ return applyLiveLocations(fetchedPins, useSignalRStore.getState().liveLocations, { receivedSince: fetchStartedAt }).pins;
+ },
+ [scheduleRefresh]
+ );
+
+ return { applyToFetchedPins };
+}
diff --git a/src/hooks/use-map-signalr-updates.ts b/src/hooks/use-map-signalr-updates.ts
index cda8504c..fb16ae66 100644
--- a/src/hooks/use-map-signalr-updates.ts
+++ b/src/hooks/use-map-signalr-updates.ts
@@ -9,7 +9,14 @@ import { useSignalRStore } from '@/stores/signalr/signalr-store';
// Debounce delay in milliseconds to prevent rapid consecutive API calls
const DEBOUNCE_DELAY = 1000;
-export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData[]) => void, onPoiLayersUpdate?: (poiLayers: PoiLayerData[]) => void) => {
+/**
+ * Refetches the map's markers (debounced) whenever the update hub reports a change.
+ *
+ * `onMarkersUpdate` receives the fetched markers and the time the request started, so realtime positions received
+ * while it was in flight can be re-applied on top. The returned `requestRefresh` runs the same fetch on demand
+ * (e.g. for a pin the realtime feed knows about but the map does not).
+ */
+export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData[], fetchStartedAt: number) => void, onPoiLayersUpdate?: (poiLayers: PoiLayerData[]) => void) => {
const lastProcessedTimestamp = useRef(0);
const isUpdating = useRef(false);
const pendingTimestamp = useRef(null);
@@ -47,6 +54,7 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData
context: { timestamp: timestampToProcess },
});
+ const fetchStartedAt = Date.now();
const mapDataAndMarkers = await getMapDataAndMarkers(abortController.current.signal);
// Check if request was aborted
@@ -67,7 +75,7 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData
},
});
- onMarkersUpdate(mapDataAndMarkers.Data.MapMakerInfos);
+ onMarkersUpdate(mapDataAndMarkers.Data.MapMakerInfos, fetchStartedAt);
if (onPoiLayersUpdate) {
onPoiLayersUpdate(mapDataAndMarkers.Data.PoiLayers ?? []);
@@ -166,4 +174,16 @@ export const useMapSignalRUpdates = (onMarkersUpdate: (markers: MapMakerInfoData
}
};
}, []);
+
+ // Stable across renders: callers hold on to it from timers and store subscriptions.
+ const fetchAndUpdateMarkersRef = useRef(fetchAndUpdateMarkers);
+ useEffect(() => {
+ fetchAndUpdateMarkersRef.current = fetchAndUpdateMarkers;
+ }, [fetchAndUpdateMarkers]);
+
+ const requestRefresh = useCallback(() => {
+ void fetchAndUpdateMarkersRef.current();
+ }, []);
+
+ return { requestRefresh };
};
diff --git a/src/hooks/use-ptt.ts b/src/hooks/use-ptt.ts
index 61caa08a..7dfd63fe 100644
--- a/src/hooks/use-ptt.ts
+++ b/src/hooks/use-ptt.ts
@@ -1,4 +1,4 @@
-import { Audio } from 'expo-av';
+import { setAudioModeAsync } from 'expo-audio';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, type AppStateStatus, Platform } from 'react-native';
@@ -155,12 +155,12 @@ export function usePTT(options: UsePTTOptions = {}): UsePTTReturn {
}
try {
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: true,
- staysActiveInBackground: true,
- playsInSilentModeIOS: true,
- shouldDuckAndroid: true,
- playThroughEarpieceAndroid: false,
+ await setAudioModeAsync({
+ allowsRecording: true,
+ shouldPlayInBackground: true,
+ playsInSilentMode: true,
+ shouldRouteThroughEarpiece: false,
+ interruptionMode: Platform.OS === 'android' ? 'duckOthers' : 'mixWithOthers',
});
logger.debug({ message: 'PTT: Audio mode configured successfully' });
diff --git a/src/hooks/use-set-status-for-call.ts b/src/hooks/use-set-status-for-call.ts
new file mode 100644
index 00000000..380740c4
--- /dev/null
+++ b/src/hooks/use-set-status-for-call.ts
@@ -0,0 +1,67 @@
+import { useCallback } from 'react';
+
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
+import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
+import { useDispatchConsoleStore } from '@/stores/dispatch/dispatch-console-store';
+import { usePersonnelActionsStore } from '@/stores/dispatch/personnel-actions-store';
+import { useUnitActionsStore } from '@/stores/dispatch/unit-actions-store';
+
+interface UseSetStatusForCallOptions {
+ calls: CallResultData[];
+ units: UnitInfoResultData[];
+ personnel: PersonnelInfoResultData[];
+ selectedCallId: string | null;
+ // The dashboard keeps the selected unit/person entities in local state and hands them to the actions panel
+ setSelectedUnitData: (unit: UnitInfoResultData | null) => void;
+ setSelectedPersonnelData: (person: PersonnelInfoResultData | null) => void;
+}
+
+/**
+ * Handlers for the dispatch dashboard's "+" set-status-for-call buttons (shown on unit/personnel rows
+ * while a call is selected).
+ *
+ * Selects the unit or person and opens the regular unit/personnel actions panel — the same panel a
+ * row selection opens — with the destination preset to the selected call. The dispatcher then picks
+ * the status and saves through the normal API path, which sends `RespondingTo` = the call id and
+ * `RespondingToType` = Call so the server's call reports link the status change to the call.
+ */
+export function useSetStatusForCall({ calls, units, personnel, selectedCallId, setSelectedUnitData, setSelectedPersonnelData }: UseSetStatusForCallOptions) {
+ const setSelectedUnitId = useDispatchConsoleStore((s) => s.setSelectedUnitId);
+ const setSelectedPersonnelId = useDispatchConsoleStore((s) => s.setSelectedPersonnelId);
+ const openUnitActions = useUnitActionsStore((s) => s.openActions);
+ const openPersonnelActions = usePersonnelActionsStore((s) => s.openActions);
+
+ const findSelectedCall = useCallback(() => (selectedCallId ? (calls.find((c) => c.CallId === selectedCallId) ?? null) : null), [calls, selectedCallId]);
+
+ const handleSetUnitStatusForCall = useCallback(
+ (unitId: string) => {
+ const unit = units.find((u) => u.UnitId === unitId);
+ if (!unit) return;
+
+ // Units and personnel are mutually exclusive selections on the dashboard
+ setSelectedPersonnelId(null);
+ setSelectedPersonnelData(null);
+ setSelectedUnitId(unitId);
+ setSelectedUnitData(unit);
+ openUnitActions(unit, { callContext: findSelectedCall() });
+ },
+ [units, findSelectedCall, setSelectedPersonnelId, setSelectedPersonnelData, setSelectedUnitId, setSelectedUnitData, openUnitActions]
+ );
+
+ const handleSetPersonnelStatusForCall = useCallback(
+ (personnelId: string) => {
+ const person = personnel.find((p) => p.UserId === personnelId);
+ if (!person) return;
+
+ setSelectedUnitId(null);
+ setSelectedUnitData(null);
+ setSelectedPersonnelId(personnelId);
+ setSelectedPersonnelData(person);
+ openPersonnelActions(person, { callContext: findSelectedCall() });
+ },
+ [personnel, findSelectedCall, setSelectedUnitId, setSelectedUnitData, setSelectedPersonnelId, setSelectedPersonnelData, openPersonnelActions]
+ );
+
+ return { handleSetUnitStatusForCall, handleSetPersonnelStatusForCall };
+}
diff --git a/src/lib/__tests__/activity-link.test.ts b/src/lib/__tests__/activity-link.test.ts
new file mode 100644
index 00000000..d3dd54c0
--- /dev/null
+++ b/src/lib/__tests__/activity-link.test.ts
@@ -0,0 +1,42 @@
+import { getActivityLinkKind, getActivityLinkKinds, StatusDestinationSources } from '../activity-link';
+
+describe('getActivityLinkKind', () => {
+ it.each([
+ [StatusDestinationSources.CarryForward, 'auto'],
+ [StatusDestinationSources.Dispatch, 'auto'],
+ [StatusDestinationSources.Unit, 'auto'],
+ [StatusDestinationSources.Inferred, 'inferred'],
+ ])('maps source %p to %p', (source, expected) => {
+ expect(getActivityLinkKind(source)).toBe(expected);
+ });
+
+ it.each([[StatusDestinationSources.Explicit], [null], [undefined], [0], [6], [-1]])('returns null for source %p', (source) => {
+ expect(getActivityLinkKind(source)).toBeNull();
+ });
+
+ it('matches the server StatusDestinationSources values', () => {
+ expect(StatusDestinationSources.Explicit).toBe(1);
+ expect(StatusDestinationSources.CarryForward).toBe(2);
+ expect(StatusDestinationSources.Dispatch).toBe(3);
+ expect(StatusDestinationSources.Unit).toBe(4);
+ expect(StatusDestinationSources.Inferred).toBe(5);
+ });
+});
+
+describe('getActivityLinkKinds', () => {
+ it('returns nothing for an empty or missing list', () => {
+ expect(getActivityLinkKinds(undefined)).toEqual([]);
+ expect(getActivityLinkKinds(null)).toEqual([]);
+ expect(getActivityLinkKinds([])).toEqual([]);
+ });
+
+ it('returns nothing when no entry is marked', () => {
+ expect(getActivityLinkKinds([{ DestinationSource: 1 }, { DestinationSource: null }, {}])).toEqual([]);
+ });
+
+ it('returns each present kind once, auto before inferred', () => {
+ expect(getActivityLinkKinds([{ DestinationSource: 5 }, { DestinationSource: 2 }, { DestinationSource: 4 }, { DestinationSource: 5 }])).toEqual(['auto', 'inferred']);
+ expect(getActivityLinkKinds([{ DestinationSource: 3 }, { DestinationSource: 1 }])).toEqual(['auto']);
+ expect(getActivityLinkKinds([{ DestinationSource: 5 }])).toEqual(['inferred']);
+ });
+});
diff --git a/src/lib/__tests__/destination-helpers.test.ts b/src/lib/__tests__/destination-helpers.test.ts
index 2b0eeee6..3ff439c8 100644
--- a/src/lib/__tests__/destination-helpers.test.ts
+++ b/src/lib/__tests__/destination-helpers.test.ts
@@ -8,10 +8,14 @@ import {
getDestinationCapabilities,
getDestinationSelectionTypeFromValue,
getDestinationSelectionTypeValue,
+ getEffectiveDestinationType,
getEnabledDestinationTabs,
getSelectedDestinationId,
+ getStatusDestinationCapabilities,
+ getStatusDestinationPayload,
isCallMarker,
isPoiDestinationType,
+ resolveDefaultDestinationCall,
} from '../destination-helpers';
describe('destination-helpers', () => {
@@ -53,4 +57,66 @@ describe('destination-helpers', () => {
expect(isCallMarker(MapMarkerEntityType.Call)).toBe(true);
expect(isCallMarker(undefined, 'call')).toBe(true);
});
+
+ describe('status destination filtering', () => {
+ const call = { CallId: 'call-7', Number: '7', Name: 'Structure Fire', State: 0 } as any;
+ const station = { GroupId: 'station-3', Name: 'Station 3' } as any;
+ const callSelection = { selectedDestinationType: 'call' as const, selectedCall: call, selectedStation: null, selectedPoi: null };
+ const stationSelection = { selectedDestinationType: 'station' as const, selectedCall: null, selectedStation: station, selectedPoi: null };
+
+ it('widens capabilities to calls only for an explicit call context', () => {
+ expect(getStatusDestinationCapabilities(CustomStateDetailType.None)).toEqual({ showCalls: false, showStations: false, showPois: false, supportsDestination: false });
+ expect(getStatusDestinationCapabilities(CustomStateDetailType.None, true)).toEqual({ showCalls: true, showStations: false, showPois: false, supportsDestination: true });
+ expect(getStatusDestinationCapabilities(CustomStateDetailType.Stations, true)).toEqual({ showCalls: true, showStations: true, showPois: false, supportsDestination: true });
+ });
+
+ it('sends a call destination as RespondingTo = CallId / RespondingToType = Call when the status supports calls', () => {
+ expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.Calls)).toEqual({ respondingTo: 'call-7', respondingToType: DestinationEntityType.Call });
+ expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.CallsAndStations)).toEqual({ respondingTo: 'call-7', respondingToType: DestinationEntityType.Call });
+ });
+
+ it('drops a leftover destination the status does not support', () => {
+ expect(getEffectiveDestinationType(callSelection, CustomStateDetailType.Stations)).toBe('none');
+ expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.Stations)).toEqual({ respondingTo: '', respondingToType: null });
+ expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.None)).toEqual({ respondingTo: '', respondingToType: null });
+ expect(getStatusDestinationPayload(stationSelection, CustomStateDetailType.Calls)).toEqual({ respondingTo: '', respondingToType: null });
+ expect(getStatusDestinationPayload(stationSelection, CustomStateDetailType.Stations)).toEqual({ respondingTo: 'station-3', respondingToType: DestinationEntityType.Station });
+ });
+
+ it('keeps the call for an explicit call context even when the status Detail does not list calls', () => {
+ expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.None, true)).toEqual({ respondingTo: 'call-7', respondingToType: DestinationEntityType.Call });
+ expect(getStatusDestinationPayload(callSelection, CustomStateDetailType.Stations, true)).toEqual({ respondingTo: 'call-7', respondingToType: DestinationEntityType.Call });
+ // The widening is for calls only — a station still needs a status that supports stations
+ expect(getStatusDestinationPayload(stationSelection, CustomStateDetailType.None, true)).toEqual({ respondingTo: '', respondingToType: null });
+ });
+
+ it('never reports a destination for an empty selection', () => {
+ expect(getEffectiveDestinationType({ selectedDestinationType: 'call', selectedCall: null, selectedStation: null, selectedPoi: null }, CustomStateDetailType.Calls)).toBe('none');
+ expect(getEffectiveDestinationType({ selectedDestinationType: 'none', selectedCall: null, selectedStation: null, selectedPoi: null }, CustomStateDetailType.Calls, true)).toBe('none');
+ });
+ });
+
+ describe('resolveDefaultDestinationCall', () => {
+ const callA = { CallId: 'A', State: 0 } as any;
+ const callB = { CallId: 'B', State: 0 } as any;
+ const contextCall = { CallId: 'CTX', State: 0 } as any;
+
+ it('prefers the explicit call context over everything else', () => {
+ expect(resolveDefaultDestinationCall({ callContext: contextCall, currentDestinationId: 'A', selectedCallId: 'B', activeCalls: [callA, callB] })).toBe(contextCall);
+ });
+
+ it("falls back to the unit's/person's current destination when it is an active call", () => {
+ expect(resolveDefaultDestinationCall({ currentDestinationId: 'A', selectedCallId: 'B', activeCalls: [callA, callB] })).toBe(callA);
+ });
+
+ it("uses the console's selected call when the current destination is not an active call", () => {
+ expect(resolveDefaultDestinationCall({ currentDestinationId: 'closed-or-station', selectedCallId: 'B', activeCalls: [callA, callB] })).toBe(callB);
+ expect(resolveDefaultDestinationCall({ currentDestinationId: '', selectedCallId: 'B', activeCalls: [callA, callB] })).toBe(callB);
+ });
+
+ it('returns null when neither the current destination nor the selected call is active', () => {
+ expect(resolveDefaultDestinationCall({ currentDestinationId: 'X', selectedCallId: 'Y', activeCalls: [callA, callB] })).toBeNull();
+ expect(resolveDefaultDestinationCall({ activeCalls: [] })).toBeNull();
+ });
+ });
});
diff --git a/src/lib/__tests__/live-locations.test.ts b/src/lib/__tests__/live-locations.test.ts
new file mode 100644
index 00000000..c3c00526
--- /dev/null
+++ b/src/lib/__tests__/live-locations.test.ts
@@ -0,0 +1,231 @@
+import { applyLiveLocations, isValidLiveCoordinate, type LiveLocation, type LiveLocations, parsePersonnelLocationUpdate, parseUnitLocationUpdate, upsertLiveLocation } from '@/lib/live-locations';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+
+const RECEIVED_AT = 1_758_808_991_000;
+const GUID = '6F9619FF-8B86-D011-B42D-00C04FC964FF';
+
+const makePin = (overrides: Partial): MapMakerInfoData => ({
+ Id: 'u1',
+ Longitude: -74.006,
+ Latitude: 40.7128,
+ Title: 'Pin',
+ zIndex: 0,
+ ImagePath: '',
+ InfoWindowContent: '',
+ Color: '',
+ Type: 1,
+ PoiImage: '',
+ Marker: '',
+ PoiTypeId: null,
+ PoiTypeName: '',
+ Address: '',
+ Note: '',
+ LayerId: '',
+ LayerName: '',
+ ...overrides,
+});
+
+const makeLocation = (overrides: Partial): LiveLocation => ({
+ pinId: 'u12',
+ latitude: 41,
+ longitude: -73,
+ timestamp: null,
+ receivedAt: RECEIVED_AT,
+ ...overrides,
+});
+
+describe('parseUnitLocationUpdate', () => {
+ it('parses the camelCase payload the hub sends', () => {
+ const payload = { departmentId: 1, unitId: '12', latitude: 39.1, longitude: -119.8, recordId: 'r1', timestamp: '2026-09-25T14:03:11.123Z' };
+
+ expect(parseUnitLocationUpdate(payload, RECEIVED_AT)).toEqual({
+ pinId: 'u12',
+ latitude: 39.1,
+ longitude: -119.8,
+ timestamp: Date.parse('2026-09-25T14:03:11.123Z'),
+ receivedAt: RECEIVED_AT,
+ });
+ });
+
+ it('accepts PascalCase fields and a numeric unit id', () => {
+ const payload = { DepartmentId: 1, UnitId: 12, Latitude: 39.1, Longitude: -119.8, Timestamp: '2026-09-25T14:03:11Z' };
+
+ expect(parseUnitLocationUpdate(payload, RECEIVED_AT)).toMatchObject({ pinId: 'u12', latitude: 39.1, longitude: -119.8, timestamp: Date.parse('2026-09-25T14:03:11Z') });
+ });
+
+ it('accepts the payload as a JSON string', () => {
+ const payload = JSON.stringify({ unitId: '7', latitude: '39.5', longitude: '-119.5' });
+
+ expect(parseUnitLocationUpdate(payload, RECEIVED_AT)).toMatchObject({ pinId: 'u7', latitude: 39.5, longitude: -119.5 });
+ });
+
+ it.each([
+ ['a missing timestamp', { unitId: '1', latitude: 39, longitude: -119 }],
+ ['a null timestamp', { unitId: '1', latitude: 39, longitude: -119, timestamp: null }],
+ ['an unparseable timestamp', { unitId: '1', latitude: 39, longitude: -119, timestamp: 'not a date' }],
+ ])('treats %s as unknown', (_label, payload) => {
+ expect(parseUnitLocationUpdate(payload, RECEIVED_AT)?.timestamp).toBeNull();
+ });
+
+ it.each([
+ ['latitude out of range', { unitId: '1', latitude: 91, longitude: 10 }],
+ ['longitude out of range', { unitId: '1', latitude: 10, longitude: -180.5 }],
+ ['0,0', { unitId: '1', latitude: 0, longitude: 0 }],
+ ['a non-finite coordinate', { unitId: '1', latitude: Number.NaN, longitude: 10 }],
+ ['an empty coordinate string', { unitId: '1', latitude: '', longitude: 10 }],
+ ['a missing unit id', { latitude: 10, longitude: 10 }],
+ ['a blank unit id', { unitId: ' ', latitude: 10, longitude: 10 }],
+ ])('rejects %s', (_label, payload) => {
+ expect(parseUnitLocationUpdate(payload, RECEIVED_AT)).toBeNull();
+ });
+
+ it.each([null, undefined, 42, 'not json', '[1,2]', []])('rejects an unusable payload (%p)', (payload) => {
+ expect(parseUnitLocationUpdate(payload, RECEIVED_AT)).toBeNull();
+ });
+});
+
+describe('parsePersonnelLocationUpdate', () => {
+ it('lower-cases the user id into a p-prefixed pin id', () => {
+ const payload = { departmentId: 1, userId: GUID, latitude: 39.1, longitude: -119.8, recordId: 'r1', timestamp: null };
+
+ expect(parsePersonnelLocationUpdate(payload, RECEIVED_AT)).toEqual({ pinId: `p${GUID.toLowerCase()}`, latitude: 39.1, longitude: -119.8, timestamp: null, receivedAt: RECEIVED_AT });
+ });
+
+ it('accepts PascalCase fields', () => {
+ expect(parsePersonnelLocationUpdate({ UserId: GUID, Latitude: 1.5, Longitude: 2.5 }, RECEIVED_AT)).toMatchObject({ pinId: `p${GUID.toLowerCase()}`, latitude: 1.5, longitude: 2.5 });
+ });
+
+ it('does not read a unit id as a user id', () => {
+ expect(parsePersonnelLocationUpdate({ unitId: '12', latitude: 1.5, longitude: 2.5 }, RECEIVED_AT)).toBeNull();
+ });
+});
+
+describe('isValidLiveCoordinate', () => {
+ it('accepts the range edges but not Null Island', () => {
+ expect(isValidLiveCoordinate(90, 180)).toBe(true);
+ expect(isValidLiveCoordinate(-90, -180)).toBe(true);
+ expect(isValidLiveCoordinate(0, 12)).toBe(true);
+ expect(isValidLiveCoordinate(0, 0)).toBe(false);
+ expect(isValidLiveCoordinate(Number.POSITIVE_INFINITY, 0)).toBe(false);
+ });
+});
+
+describe('upsertLiveLocation', () => {
+ it('adds a new entity immutably', () => {
+ const current: LiveLocations = {};
+ const next = upsertLiveLocation(current, makeLocation({}));
+
+ expect(next).not.toBe(current);
+ expect(current).toEqual({});
+ expect(next.u12).toMatchObject({ latitude: 41, longitude: -73 });
+ });
+
+ it('ignores a fix older than the one already held', () => {
+ const current: LiveLocations = { u12: makeLocation({ timestamp: 2000 }) };
+
+ expect(upsertLiveLocation(current, makeLocation({ timestamp: 1000, latitude: 1 }))).toBe(current);
+ });
+
+ it('applies a newer fix', () => {
+ const current: LiveLocations = { u12: makeLocation({ timestamp: 1000 }) };
+ const next = upsertLiveLocation(current, makeLocation({ timestamp: 2000, latitude: 42 }));
+
+ expect(next.u12.latitude).toBe(42);
+ });
+
+ it('always applies a fix with an unknown time, and a known fix after an unknown one', () => {
+ const current: LiveLocations = { u12: makeLocation({ timestamp: 2000 }) };
+ const withUnknown = upsertLiveLocation(current, makeLocation({ timestamp: null, latitude: 43 }));
+ expect(withUnknown.u12.latitude).toBe(43);
+
+ const withKnown = upsertLiveLocation(withUnknown, makeLocation({ timestamp: 1000, latitude: 44 }));
+ expect(withKnown.u12.latitude).toBe(44);
+ });
+
+ it('drops an identical repeat of the same fix', () => {
+ const current: LiveLocations = { u12: makeLocation({ timestamp: 2000 }) };
+
+ expect(upsertLiveLocation(current, makeLocation({ timestamp: 2000, receivedAt: RECEIVED_AT + 5 }))).toBe(current);
+ });
+});
+
+describe('applyLiveLocations', () => {
+ const pins = [makePin({ Id: 'c5', Type: 0 }), makePin({ Id: 'u12', Type: 1 }), makePin({ Id: `p${GUID}`, Type: 3 }), makePin({ Id: 's3', Type: 2 })];
+
+ it('returns the same array when there is nothing to apply', () => {
+ const result = applyLiveLocations(pins, {});
+
+ expect(result.pins).toBe(pins);
+ expect(result.unknownPinIds).toEqual([]);
+ });
+
+ it('returns the same array when the live position equals the pin position', () => {
+ const live: LiveLocations = { u12: makeLocation({ latitude: 40.7128, longitude: -74.006 }) };
+
+ expect(applyLiveLocations(pins, live).pins).toBe(pins);
+ });
+
+ it('moves a pin immutably, replacing only the moved pin', () => {
+ const live: LiveLocations = { u12: makeLocation({ latitude: 41, longitude: -73 }) };
+ const result = applyLiveLocations(pins, live);
+
+ expect(result.pins).not.toBe(pins);
+ expect(result.pins[1]).not.toBe(pins[1]);
+ expect(result.pins[1]).toMatchObject({ Id: 'u12', Latitude: 41, Longitude: -73, Title: 'Pin' });
+ expect(pins[1]).toMatchObject({ Latitude: 40.7128, Longitude: -74.006 });
+ expect(result.pins[0]).toBe(pins[0]);
+ expect(result.pins[2]).toBe(pins[2]);
+ expect(result.pins[3]).toBe(pins[3]);
+ });
+
+ it('matches personnel pin ids case-insensitively', () => {
+ const live: LiveLocations = { [`p${GUID.toLowerCase()}`]: makeLocation({ pinId: `p${GUID.toLowerCase()}`, latitude: 1, longitude: 2 }) };
+ const result = applyLiveLocations(pins, live);
+
+ expect(result.pins[2]).toMatchObject({ Id: `p${GUID}`, Latitude: 1, Longitude: 2 });
+ expect(result.unknownPinIds).toEqual([]);
+ });
+
+ it('moves the bare-id unit and personnel pins of a server from before pin ids were prefixed', () => {
+ const legacyPins = [makePin({ Id: '12', Type: 1 }), makePin({ Id: GUID, Type: 3 })];
+ const live: LiveLocations = { u12: makeLocation({ latitude: 3, longitude: 4 }), [`p${GUID.toLowerCase()}`]: makeLocation({ pinId: `p${GUID.toLowerCase()}`, latitude: 5, longitude: 6 }) };
+ const result = applyLiveLocations(legacyPins, live);
+
+ expect(result.pins[0]).toMatchObject({ Id: '12', Latitude: 3, Longitude: 4 });
+ expect(result.pins[1]).toMatchObject({ Id: GUID, Latitude: 5, Longitude: 6 });
+ expect(result.unknownPinIds).toEqual([]);
+ });
+
+ it('never adds pins and reports pushes for pins it does not have', () => {
+ const live: LiveLocations = { u99: makeLocation({ pinId: 'u99' }) };
+ const result = applyLiveLocations(pins, live);
+
+ expect(result.pins).toBe(pins);
+ expect(result.pins).toHaveLength(4);
+ expect(result.unknownPinIds).toEqual(['u99']);
+ });
+
+ it('only moves unit and personnel pins', () => {
+ const typedPins = [makePin({ Id: 'u12', Type: 0 }), makePin({ Id: 'p1', Type: 4 })];
+ const live: LiveLocations = { u12: makeLocation({}), p1: makeLocation({ pinId: 'p1' }) };
+ const result = applyLiveLocations(typedPins, live);
+
+ expect(result.pins).toBe(typedPins);
+ expect(result.unknownPinIds).toEqual(['u12', 'p1']);
+ });
+
+ it('ignores positions received before a fresh snapshot started', () => {
+ const live: LiveLocations = { u12: makeLocation({ receivedAt: RECEIVED_AT - 1 }) };
+
+ expect(applyLiveLocations(pins, live, { receivedSince: RECEIVED_AT }).pins).toBe(pins);
+ expect(applyLiveLocations(pins, live, { receivedSince: RECEIVED_AT - 1 }).pins[1]).toMatchObject({ Latitude: 41 });
+ });
+
+ it('can be limited to the entries that just changed', () => {
+ const live: LiveLocations = { u12: makeLocation({}), [`p${GUID.toLowerCase()}`]: makeLocation({ pinId: `p${GUID.toLowerCase()}`, latitude: 5, longitude: 6 }) };
+ const result = applyLiveLocations(pins, live, { onlyPinIds: ['U12'] });
+
+ expect(result.pins[1]).toMatchObject({ Latitude: 41 });
+ expect(result.pins[2]).toBe(pins[2]);
+ });
+});
diff --git a/src/lib/__tests__/map-markers-web.test.ts b/src/lib/__tests__/map-markers-web.test.ts
new file mode 100644
index 00000000..35760c9b
--- /dev/null
+++ b/src/lib/__tests__/map-markers-web.test.ts
@@ -0,0 +1,28 @@
+import { buildMapPinPopupHtml } from '@/lib/map-markers-web';
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+
+const pin = (patch: Partial = {}): MapMakerInfoData =>
+ ({ Id: 'u12', Title: 'Engine 41', Latitude: 39.5, Longitude: -119.8, Address: '', Note: '', PoiTypeName: '', InfoWindowContent: '', ...patch }) as MapMakerInfoData;
+
+describe('buildMapPinPopupHtml', () => {
+ it('shows the title, summary and coordinates', () => {
+ const html = buildMapPinPopupHtml(pin({ Address: '123 Main St' }));
+
+ expect(html).toContain('>Engine 41');
+ expect(html).toContain('>123 Main St
');
+ expect(html).toContain('39.500000, -119.800000');
+ });
+
+ it('escapes markup in the title and summary so it shows as text instead of running', () => {
+ const html = buildMapPinPopupHtml(pin({ Title: ' ', InfoWindowContent: '' }));
+
+ expect(html).not.toContain(' {
+ expect(buildMapPinPopupHtml(pin())).not.toContain('font-size: 12px');
+ });
+});
diff --git a/src/lib/__tests__/map-pin-ids.test.ts b/src/lib/__tests__/map-pin-ids.test.ts
new file mode 100644
index 00000000..c65d1462
--- /dev/null
+++ b/src/lib/__tests__/map-pin-ids.test.ts
@@ -0,0 +1,69 @@
+import { getPinEntityId, isCallPin, isPinForCall, MapPinType } from '../map-pin-ids';
+
+const personId = '4f1c2b7e-0000-4000-8000-00000000abcd';
+
+describe('map pin ids', () => {
+ describe('getPinEntityId', () => {
+ it.each([
+ ['c123', MapPinType.Call, '123'],
+ ['u12', MapPinType.Unit, '12'],
+ ['s5', MapPinType.Station, '5'],
+ [`p${personId}`, MapPinType.Personnel, personId],
+ ['poi9', MapPinType.Poi, '9'],
+ ['hydrant-44', MapPinType.Hydrant, '44'],
+ ])('strips the type prefix from %s', (id, type, expected) => {
+ expect(getPinEntityId({ Id: id, Type: type })).toBe(expected);
+ });
+
+ it('matches the prefix case-insensitively', () => {
+ expect(getPinEntityId({ Id: 'C123', Type: MapPinType.Call })).toBe('123');
+ });
+
+ it.each([
+ ['123', MapPinType.Call],
+ ['12', MapPinType.Unit],
+ ['9', MapPinType.Poi],
+ [personId, MapPinType.Personnel],
+ ])('leaves the legacy unprefixed id %s unchanged', (id, type) => {
+ expect(getPinEntityId({ Id: id, Type: type })).toBe(id);
+ });
+
+ it('does not strip a prefix from a numeric type when the rest is not a number', () => {
+ expect(getPinEntityId({ Id: 'cat', Type: MapPinType.Call })).toBe('cat');
+ expect(getPinEntityId({ Id: 'poison', Type: MapPinType.Poi })).toBe('poison');
+ });
+
+ it('does not strip a prefix that belongs to another pin type', () => {
+ expect(getPinEntityId({ Id: 'u12', Type: MapPinType.Call })).toBe('u12');
+ });
+
+ it('leaves ids of unknown pin types and empty ids alone', () => {
+ expect(getPinEntityId({ Id: 'x7', Type: 99 })).toBe('x7');
+ expect(getPinEntityId({ Id: '', Type: MapPinType.Call })).toBe('');
+ expect(getPinEntityId({ Id: 'c', Type: MapPinType.Call })).toBe('c');
+ expect(getPinEntityId({ Id: undefined as unknown as string, Type: MapPinType.Call })).toBe('');
+ });
+ });
+
+ describe('isCallPin', () => {
+ it('is true only for call pins', () => {
+ expect(isCallPin({ Type: MapPinType.Call })).toBe(true);
+ expect(isCallPin({ Type: MapPinType.Unit })).toBe(false);
+ expect(isCallPin({ Type: MapPinType.Personnel })).toBe(false);
+ });
+ });
+
+ describe('isPinForCall', () => {
+ it('matches a prefixed or legacy call pin against the bare call id', () => {
+ expect(isPinForCall({ Id: 'c123', Type: MapPinType.Call }, '123')).toBe(true);
+ expect(isPinForCall({ Id: '123', Type: MapPinType.Call }, 123)).toBe(true);
+ });
+
+ it('does not match other calls, other pin types or a missing call id', () => {
+ expect(isPinForCall({ Id: 'c123', Type: MapPinType.Call }, '124')).toBe(false);
+ expect(isPinForCall({ Id: 'u123', Type: MapPinType.Unit }, '123')).toBe(false);
+ expect(isPinForCall({ Id: 'c123', Type: MapPinType.Call }, null)).toBe(false);
+ expect(isPinForCall({ Id: 'c123', Type: MapPinType.Call }, '')).toBe(false);
+ });
+ });
+});
diff --git a/src/lib/__tests__/poi-map-layers.test.ts b/src/lib/__tests__/poi-map-layers.test.ts
new file mode 100644
index 00000000..5b10f762
--- /dev/null
+++ b/src/lib/__tests__/poi-map-layers.test.ts
@@ -0,0 +1,31 @@
+import { createDefaultVisiblePoiLayerIds, mergeVisiblePoiLayerIds } from '@/lib/poi-map-layers';
+import { type PoiLayerData } from '@/models/v4/mapping/poiLayerData';
+
+const layer = (poiTypeId: number): PoiLayerData => ({ PoiTypeId: poiTypeId, Name: `Layer ${poiTypeId}`, Color: '', ImagePath: '', PoiImage: '', Marker: '', IsDestination: false });
+
+describe('mergeVisiblePoiLayerIds', () => {
+ it('keeps a layer the user hid hidden across a refresh', () => {
+ const layers = [layer(1), layer(2)];
+ const current = new Set(['poi-1']);
+
+ const merged = mergeVisiblePoiLayerIds(current, layers, [layer(1), layer(2)]);
+
+ expect(merged).toBe(current);
+ expect(Array.from(merged)).toEqual(['poi-1']);
+ });
+
+ it('shows layers that are new in the refresh and drops ones that disappeared', () => {
+ const current = new Set(['poi-1']);
+
+ const merged = mergeVisiblePoiLayerIds(current, [layer(1), layer(2)], [layer(2), layer(3)]);
+
+ expect(Array.from(merged).sort()).toEqual(['poi-3']);
+ });
+
+ it('returns the same set when the defaults are unchanged', () => {
+ const layers = [layer(1), layer(2)];
+ const current = createDefaultVisiblePoiLayerIds(layers);
+
+ expect(mergeVisiblePoiLayerIds(current, layers, [layer(1), layer(2)])).toBe(current);
+ });
+});
diff --git a/src/lib/__tests__/server-url.test.ts b/src/lib/__tests__/server-url.test.ts
new file mode 100644
index 00000000..ebac02f3
--- /dev/null
+++ b/src/lib/__tests__/server-url.test.ts
@@ -0,0 +1,93 @@
+const mockGetSystemConfig = jest.fn();
+
+jest.mock('@/api/config', () => ({
+ getSystemConfig: () => mockGetSystemConfig(),
+}));
+jest.mock('@/lib/env', () => ({ Env: { API_VERSION: 'v4' } }));
+jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() } }));
+
+import { buildApiUrl, findLocationByUrl, isSameServerUrl, loadServerLocations, mergeServerLocations, RESGRID_HOSTED_LOCATIONS, toBaseUrl } from '../server-url';
+
+const location = (Name: string, ApiUrl: string) => ({ Name, ApiUrl, DisplayName: '', LocationInfo: '', IsDefault: false, AllowsFreeAccounts: false });
+
+describe('server-url', () => {
+ beforeEach(() => {
+ mockGetSystemConfig.mockReset();
+ });
+
+ it('includes the US-West and EU-Central hosted sites', () => {
+ expect(RESGRID_HOSTED_LOCATIONS.map((item) => [item.Name, item.ApiUrl])).toEqual([
+ ['US-West', 'https://api.resgrid.com'],
+ ['EU-Central', 'https://api-eu-central.resgrid.com'],
+ ]);
+ });
+
+ describe('toBaseUrl / buildApiUrl', () => {
+ it('strips the api suffix and trailing slashes', () => {
+ expect(toBaseUrl(' https://api.resgrid.com/api/v4/ ')).toBe('https://api.resgrid.com');
+ expect(toBaseUrl('https://api.resgrid.com///')).toBe('https://api.resgrid.com');
+ });
+
+ it('always produces exactly one api suffix', () => {
+ expect(buildApiUrl('https://api.resgrid.com')).toBe('https://api.resgrid.com/api/v4');
+ expect(buildApiUrl('https://api.resgrid.com/api/v4/')).toBe('https://api.resgrid.com/api/v4');
+ });
+ });
+
+ describe('isSameServerUrl / findLocationByUrl', () => {
+ it('compares with or without the api suffix and ignores host case', () => {
+ expect(isSameServerUrl('https://API.resgrid.com/api/v4', 'https://api.resgrid.com/')).toBe(true);
+ expect(isSameServerUrl('https://api.resgrid.com/api/v4', 'https://api-eu-central.resgrid.com/api/v4')).toBe(false);
+ });
+
+ it('finds the hosted site matching a persisted url', () => {
+ expect(findLocationByUrl(RESGRID_HOSTED_LOCATIONS, 'https://api-eu-central.resgrid.com/api/v4')?.Name).toBe('EU-Central');
+ expect(findLocationByUrl(RESGRID_HOSTED_LOCATIONS, 'https://dispatch.example.org/api/v4')).toBeUndefined();
+ });
+ });
+
+ describe('mergeServerLocations', () => {
+ it('returns the built-in hosted sites when nothing was fetched', () => {
+ expect(mergeServerLocations([]).map((item) => item.Name)).toEqual(['US-West', 'EU-Central']);
+ });
+
+ it('keeps server-reported sites first and does not duplicate hosted ones', () => {
+ const fetched = [location('US-West', 'https://api.resgrid.com'), location('EU-Central', 'https://api-eu-central.resgrid.com/api/v4')];
+ expect(mergeServerLocations(fetched)).toEqual(fetched);
+ });
+
+ it('appends the hosted sites to a self-hosted server list', () => {
+ const merged = mergeServerLocations([location('Local', 'https://resgrid.example.org')]);
+ expect(merged.map((item) => item.Name)).toEqual(['Local', 'US-West', 'EU-Central']);
+ });
+
+ it('skips a hosted site whose name is already taken so dropdown values stay unique', () => {
+ const merged = mergeServerLocations([location('US-West', 'https://mirror.example.org')]);
+ expect(merged.map((item) => item.Name)).toEqual(['US-West', 'EU-Central']);
+ expect(merged[0].ApiUrl).toBe('https://mirror.example.org');
+ });
+
+ it('drops entries without a name or api url', () => {
+ const merged = mergeServerLocations([location('', 'https://a.example.org'), location('B', '')]);
+ expect(merged.map((item) => item.Name)).toEqual(['US-West', 'EU-Central']);
+ });
+ });
+
+ describe('loadServerLocations', () => {
+ it('uses the sites reported by the configured server', async () => {
+ mockGetSystemConfig.mockResolvedValue({ Data: { Locations: [location('US-West', 'https://api.resgrid.com'), location('EU-Central', 'https://api-eu-central.resgrid.com')] } });
+
+ const result = await loadServerLocations();
+
+ expect(result.map((item) => item.Name)).toEqual(['US-West', 'EU-Central']);
+ });
+
+ it('falls back to the hosted sites when the server cannot be reached', async () => {
+ mockGetSystemConfig.mockRejectedValue(new Error('Network Error'));
+
+ const result = await loadServerLocations();
+
+ expect(result.map((item) => item.Name)).toEqual(['US-West', 'EU-Central']);
+ });
+ });
+});
diff --git a/src/lib/__tests__/unit-status-helpers.test.ts b/src/lib/__tests__/unit-status-helpers.test.ts
new file mode 100644
index 00000000..dd89ff2b
--- /dev/null
+++ b/src/lib/__tests__/unit-status-helpers.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
+import { type UnitTypeStatusResultData } from '@/models/v4/statuses/unitTypeStatusResultData';
+
+import { resolveUnitStatusOptions } from '../unit-status-helpers';
+
+const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Type: 3, StateId: 0, Text, BColor: '', Color: '', Gps: false, Note: 0, Detail }) as StatusesResultData;
+
+// The department default group ("0") as served by /Statuses/GetAllUnitStatuses, with real Detail types
+const defaultGroup: UnitTypeStatusResultData = {
+ UnitType: '0',
+ StatusId: '',
+ Statuses: [status(0, 'Available', 1), status(3, 'Responding', 2), status(6, 'On Scene', 2), status(8, 'Staging', 2)],
+};
+
+const engineGroup: UnitTypeStatusResultData = {
+ UnitType: 'Engine',
+ StatusId: '42',
+ Statuses: [status(100, 'Engine Responding', 2), status(101, 'Engine In Quarters', 1)],
+};
+
+// What an older server returns from /Dispatch/GetSetUnitStatusData for a unit without a custom set: all Detail 0
+const legacyServerList = [status(0, 'Available', 0), status(3, 'Responding', 0), status(6, 'On Scene', 0)];
+
+describe('resolveUnitStatusOptions', () => {
+ it('returns the custom status set matched by CustomStatusSetId', () => {
+ expect(resolveUnitStatusOptions({ CustomStatusSetId: '42', Type: 'Something Else' }, [defaultGroup, engineGroup], legacyServerList)).toBe(engineGroup.Statuses);
+ });
+
+ it('returns the custom status set matched by unit type name', () => {
+ expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Engine' }, [defaultGroup, engineGroup], legacyServerList)).toBe(engineGroup.Statuses);
+ });
+
+ it('prefers the department default group over the per-unit server list for a unit without a custom set', () => {
+ const options = resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Ladder' }, [defaultGroup, engineGroup], legacyServerList);
+
+ expect(options).toBe(defaultGroup.Statuses);
+ // Responding / On Scene / Staging accept a call destination
+ expect(options.filter((s) => s.Detail === 2).map((s) => s.Text)).toEqual(['Responding', 'On Scene', 'Staging']);
+ });
+
+ it('prefers the default group when the unit has no type at all', () => {
+ expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: '' }, [defaultGroup], legacyServerList)).toBe(defaultGroup.Statuses);
+ });
+
+ it('falls back to the per-unit server list only when there is no default group', () => {
+ expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Ladder' }, [engineGroup], legacyServerList)).toBe(legacyServerList);
+ expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Ladder' }, undefined, legacyServerList)).toBe(legacyServerList);
+ expect(resolveUnitStatusOptions({ CustomStatusSetId: '', Type: 'Ladder' }, [{ ...defaultGroup, Statuses: [] }], legacyServerList)).toBe(legacyServerList);
+ });
+
+ it('returns an empty list when nothing is available', () => {
+ expect(resolveUnitStatusOptions(null, [], [])).toEqual([]);
+ });
+});
diff --git a/src/lib/activity-link.ts b/src/lib/activity-link.ts
new file mode 100644
index 00000000..c7d1fa72
--- /dev/null
+++ b/src/lib/activity-link.ts
@@ -0,0 +1,46 @@
+import { type DispatchedEventResultData } from '@/models/v4/calls/dispatchedEventResultData';
+
+/**
+ * Server `StatusDestinationSources`: how a status entry in a call's `Activity` came to be tied to the call.
+ * Carried on `DispatchedEventResultData.DestinationSource`.
+ */
+export enum StatusDestinationSources {
+ /** The sender chose the call. */
+ Explicit = 1,
+ /** Sent without a call; linked from the unit's/person's previous status. */
+ CarryForward = 2,
+ /** Sent without a call; linked from the sender's one open dispatch. */
+ Dispatch = 3,
+ /** Sent without a call by a person; linked from the unit they rode. */
+ Unit = 4,
+ /** Set with no destination by a resource dispatched to the call while working it (derived at read time, not stored). */
+ Inferred = 5,
+}
+
+/** The marker a status entry carries: `auto` = auto-linked by the server, `inferred` = attributed at read time. */
+export type ActivityLinkKind = 'auto' | 'inferred';
+
+/** Maps a `DestinationSource` to its marker; `null` for explicit entries, older rows and non-status entries. */
+export function getActivityLinkKind(source: number | null | undefined): ActivityLinkKind | null {
+ switch (source) {
+ case StatusDestinationSources.CarryForward:
+ case StatusDestinationSources.Dispatch:
+ case StatusDestinationSources.Unit:
+ return 'auto';
+ case StatusDestinationSources.Inferred:
+ return 'inferred';
+ default:
+ return null;
+ }
+}
+
+/** The distinct markers present in an activity list, in display order (`auto` before `inferred`). */
+export function getActivityLinkKinds(activity: readonly Pick[] | null | undefined): ActivityLinkKind[] {
+ if (!activity || activity.length === 0) return [];
+ const present = new Set();
+ for (const item of activity) {
+ const kind = getActivityLinkKind(item?.DestinationSource);
+ if (kind) present.add(kind);
+ }
+ return (['auto', 'inferred'] as const).filter((kind) => present.has(kind));
+}
diff --git a/src/lib/contacts/__tests__/format.test.ts b/src/lib/contacts/__tests__/format.test.ts
new file mode 100644
index 00000000..2e3cd00d
--- /dev/null
+++ b/src/lib/contacts/__tests__/format.test.ts
@@ -0,0 +1,27 @@
+import { htmlToText, mapsUrls, parseCoordinates, withoutRedacted } from '@/lib/contacts/format';
+import { type ContactResultData, ContactType } from '@/models/v4/contacts/contactResultData';
+
+it('turns web-editor rich text into readable plain text', () => {
+ expect(htmlToText('Knox box at north door
Gate & code
')).toBe('Knox box at north door\nGate & code\n• Hydrant');
+ expect(htmlToText(null)).toBe('');
+});
+
+it('parses coordinates and builds a maps link for an address or a point', () => {
+ expect(parseCoordinates('39.7392, -104.9903')).toEqual({ latitude: 39.7392, longitude: -104.9903 });
+ expect(parseCoordinates('not a place')).toBeNull();
+ expect(parseCoordinates('95, 10')).toBeNull();
+ // The platform app link differs (maps: on iOS, geo: on Android); both carry the point or the encoded address.
+ expect(mapsUrls('39.7392,-104.9903').app).toMatch(/39\.7392,-104\.9903.*q=39\.7392%2C-104\.9903$/);
+ expect(mapsUrls('1 Main St, Denver').app).toMatch(/0,0\?q=1%20Main%20St%2C%20Denver$/);
+ expect(mapsUrls('1 Main St').web).toBe('https://maps.google.com/?q=1%20Main%20St');
+});
+
+it('removes withheld values and names the fields so the sheet never shows or dials the sentinel', () => {
+ const contact = { ContactId: 'c-1', ContactType: ContactType.Person, FirstName: 'Ana', CellPhoneNumber: 'REDACTED', Email: 'REDACTED', IsDeleted: false } as unknown as ContactResultData;
+ const shown = withoutRedacted(contact);
+ expect(shown.CellPhoneNumber).toBeUndefined();
+ expect(shown.Email).toBeUndefined();
+ expect(shown.FirstName).toBe('Ana');
+ expect(shown.WithheldFields).toEqual(['CellPhoneNumber', 'Email']);
+ expect(contact.CellPhoneNumber).toBe('REDACTED');
+});
diff --git a/src/lib/contacts/format.ts b/src/lib/contacts/format.ts
new file mode 100644
index 00000000..08a49510
--- /dev/null
+++ b/src/lib/contacts/format.ts
@@ -0,0 +1,64 @@
+import { Platform } from 'react-native';
+
+import { type ContactResultData } from '@/models/v4/contacts/contactResultData';
+
+// Pure presentation helpers for a contact (the shared detail sheet in every field app). Nothing here decides
+// what a person may see: withheld values arrive redacted from the server and are shown as such by the sheet.
+
+const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ' };
+
+/** Rich text from the web editor as plain readable text: line breaks kept, markup and scripts dropped. */
+export const htmlToText = (html: string | null | undefined): string => {
+ if (!html) return '';
+ return html
+ .replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '')
+ .replace(/ /gi, '\n')
+ .replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
+ .replace(/]*>/gi, '• ')
+ .replace(/<[^>]+>/g, '')
+ .replace(/&(amp|lt|gt|quot|#39|apos|nbsp);/g, (entity) => ENTITIES[entity] ?? entity)
+ .replace(/[ \t]+\n/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+};
+
+/** "lat,lng" (or "lat lng") as numbers, when it parses. */
+export const parseCoordinates = (value: string | null | undefined): { latitude: number; longitude: number } | null => {
+ const match = /^\s*(-?\d+(?:\.\d+)?)\s*[, ]\s*(-?\d+(?:\.\d+)?)\s*$/.exec(value ?? '');
+ if (!match) return null;
+ const latitude = Number(match[1]);
+ const longitude = Number(match[2]);
+ return Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180 ? { latitude, longitude } : null;
+};
+
+/** The platform maps app for an address or a coordinate pair, with a web map as the fallback. */
+export const mapsUrls = (query: string) => {
+ const coordinates = parseCoordinates(query);
+ const encoded = encodeURIComponent(coordinates ? `${coordinates.latitude},${coordinates.longitude}` : query);
+ const web = `https://maps.google.com/?q=${encoded}`;
+ const app = Platform.select({
+ ios: coordinates ? `maps:?ll=${coordinates.latitude},${coordinates.longitude}&q=${encoded}` : `maps:0,0?q=${encoded}`,
+ android: coordinates ? `geo:${coordinates.latitude},${coordinates.longitude}?q=${encoded}` : `geo:0,0?q=${encoded}`,
+ default: web,
+ });
+ return { app, web };
+};
+
+const REDACTED = 'REDACTED';
+
+/**
+ * The contact with every withheld value removed, plus the names of the fields that were withheld. The detail
+ * sheet then never shows the wire sentinel as data or dials "tel:REDACTED"; it says what is protected and
+ * offers the reveal instead. The server's RedactedFields list marks catalog fields; the sentinel is the fallback.
+ */
+export const withoutRedacted = (contact: T): T & { WithheldFields: string[] } => {
+ const withheld: string[] = [];
+ const copy = { ...contact } as unknown as Record;
+ for (const [key, value] of Object.entries(contact)) {
+ if (value === REDACTED) {
+ copy[key] = undefined;
+ withheld.push(key);
+ }
+ }
+ return { ...(copy as T), WithheldFields: withheld };
+};
diff --git a/src/lib/destination-helpers.ts b/src/lib/destination-helpers.ts
index 81ee0154..dea39eae 100644
--- a/src/lib/destination-helpers.ts
+++ b/src/lib/destination-helpers.ts
@@ -168,3 +168,97 @@ export const getSelectedDestinationId = ({ selectedDestinationType, selectedCall
return '';
}
};
+
+/**
+ * Destination capabilities for a status change. Normally these come straight from the status's
+ * `Detail`, but a status set from an explicit call context (the dashboard "+" set-status-for-call
+ * action) may always carry a call destination, whatever the status's `Detail` lists — that call is
+ * how the server's call reports attribute the status change to the call.
+ */
+export const getStatusDestinationCapabilities = (detail?: number | null, hasCallContext = false): DestinationCapabilities => {
+ const capabilities = getDestinationCapabilities(detail);
+ if (!hasCallContext || capabilities.showCalls) {
+ return capabilities;
+ }
+
+ return { ...capabilities, showCalls: true, supportsDestination: true };
+};
+
+/**
+ * Narrows a destination selection to what the chosen status accepts. A destination picked (or
+ * carried over) for an earlier status is dropped to 'none' when the new status's `Detail` does not
+ * support that destination type, so a leftover call/station/POI is never sent with an unrelated status.
+ */
+export const getEffectiveDestinationType = (selection: DestinationSelectionState, detail?: number | null, hasCallContext = false): DestinationSelectionType => {
+ const capabilities = getStatusDestinationCapabilities(detail, hasCallContext);
+
+ switch (selection.selectedDestinationType) {
+ case 'call':
+ return capabilities.showCalls && selection.selectedCall ? 'call' : 'none';
+ case 'station':
+ return capabilities.showStations && selection.selectedStation ? 'station' : 'none';
+ case 'poi':
+ return capabilities.showPois && selection.selectedPoi ? 'poi' : 'none';
+ default:
+ return 'none';
+ }
+};
+
+export interface StatusDestinationPayload {
+ respondingTo: string;
+ respondingToType: DestinationEntityType | null;
+}
+
+/**
+ * The `RespondingTo` / `RespondingToType` pair to send with a status change (see
+ * {@link getEffectiveDestinationType}). Returns an empty destination when the status does not
+ * support the selected destination type.
+ */
+export const getStatusDestinationPayload = (selection: DestinationSelectionState, detail?: number | null, hasCallContext = false): StatusDestinationPayload => {
+ const effectiveType = getEffectiveDestinationType(selection, detail, hasCallContext);
+ const respondingTo = getSelectedDestinationId({ ...selection, selectedDestinationType: effectiveType });
+
+ if (!respondingTo) {
+ return { respondingTo: '', respondingToType: null };
+ }
+
+ return { respondingTo, respondingToType: getDestinationSelectionTypeValue(effectiveType) };
+};
+
+export interface DefaultDestinationCallInput {
+ /** Explicit call context (the dashboard "+" set-status-for-call action). Always wins. */
+ callContext?: CallResultData | null;
+ /** The unit's `CurrentDestinationId` / the person's `StatusDestinationId`. */
+ currentDestinationId?: string | null;
+ /** The call currently selected on the dispatch console. */
+ selectedCallId?: string | null;
+ /** Active calls only — a closed or cancelled call is never a default destination. */
+ activeCalls: CallResultData[];
+}
+
+/**
+ * Picks the call a status change for a unit/person should default to:
+ * (a) the explicit call context; (b) the unit's/person's current destination when it is a call that
+ * is still active; (c) the console's selected call when it is active; otherwise none.
+ */
+export const resolveDefaultDestinationCall = ({ callContext, currentDestinationId, selectedCallId, activeCalls }: DefaultDestinationCallInput): CallResultData | null => {
+ if (callContext) {
+ return callContext;
+ }
+
+ if (currentDestinationId) {
+ const currentCall = activeCalls.find((call) => call.CallId === currentDestinationId);
+ if (currentCall) {
+ return currentCall;
+ }
+ }
+
+ if (selectedCallId) {
+ const selectedCall = activeCalls.find((call) => call.CallId === selectedCallId);
+ if (selectedCall) {
+ return selectedCall;
+ }
+ }
+
+ return null;
+};
diff --git a/src/lib/hooks/__tests__/use-selected-theme.test.ts b/src/lib/hooks/__tests__/use-selected-theme.test.ts
index de191c9d..50486215 100644
--- a/src/lib/hooks/__tests__/use-selected-theme.test.ts
+++ b/src/lib/hooks/__tests__/use-selected-theme.test.ts
@@ -74,6 +74,6 @@ describe('loadSelectedTheme', () => {
loadSelectedTheme();
- expect(setColorSchemeSpy).toHaveBeenCalledWith(null);
+ expect(setColorSchemeSpy).toHaveBeenCalledWith('unspecified');
});
});
diff --git a/src/lib/hooks/use-selected-theme.tsx b/src/lib/hooks/use-selected-theme.tsx
index 6ca9494f..e1e539f2 100644
--- a/src/lib/hooks/use-selected-theme.tsx
+++ b/src/lib/hooks/use-selected-theme.tsx
@@ -9,9 +9,9 @@ export type ColorSchemeType = 'light' | 'dark' | 'system';
const applyColorScheme = (t: ColorSchemeType) => {
// NativeWind v5: theme overrides go through the standard Appearance API
- // (null clears the override on RN 0.81). GluestackUIProvider picks the value
+ // ('unspecified' clears the override). GluestackUIProvider picks the value
// up via useColorScheme and applies the web class / native class wrapper.
- Appearance.setColorScheme(t === 'system' ? null : t);
+ Appearance.setColorScheme(t === 'system' ? 'unspecified' : t);
};
/**
diff --git a/src/lib/live-locations.ts b/src/lib/live-locations.ts
new file mode 100644
index 00000000..57cb2f71
--- /dev/null
+++ b/src/lib/live-locations.ts
@@ -0,0 +1,204 @@
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+
+import { MapMarkerEntityType } from './destination-helpers';
+import { getPinEntityId } from './map-pin-ids';
+
+/**
+ * The latest realtime position the geolocation hub pushed for one map entity.
+ *
+ * `pinId` is the REST map pin id it moves, lower-cased (`u12` for unit 12, `p` for a person), so a
+ * lookup against `MapMakerInfoData.Id` is a case-insensitive match. `timestamp` is the GPS fix time in epoch
+ * milliseconds, or null when the server did not send one (older servers) or it could not be parsed.
+ * `receivedAt` is when this client received the push, in epoch milliseconds.
+ */
+export interface LiveLocation {
+ pinId: string;
+ latitude: number;
+ longitude: number;
+ timestamp: number | null;
+ receivedAt: number;
+}
+
+/** Live positions keyed by lower-cased pin id. Always replaced, never mutated. */
+export type LiveLocations = Record;
+
+const UNIT_PIN_PREFIX = 'u';
+const PERSONNEL_PIN_PREFIX = 'p';
+
+/** The key a pin is stored under in {@link LiveLocations}. */
+export const toLivePinKey = (pinId: string): string => pinId.toLowerCase();
+
+/** Hub payloads may arrive as an object or as a JSON string; anything else is unusable. */
+const toPayloadObject = (payload: unknown): Record | null => {
+ let value = payload;
+ if (typeof value === 'string') {
+ try {
+ value = JSON.parse(value);
+ } catch {
+ return null;
+ }
+ }
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null;
+};
+
+/** Reads a field under its camelCase name (the SignalR JSON protocol default) or its PascalCase name. */
+const readField = (source: Record, camelName: string): unknown => {
+ const pascalName = camelName.charAt(0).toUpperCase() + camelName.slice(1);
+ return source[camelName] ?? source[pascalName];
+};
+
+const toIdentifier = (value: unknown): string | null => {
+ if (typeof value === 'string') {
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return String(value);
+ }
+ return null;
+};
+
+const toCoordinate = (value: unknown): number | null => {
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? value : null;
+ }
+ if (typeof value === 'string' && value.trim().length > 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+ return null;
+};
+
+/** GPS fix time in epoch ms; null means "unknown", which callers must treat as always newer. */
+const toTimestamp = (value: unknown): number | null => {
+ if (typeof value === 'string' && value.trim().length > 0) {
+ const parsed = Date.parse(value);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+ return null;
+};
+
+/** Coordinates the map can actually place: finite, in range, and not the 0,0 "no fix" placeholder. */
+export const isValidLiveCoordinate = (latitude: number, longitude: number): boolean => {
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return false;
+ if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return false;
+ return !(latitude === 0 && longitude === 0);
+};
+
+const parseLocationUpdate = (payload: unknown, idField: 'unitId' | 'userId', prefix: string, receivedAt: number): LiveLocation | null => {
+ const source = toPayloadObject(payload);
+ if (!source) return null;
+
+ const entityId = toIdentifier(readField(source, idField));
+ const latitude = toCoordinate(readField(source, 'latitude'));
+ const longitude = toCoordinate(readField(source, 'longitude'));
+ if (entityId === null || latitude === null || longitude === null || !isValidLiveCoordinate(latitude, longitude)) {
+ return null;
+ }
+
+ return {
+ pinId: toLivePinKey(`${prefix}${entityId}`),
+ latitude,
+ longitude,
+ timestamp: toTimestamp(readField(source, 'timestamp')),
+ receivedAt,
+ };
+};
+
+/** Parses an `onUnitLocationUpdated` push. Returns null for anything that cannot move a pin. */
+export const parseUnitLocationUpdate = (payload: unknown, receivedAt: number = Date.now()): LiveLocation | null => parseLocationUpdate(payload, 'unitId', UNIT_PIN_PREFIX, receivedAt);
+
+/** Parses an `onPersonnelLocationUpdated` push. Returns null for anything that cannot move a pin. */
+export const parsePersonnelLocationUpdate = (payload: unknown, receivedAt: number = Date.now()): LiveLocation | null => parseLocationUpdate(payload, 'userId', PERSONNEL_PIN_PREFIX, receivedAt);
+
+/**
+ * Records a live position, returning the SAME object when the update is ignored.
+ *
+ * Trackers replay buffered fixes and queue consumers can reorder, so a fix older than the one already held
+ * for the entity is dropped. A fix with an unknown time always applies, and an identical repeat is dropped so
+ * it does not wake every map.
+ */
+export const upsertLiveLocation = (current: LiveLocations, update: LiveLocation): LiveLocations => {
+ const existing = current[update.pinId];
+ if (existing && existing.timestamp !== null && update.timestamp !== null) {
+ if (update.timestamp < existing.timestamp) {
+ return current;
+ }
+ if (update.timestamp === existing.timestamp && update.latitude === existing.latitude && update.longitude === existing.longitude) {
+ return current;
+ }
+ }
+ return { ...current, [update.pinId]: update };
+};
+
+/**
+ * Only unit and personnel pins track a live position. Pushes always carry the type-prefixed id, while a server
+ * from before pin ids were prefixed sends the bare id, so the key is rebuilt from the entity id and the pin type.
+ */
+const getLivePinKey = (pin: MapMakerInfoData): string | null => {
+ if (typeof pin.Id !== 'string' || pin.Id.length === 0) return null;
+ const prefix = pin.Type === MapMarkerEntityType.Unit ? UNIT_PIN_PREFIX : pin.Type === MapMarkerEntityType.Personnel ? PERSONNEL_PIN_PREFIX : null;
+ return prefix ? toLivePinKey(`${prefix}${getPinEntityId(pin)}`) : null;
+};
+
+export interface ApplyLiveLocationsOptions {
+ /** Ignore live positions received before this time (epoch ms), e.g. older than a fresh REST snapshot. */
+ receivedSince?: number;
+ /** Consider only these pin keys (lower-cased), e.g. the entries a push just changed. */
+ onlyPinIds?: Iterable;
+}
+
+export interface ApplyLiveLocationsResult {
+ /** The input array itself when nothing moved; otherwise a new array with new objects for moved pins only. */
+ pins: MapMakerInfoData[];
+ /** Considered live positions that match no unit/personnel pin. Pins are never created from a push. */
+ unknownPinIds: string[];
+}
+
+/**
+ * Moves unit and personnel pins to their live positions.
+ *
+ * The realtime feed is not filtered by the viewer's visibility rules or location TTLs the REST map endpoint
+ * applies, so a push can only move a pin REST already returned — it never adds one. Unchanged pins keep their
+ * object identity (they are memoized by the renderers), and the input array is returned untouched when no pin
+ * moved.
+ */
+export const applyLiveLocations = (pins: MapMakerInfoData[], liveLocations: LiveLocations, options: ApplyLiveLocationsOptions = {}): ApplyLiveLocationsResult => {
+ const candidates = new Map();
+ const keys = options.onlyPinIds ? Array.from(options.onlyPinIds, toLivePinKey) : Object.keys(liveLocations);
+ keys.forEach((key) => {
+ const location = liveLocations[key];
+ if (!location) return;
+ if (options.receivedSince !== undefined && location.receivedAt < options.receivedSince) return;
+ candidates.set(key, location);
+ });
+
+ if (candidates.size === 0) {
+ return { pins, unknownPinIds: [] };
+ }
+
+ const matched = new Set();
+ let nextPins: MapMakerInfoData[] | null = null;
+
+ for (let index = 0; index < pins.length; index += 1) {
+ const pin = pins[index];
+ const key = getLivePinKey(pin);
+ if (key === null) continue;
+ const location = candidates.get(key);
+ if (!location) continue;
+
+ matched.add(key);
+ if (pin.Latitude === location.latitude && pin.Longitude === location.longitude) continue;
+
+ if (nextPins === null) {
+ nextPins = pins.slice();
+ }
+ nextPins[index] = { ...pin, Latitude: location.latitude, Longitude: location.longitude };
+ }
+
+ const unknownPinIds = Array.from(candidates.keys()).filter((key) => !matched.has(key));
+ return { pins: nextPins ?? pins, unknownPinIds };
+};
diff --git a/src/lib/map-markers-web.ts b/src/lib/map-markers-web.ts
index d3078976..29de03e6 100644
--- a/src/lib/map-markers-web.ts
+++ b/src/lib/map-markers-web.ts
@@ -1,7 +1,8 @@
import { getMapIconWebUrl, MAP_ICONS } from '@/constants/map-icons';
import { isPoiMarker } from '@/lib/destination-helpers';
-import { getMapMarkerColor, getPoiMarkerIconChar, getPoiMarkerShapePath, resolveMapMarkerIconKey } from '@/lib/map-markers';
+import { getMapMarkerColor, getMapPinSummary, getPoiMarkerIconChar, getPoiMarkerShapePath, resolveMapMarkerIconKey } from '@/lib/map-markers';
import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+import { escapeHtml } from '@/utils/html-entities';
type MapIconKey = keyof typeof MAP_ICONS;
@@ -111,6 +112,22 @@ const createPoiMarkerElement = (pin: MapMakerInfoData): HTMLElement => {
return wrapper;
};
+/**
+ * The Mapbox popup markup for a pin. The title and summary are server text shown as text (as the native
+ * pin sheet does), so they are escaped: `Popup.setHTML` parses its string, and markup in a pin field would
+ * otherwise run in the dispatcher's browser.
+ */
+export const buildMapPinPopupHtml = (pin: MapMakerInfoData): string => {
+ const summary = getMapPinSummary(pin);
+ return `
+
${escapeHtml(String(pin.Title ?? ''))}
+ ${summary ? `
${escapeHtml(String(summary))}
` : ''}
+
+ ${pin.Latitude.toFixed(6)}, ${pin.Longitude.toFixed(6)}
+
+
`;
+};
+
/**
* Creates a marker DOM element that can be used with Mapbox GL JS.
* Handles both POI markers (SVG shape + map-icons font) and
diff --git a/src/lib/map-pin-ids.ts b/src/lib/map-pin-ids.ts
new file mode 100644
index 00000000..4fff267d
--- /dev/null
+++ b/src/lib/map-pin-ids.ts
@@ -0,0 +1,58 @@
+import { type MapMakerInfoData } from '@/models/v4/mapping/getMapDataAndMarkersData';
+
+/**
+ * Pin types returned by the Core map API (`/Mapping/GetMapDataAndMarkers`).
+ */
+export const MapPinType = {
+ Call: 0,
+ Unit: 1,
+ Station: 2,
+ Personnel: 3,
+ Poi: 4,
+ Hydrant: 5,
+} as const;
+
+// The Core map API prefixes each pin id with its type so ids are unique across types
+// (`c123` is call 123, `p` a person). Servers from before that change send the bare id.
+const PIN_ID_PREFIXES: Record = {
+ [MapPinType.Call]: 'c',
+ [MapPinType.Unit]: 'u',
+ [MapPinType.Station]: 's',
+ [MapPinType.Personnel]: 'p',
+ [MapPinType.Poi]: 'poi',
+ [MapPinType.Hydrant]: 'hydrant-',
+};
+
+const NUMERIC_ID_TYPES = new Set([MapPinType.Call, MapPinType.Unit, MapPinType.Station, MapPinType.Poi]);
+
+type PinIdentity = Pick;
+
+/**
+ * The id of the call, unit, station, person, POI or hydrant behind a map pin, i.e. the pin id without
+ * its type prefix, for routes and stores that take the entity id. A legacy unprefixed id, or an id of
+ * any other pin type, is returned unchanged.
+ */
+export const getPinEntityId = (pin: PinIdentity): string => {
+ const id = pin.Id == null ? '' : String(pin.Id).trim();
+ const prefix = PIN_ID_PREFIXES[pin.Type];
+
+ if (!prefix || id.length <= prefix.length || id.slice(0, prefix.length).toLowerCase() !== prefix) {
+ return id;
+ }
+
+ const entityId = id.slice(prefix.length);
+
+ // Call, unit, station and POI ids are numbers; anything else is not a prefixed id, so leave it alone.
+ if (NUMERIC_ID_TYPES.has(pin.Type) && !/^\d+$/.test(entityId)) {
+ return id;
+ }
+
+ return entityId;
+};
+
+export const isCallPin = (pin: Pick): boolean => pin.Type === MapPinType.Call;
+
+/**
+ * Whether the pin is the given call, e.g. the active call (stores hold the bare call id).
+ */
+export const isPinForCall = (pin: PinIdentity, callId: string | number | null | undefined): boolean => callId != null && String(callId) !== '' && isCallPin(pin) && getPinEntityId(pin) === String(callId);
diff --git a/src/lib/media/photo.ts b/src/lib/media/photo.ts
new file mode 100644
index 00000000..86107158
--- /dev/null
+++ b/src/lib/media/photo.ts
@@ -0,0 +1,47 @@
+import * as FileSystem from 'expo-file-system/legacy';
+import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
+import * as ImagePicker from 'expo-image-picker';
+
+// One photo from the camera or the library, normalised for upload: HEIC and other camera formats become
+// JPEG, the long side is bounded, and metadata (GPS, device) is dropped by the re-encode. Receipts, work
+// order photos and certificate scans all go through here. Cache copies are removed; a camera-roll original
+// never is.
+
+export interface CapturedPhoto {
+ base64: string;
+ uri: string;
+ name: string;
+ contentType: 'image/jpeg';
+ width: number;
+ height: number;
+}
+
+export class PhotoPermissionError extends Error {
+ constructor() {
+ super('denied');
+ }
+}
+
+const MAX_EDGE = 2048;
+
+export const capturePhoto = async (source: 'camera' | 'library', name = `photo-${Date.now()}.jpg`): Promise => {
+ const permission = await (source === 'camera' ? ImagePicker.requestCameraPermissionsAsync() : ImagePicker.requestMediaLibraryPermissionsAsync());
+ if (!permission.granted) throw new PhotoPermissionError();
+ const result = await (source === 'camera' ? ImagePicker.launchCameraAsync({ quality: 0.8, exif: false }) : ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.8, exif: false }));
+ if (result.canceled || !result.assets?.[0]) return null;
+ const asset = result.assets[0];
+ let converted: { uri: string; base64?: string; width: number; height: number } | null = null;
+ try {
+ const resize = asset.width >= asset.height ? { width: Math.min(asset.width || MAX_EDGE, MAX_EDGE) } : { height: Math.min(asset.height || MAX_EDGE, MAX_EDGE) };
+ converted = await manipulateAsync(asset.uri, [{ resize }], { format: SaveFormat.JPEG, compress: 0.8, base64: true });
+ if (!converted.base64) return null;
+ return { base64: converted.base64, uri: converted.uri, name: name.toLowerCase().endsWith('.jpg') ? name : `${name}.jpg`, contentType: 'image/jpeg', width: converted.width, height: converted.height };
+ } finally {
+ if (FileSystem.cacheDirectory && asset.uri.startsWith(FileSystem.cacheDirectory) && asset.uri !== converted?.uri) await FileSystem.deleteAsync(asset.uri, { idempotent: true }).catch(() => undefined);
+ }
+};
+
+/** Removes an app cache copy made by {@link capturePhoto} once it has been uploaded. */
+export const discardPhoto = async (photo: CapturedPhoto | null | undefined) => {
+ if (photo?.uri && FileSystem.cacheDirectory && photo.uri.startsWith(FileSystem.cacheDirectory)) await FileSystem.deleteAsync(photo.uri, { idempotent: true }).catch(() => undefined);
+};
diff --git a/src/lib/notifications/__tests__/inbox-reference.test.ts b/src/lib/notifications/__tests__/inbox-reference.test.ts
new file mode 100644
index 00000000..8084c6be
--- /dev/null
+++ b/src/lib/notifications/__tests__/inbox-reference.test.ts
@@ -0,0 +1,34 @@
+import { referenceFromEventCode, referenceHref } from '../inbox-reference';
+
+describe('inbox references', () => {
+ it('reads call dispatch codes, which carry no separator', () => {
+ expect(referenceFromEventCode('C1234')).toEqual({ referenceType: 'call', referenceId: '1234' });
+ expect(referenceFromEventCode('c:77')).toEqual({ referenceType: 'call', referenceId: '77' });
+ });
+
+ it('does not read a communication-test token as a call', () => {
+ expect(referenceFromEventCode('CT:9f2c')).toBeUndefined();
+ });
+
+ it('reads direct and group chat codes', () => {
+ expect(referenceFromEventCode('t:9a2b')).toEqual({ referenceType: 'chat', referenceId: '9a2b' });
+ expect(referenceFromEventCode('G:7f1c')).toEqual({ referenceType: 'chat', referenceId: '7f1c' });
+ });
+
+ it('refuses ids that could steer the router and codes it has no screen for', () => {
+ expect(referenceFromEventCode('g:../call/9')).toBeUndefined();
+ expect(referenceFromEventCode('t:a?x=1')).toBeUndefined();
+ expect(referenceFromEventCode('NWO:0b7c3e52-2f4a-4d0e-9a57-1f7a0c9d6e11')).toBeUndefined();
+ expect(referenceFromEventCode('M5678')).toBeUndefined();
+ expect(referenceFromEventCode('')).toBeUndefined();
+ expect(referenceFromEventCode(undefined)).toBeUndefined();
+ expect(referenceFromEventCode(42)).toBeUndefined();
+ });
+
+ it('builds the route for each reference and refuses unsafe ids', () => {
+ expect(referenceHref('call', '1234')).toEqual({ pathname: '/call/[id]', params: { id: '1234' } });
+ expect(referenceHref('chat', '7f1c')).toEqual({ pathname: '/chat/[channelId]', params: { channelId: '7f1c' } });
+ expect(referenceHref('chat', '../settings')).toBeNull();
+ expect(referenceHref('note', 'n-1')).toBeNull();
+ });
+});
diff --git a/src/lib/notifications/inbox-reference.ts b/src/lib/notifications/inbox-reference.ts
new file mode 100644
index 00000000..06c2f87e
--- /dev/null
+++ b/src/lib/notifications/inbox-reference.ts
@@ -0,0 +1,35 @@
+import type { Href } from 'expo-router';
+
+import { isSafeRouteId, parseNotificationData } from '@/stores/push-notification/store';
+
+export type InboxReferenceType = 'call' | 'chat';
+
+export interface InboxReference {
+ referenceType: InboxReferenceType;
+ referenceId: string;
+}
+
+/**
+ * The item a Novu inbox notification links to, read from the push event code the Novu bridge copies
+ * into the in-app `data`: "C{callId}" for call dispatches, "t:{channelId}" / "g:{channelId}" for chat.
+ * Ids that could steer the router elsewhere are refused, as the push handlers do.
+ */
+export const referenceFromEventCode = (eventCode: unknown): InboxReference | undefined => {
+ if (typeof eventCode !== 'string') return undefined;
+ const call = /^C:?(\d+)$/i.exec(eventCode);
+ if (call) return { referenceType: 'call', referenceId: call[1] };
+ const parsed = parseNotificationData({ eventCode });
+ if ((parsed.type === 'chat' || parsed.type === 'group-chat') && isSafeRouteId(parsed.id)) return { referenceType: 'chat', referenceId: parsed.id };
+ return undefined;
+};
+
+/** The screen an inbox reference opens, or null when this app has none for it. */
+export const referenceHref = (referenceType: string, referenceId: string): Href | null => {
+ if (!isSafeRouteId(referenceId)) return null;
+ if (referenceType === 'call') return { pathname: '/call/[id]', params: { id: referenceId } };
+ if (referenceType === 'chat') return { pathname: '/chat/[channelId]', params: { channelId: referenceId } };
+ return null;
+};
+
+/** Whether a notification's reference opens a screen in this app, so a link to it is worth showing. */
+export const hasReferenceRoute = (referenceType: string | undefined, referenceId: string | undefined): boolean => !!referenceType && !!referenceId && referenceHref(referenceType, referenceId) !== null;
diff --git a/src/lib/operations/__tests__/time.test.ts b/src/lib/operations/__tests__/time.test.ts
index e6670336..a980db59 100644
--- a/src/lib/operations/__tests__/time.test.ts
+++ b/src/lib/operations/__tests__/time.test.ts
@@ -1,7 +1,29 @@
-import { combineDateTime, entryHours, isClockTime, newEntry, operationsError, reportForDate, rosterSubjects, sumHours, timeOf, validateEntries } from '@/lib/operations/time';
-import type { Deployment, TimeEntry } from '@/models/v4/operations';
+import {
+ applyToCrew,
+ availableScopes,
+ combineDateTime,
+ coveringReport,
+ defaultScope,
+ endLocalFor,
+ entryHours,
+ hoursBySubject,
+ isClockTime,
+ isReportEditable,
+ newEntry,
+ operationsError,
+ reportForScope,
+ scopeKey,
+ scopeSubjects,
+ shiftDateKey,
+ sumHours,
+ timeOf,
+ validateEntries,
+} from '@/lib/operations/time';
+import type { Deployment, TimeEntry, TimeReport } from '@/models/v4/operations';
-const deployment = (): Deployment => ({
+// Engine 41 (me as crew boss, a crew member, a released member, a pump), Engine 42 (another crew, a saw),
+// a single resource with no unit. TimeAccess is what the server answers for "me".
+const deployment = (access: Partial> = {}): Deployment => ({
Id: 'dep-1',
Name: 'LNU Lightning Complex',
Status: 2,
@@ -16,29 +38,39 @@ const deployment = (): Deployment => ({
{ Id: 'dp-2', UserId: 'crew', Name: 'Crew', DeploymentUnitId: 'du-1', IsActive: true, CallSign: 'FF' },
{ Id: 'dp-3', UserId: 'other', Name: 'Other', DeploymentUnitId: 'du-2', IsActive: true },
{ Id: 'dp-4', UserId: 'gone', Name: 'Gone', DeploymentUnitId: 'du-1', IsActive: false },
+ { Id: 'dp-5', UserId: 'solo', Name: 'Solo', DeploymentUnitId: null, IsActive: true },
],
- Equipment: [{ Id: 'de-1', DeploymentUnitId: 'du-2', Name: 'Saw', IsActive: true }],
+ Equipment: [
+ { Id: 'de-1', DeploymentUnitId: 'du-1', Name: 'Pump', IsActive: true },
+ { Id: 'de-2', DeploymentUnitId: 'du-2', Name: 'Saw', IsActive: true },
+ ],
+ TimeAccess: { CanManage: false, CanApprove: false, PersonnelId: 'dp-1', CrewUnitIds: ['du-1'], WritableSubjectIds: ['dp-1', 'du-1', 'dp-2', 'dp-4', 'de-1'], TimeZone: 'Pacific Standard Time', ...access },
});
const entry = (patch: Partial): TimeEntry => ({ ...newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0), ...patch });
+const report = (patch: Partial): TimeReport => ({ Id: 'r-1', DeploymentId: 'dep-1', ReportNumber: 1, ReportDate: '2026-09-19T00:00:00', Scope: 1, DeploymentUnitId: 'du-1', CanAct: true, Status: 0, Entries: [], ...patch });
-it('computes hours from deployment-local wall clock minus unpaid breaks', () => {
- expect(timeOf('2026-09-19T07:30:00')).toBe('07:30');
+it('computes hours from department-local wall clock minus unpaid breaks, across midnight too', () => {
+ expect(timeOf('2026-09-19T07:30')).toBe('07:30');
expect(isClockTime('7:30')).toBe(false);
expect(isClockTime('23:59')).toBe(true);
- expect(combineDateTime('2026-09-19', '07:30')).toBe('2026-09-19T07:30:00');
- expect(entryHours(entry({ StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T18:00:00', UnpaidBreakMinutes: 30 }))).toBe(9.5);
- expect(entryHours(entry({ StartTime: '2026-09-19T18:00:00', EndTime: '2026-09-19T08:00:00' }))).toBe(0);
- expect(sumHours([entry({ StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T12:15:00' }), entry({ StartTime: '2026-09-19T13:00:00', EndTime: '2026-09-19T14:00:00' })])).toBe(5.25);
+ expect(combineDateTime('2026-09-19', '07:30')).toBe('2026-09-19T07:30');
+ expect(shiftDateKey('2026-12-31', 1)).toBe('2027-01-01');
+ expect(endLocalFor('2026-09-19T20:00', '06:00')).toBe('2026-09-20T06:00');
+ expect(endLocalFor('2026-09-19T08:00', '18:00')).toBe('2026-09-19T18:00');
+ expect(entryHours(entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T18:00', UnpaidBreakMinutes: 30 }))).toBe(9.5);
+ expect(entryHours(entry({ StartLocal: '2026-09-19T20:00', EndLocal: '2026-09-20T06:00' }))).toBe(10);
+ expect(entryHours(entry({ StartLocal: '2026-09-19T18:00', EndLocal: '2026-09-19T08:00' }))).toBe(0);
+ expect(sumHours([entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T12:15' }), entry({ StartLocal: '2026-09-19T13:00', EndLocal: '2026-09-19T14:00' })])).toBe(5.25);
});
it('flags missing subjects, inverted times and overlapping entries for the same subject only', () => {
const issues = validateEntries([
entry({ DeploymentPersonnelId: null }),
- entry({ StartTime: '2026-09-19T18:00:00', EndTime: '2026-09-19T08:00:00' }),
- entry({ StartTime: '2026-09-19T08:00:00', EndTime: '2026-09-19T12:00:00' }),
- entry({ StartTime: '2026-09-19T11:00:00', EndTime: '2026-09-19T13:00:00' }),
- entry({ DeploymentPersonnelId: 'dp-2', StartTime: '2026-09-19T11:00:00', EndTime: '2026-09-19T13:00:00' }),
+ entry({ StartLocal: '2026-09-19T18:00', EndLocal: '2026-09-19T08:00' }),
+ entry({ StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T12:00' }),
+ entry({ StartLocal: '2026-09-19T11:00', EndLocal: '2026-09-19T13:00' }),
+ entry({ DeploymentPersonnelId: 'dp-2', StartLocal: '2026-09-19T11:00', EndLocal: '2026-09-19T13:00' }),
]);
expect(issues).toEqual([
{ code: 'missing_subject', index: 0 },
@@ -47,23 +79,62 @@ it('flags missing subjects, inverted times and overlapping entries for the same
]);
});
-it('scopes the roster picker to the person, their active unit or the whole deployment for a manager', () => {
- const all = rosterSubjects(deployment(), { userId: 'me', activeUnitId: null, manager: true });
- expect(all.map((subject) => subject.id)).toEqual(['dp-1', 'dp-2', 'dp-3', 'du-1', 'du-2', 'de-1']);
- expect(all.find((subject) => subject.id === 'dp-2')?.label).toBe('Crew (FF)');
- expect(rosterSubjects(deployment(), { userId: 'me', activeUnitId: '12', manager: false }).map((subject) => subject.id)).toEqual(['dp-1', 'dp-2', 'du-1']);
- expect(rosterSubjects(deployment(), { userId: 'me', activeUnitId: '99', manager: false }).map((subject) => subject.id)).toEqual(['dp-1']);
- expect(rosterSubjects(deployment(), { userId: 'me', activeUnitId: null, manager: false }).map((subject) => subject.id)).toEqual(['dp-1']);
- expect(rosterSubjects(deployment(), { userId: 'nobody', activeUnitId: null, manager: false })).toEqual([]);
+it('offers exactly the scopes the server granted and opens on the crew report', () => {
+ expect(availableScopes(deployment()).map(scopeKey)).toEqual(['crew:du-1', 'person:dp-1']);
+ expect(scopeKey(defaultScope(deployment(), null))).toBe('crew:du-1');
+ // The Engine 42 tablet: seated on unit 13, not on the roster.
+ const tablet = deployment({ PersonnelId: null, CrewUnitIds: ['du-2'], WritableSubjectIds: ['du-2', 'dp-3', 'de-2'] });
+ expect(scopeKey(defaultScope(tablet, '13'))).toBe('crew:du-2');
+ // A single resource files their own report.
+ const solo = deployment({ PersonnelId: 'dp-5', CrewUnitIds: [], WritableSubjectIds: ['dp-5'] });
+ expect(availableScopes(solo).map(scopeKey)).toEqual(['person:dp-5']);
+ expect(scopeKey(defaultScope(solo, null))).toBe('person:dp-5');
+ // A manager may open every active crew, their own and the deployment-wide DTR.
+ const manager = deployment({ CanManage: true, PersonnelId: null, CrewUnitIds: [], WritableSubjectIds: [] });
+ expect(availableScopes(manager).map(scopeKey)).toEqual(['crew:du-1', 'crew:du-2', 'deployment']);
+ expect(availableScopes({ ...deployment(), TimeAccess: null })).toEqual([]);
+});
+
+it('limits the picker to the report scope and to subjects the person may write', () => {
+ expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-1' }).map((subject) => subject.id)).toEqual(['du-1', 'dp-1', 'dp-2', 'de-1']);
+ expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-1' }).find((subject) => subject.id === 'dp-2')?.label).toBe('Crew (FF)');
+ expect(scopeSubjects(deployment(), { kind: 'crew', unitId: 'du-2' })).toEqual([]);
+ expect(scopeSubjects(deployment(), { kind: 'individual', personnelId: 'dp-1' }).map((subject) => subject.id)).toEqual(['dp-1']);
+ expect(scopeSubjects(deployment({ CanManage: true }), { kind: 'deployment' }).map((subject) => subject.id)).toEqual(['du-1', 'du-2', 'dp-1', 'dp-2', 'dp-3', 'dp-5', 'de-1', 'de-2']);
+});
+
+it('copies the unit row onto the crew only for writable rows of the same entry type', () => {
+ const rows = [
+ entry({ SubjectType: 1, DeploymentPersonnelId: null, DeploymentUnitId: 'du-1', StartLocal: '2026-09-19T06:00', EndLocal: '2026-09-19T20:00', UnpaidBreakMinutes: 30, AgencySuppliedMeals: true }),
+ entry({ DeploymentPersonnelId: 'dp-2' }),
+ entry({ DeploymentPersonnelId: 'dp-3' }),
+ entry({ DeploymentPersonnelId: 'dp-1', EntryType: 2 }),
+ ];
+ const applied = applyToCrew(rows, 0, (id) => id !== 'dp-3');
+ expect(applied[1]).toMatchObject({ StartLocal: '2026-09-19T06:00', EndLocal: '2026-09-19T20:00', UnpaidBreakMinutes: 30, AgencySuppliedMeals: true });
+ expect(applied[2]).toBe(rows[2]);
+ expect(applied[3]).toBe(rows[3]);
+ expect(hoursBySubject(applied)).toEqual({ 'du-1': 13.5, 'dp-2': 13.5, 'dp-3': 10, 'dp-1': 10 });
+});
+
+it('finds the report for a scope and day, the report already covering a subject, and editability', () => {
+ const crew = report({ Entries: [entry({ DeploymentPersonnelId: 'dp-1' })] });
+ const solo = report({ Id: 'r-2', Scope: 2, DeploymentUnitId: null, DeploymentPersonnelId: 'dp-5' });
+ const voided = report({ Id: 'r-3', Status: 4 });
+ expect(reportForScope([voided, crew, solo], '2026-09-19', { kind: 'crew', unitId: 'du-1' })?.Id).toBe('r-1');
+ expect(reportForScope([crew, solo], '2026-09-19', { kind: 'individual', personnelId: 'dp-5' })?.Id).toBe('r-2');
+ expect(reportForScope([crew], '2026-09-20', { kind: 'crew', unitId: 'du-1' })).toBeNull();
+ expect(coveringReport([crew, solo], '2026-09-19', 'dp-1')?.Id).toBe('r-1');
+ expect(coveringReport([crew], '2026-09-19', 'dp-1', 'r-1')).toBeNull();
+ expect(isReportEditable(crew)).toBe(true);
+ expect(isReportEditable({ ...crew, CanAct: false })).toBe(false);
+ expect(isReportEditable({ ...crew, Status: 1 })).toBe(false);
});
-it('matches a report by calendar day and maps server failures to short reason codes', () => {
- const reports = [{ Id: 'r-1', ReportDate: '2026-09-19T00:00:00', Entries: [] }, { Id: 'r-2', ReportDate: '2026-09-20T00:00:00', Entries: [] }] as never;
- expect(reportForDate(reports, '2026-09-20')?.Id).toBe('r-2');
- expect(reportForDate(reports, '2026-09-21')).toBeNull();
+it('maps server failures to short reason codes', () => {
expect(operationsError({ response: { status: 403, headers: { 'x-resgrid-reason': 'deployments_disabled' } } })).toBe('disabled');
expect(operationsError({ response: { status: 401 } })).toBe('denied');
- expect(operationsError({ response: { status: 400, headers: { 'x-resgrid-reason': 'time_report_not_editable' } } })).toBe('time_report_not_editable');
+ expect(operationsError({ response: { status: 400, headers: { 'x-resgrid-reason': 'timereports_subject_covered' } } })).toBe('timereports_subject_covered');
expect(operationsError({ response: { status: 400 } })).toBe('validation');
expect(operationsError({ response: { status: 200, data: { type: 'protected_data_required' } } })).toBe('locked');
expect(operationsError(new Error('boom'))).toBe('retry');
diff --git a/src/lib/operations/capabilities.ts b/src/lib/operations/capabilities.ts
index 41f70935..b98c1dd7 100644
--- a/src/lib/operations/capabilities.ts
+++ b/src/lib/operations/capabilities.ts
@@ -1,11 +1,16 @@
import type { Href } from 'expo-router';
// What this app does with a deployment. The server enforces every rule again; this only shapes the UI.
+// Dispatchers see deployments and their time reports read-only.
export const operationsCapabilities = {
- /** Write daily time report entries (Responder: own row; Unit: the active unit's crew). */
+ /** Write crew / individual time reports (read-only here). */
editTime: false,
/** Record odometer / engine / fuel readings against a deployment unit. */
recordUsage: false,
+ /** Add expenses (meals, fuel, lodging) with a receipt photo. */
+ recordExpenses: false,
+ /** Approve submitted time reports when the person holds TimeReports_Approve. */
+ approveTime: false,
/** Draft and validate the CAL OES MARS F-42 from the field. */
draftF42: false,
homeRoute: '/home' as Href,
diff --git a/src/lib/operations/time.ts b/src/lib/operations/time.ts
index cfca99a3..b9085e07 100644
--- a/src/lib/operations/time.ts
+++ b/src/lib/operations/time.ts
@@ -1,41 +1,57 @@
import type { Deployment, TimeEntry, TimeReport } from '@/models/v4/operations';
-import { TimeEntryType, TimeSubjectType } from '@/models/v4/operations';
+import { TimeEntryType, TimeReportScope, TimeReportStatus, TimeSubjectType } from '@/models/v4/operations';
-// Pure helpers behind the daily time report editor. Times are deployment-local wall clock: the
-// server stores what the crew wrote on the paper DTR, so nothing here converts to UTC.
+// Pure helpers behind the crew / individual time report editor. Entry times are the department-local wall
+// clock the crew writes on a paper time report ("yyyy-MM-ddTHH:mm", StartLocal/EndLocal); the server does
+// the zone conversion, so nothing here touches UTC or the device's time zone.
const pad = (n: number) => String(n).padStart(2, '0');
export const localDateKey = (date = new Date()) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
+export const shiftDateKey = (dateKey: string, days: number) => {
+ const [y, m, d] = dateKey.split('-').map(Number);
+ return localDateKey(new Date(y, m - 1, d + days));
+};
+
export const timeOf = (value: string | null | undefined) => {
const match = /T(\d{2}):(\d{2})/.exec(value ?? '');
return match ? `${match[1]}:${match[2]}` : '';
};
+export const dateOf = (value: string | null | undefined) => /^(\d{4}-\d{2}-\d{2})/.exec(value ?? '')?.[1] ?? '';
+
export const isClockTime = (value: string) => /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
-export const combineDateTime = (dateKey: string, clock: string) => `${dateKey}T${clock}:00`;
+export const combineDateTime = (dateKey: string, clock: string) => `${dateKey}T${clock}`;
-const minutesOf = (value: string) => {
- const match = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
+const minutesOf = (value: string | null | undefined) => {
+ const match = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value ?? '');
if (!match) return null;
const day = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return day / 60000 + Number(match[4]) * 60 + Number(match[5]);
};
-export const entryMinutes = (entry: Pick) => {
- const start = minutesOf(entry.StartTime);
- const end = minutesOf(entry.EndTime);
+/** An end clock at or before the start clock is the next morning (a shift across midnight). */
+export const endLocalFor = (startLocal: string, clock: string) => {
+ const day = dateOf(startLocal);
+ return clock <= timeOf(startLocal) ? combineDateTime(shiftDateKey(day, 1), clock) : combineDateTime(day, clock);
+};
+
+export const entryMinutes = (entry: Pick) => {
+ const start = minutesOf(entry.StartLocal);
+ const end = minutesOf(entry.EndLocal);
if (start == null || end == null || end < start) return 0;
return Math.max(0, end - start - (entry.UnpaidBreakMinutes || 0));
};
-export const entryHours = (entry: Pick) => Math.round((entryMinutes(entry) / 60) * 100) / 100;
+export const entryHours = (entry: Pick) => Math.round((entryMinutes(entry) / 60) * 100) / 100;
export const sumHours = (entries: TimeEntry[]) => Math.round(entries.reduce((total, entry) => total + entryMinutes(entry), 0) / 0.6) / 100;
-export const subjectKey = (entry: TimeEntry) => `${entry.SubjectType}:${entry.DeploymentPersonnelId ?? entry.DeploymentUnitId ?? entry.DeploymentEquipmentId ?? ''}`;
+export const subjectIdOf = (entry: Pick) => entry.DeploymentPersonnelId ?? entry.DeploymentUnitId ?? entry.DeploymentEquipmentId ?? '';
+
+export const subjectKey = (entry: TimeEntry) => `${entry.SubjectType}:${subjectIdOf(entry)}`;
export interface EntryIssue {
code: 'missing_subject' | 'missing_time' | 'end_before_start' | 'overlap';
@@ -46,10 +62,9 @@ export const validateEntries = (entries: TimeEntry[]): EntryIssue[] => {
const issues: EntryIssue[] = [];
const seen: { key: string; start: number; end: number; index: number }[] = [];
entries.forEach((entry, index) => {
- const subject = entry.DeploymentPersonnelId || entry.DeploymentUnitId || entry.DeploymentEquipmentId;
- if (!subject) issues.push({ code: 'missing_subject', index });
- const start = minutesOf(entry.StartTime);
- const end = minutesOf(entry.EndTime);
+ if (!subjectIdOf(entry)) issues.push({ code: 'missing_subject', index });
+ const start = minutesOf(entry.StartLocal);
+ const end = minutesOf(entry.EndLocal);
if (start == null || end == null) {
issues.push({ code: 'missing_time', index });
return;
@@ -71,69 +86,151 @@ export interface EntrySubject {
label: string;
}
-export const newEntry = (subject: EntrySubject, dateKey: string, sortOrder: number): TimeEntry => ({
+export const newEntry = (subject: EntrySubject, dateKey: string, sortOrder: number, template?: Partial): TimeEntry => ({
Id: null,
SubjectType: subject.type,
DeploymentPersonnelId: subject.type === TimeSubjectType.Personnel ? subject.id : null,
DeploymentUnitId: subject.type === TimeSubjectType.Unit ? subject.id : null,
DeploymentEquipmentId: subject.type === TimeSubjectType.Equipment ? subject.id : null,
- EntryType: TimeEntryType.Deployment,
- StartTime: combineDateTime(dateKey, '08:00'),
- EndTime: combineDateTime(dateKey, '18:00'),
- PaidBreakMinutes: 0,
- UnpaidBreakMinutes: 0,
+ EntryType: template?.EntryType ?? TimeEntryType.Deployment,
+ StartLocal: template?.StartLocal ?? combineDateTime(dateKey, '08:00'),
+ EndLocal: template?.EndLocal ?? combineDateTime(dateKey, '18:00'),
+ PaidBreakMinutes: template?.PaidBreakMinutes ?? 0,
+ UnpaidBreakMinutes: template?.UnpaidBreakMinutes ?? 0,
CrewSizeSnapshot: null,
CertificationCode: null,
MileageKm: null,
FuelDeductionLitres: null,
- AgencySuppliedMeals: false,
- AgencySuppliedAccommodation: false,
+ AgencySuppliedMeals: template?.AgencySuppliedMeals ?? false,
+ AgencySuppliedAccommodation: template?.AgencySuppliedAccommodation ?? false,
Notes: null,
SortOrder: sortOrder,
});
-export interface RosterScope {
- userId: string | null;
- activeUnitId: string | null;
- manager: boolean;
-}
+// ---------------------------------------------------------------------------------------------------
+// Report scopes. A deployed unit files one Crew Time Report a day for the unit, its crew and its
+// equipment; a single resource files an individual report; a manager may keep the deployment-wide DTR.
+// ---------------------------------------------------------------------------------------------------
-const named = (name: string, callSign?: string | null) => (callSign ? `${name} (${callSign})` : name);
+export type TimeScope = { kind: 'crew'; unitId: string } | { kind: 'individual'; personnelId: string } | { kind: 'deployment' };
-// Who a person may write time for. A manager sees the whole roster; a crew member on an active unit
-// sees that unit, its crew and its equipment; anyone else only their own roster row. The server
-// enforces the same rule, this only keeps the picker honest.
-export const rosterSubjects = (deployment: Deployment, scope: RosterScope): EntrySubject[] => {
- const units = deployment.Units.filter((unit) => unit.IsActive);
- const personnel = deployment.Personnel.filter((person) => person.IsActive);
- const equipment = deployment.Equipment.filter((item) => item.IsActive);
- const people = (rows: typeof personnel) => rows.map((person) => ({ type: TimeSubjectType.Personnel, id: person.Id, label: named(person.Name, person.CallSign) }));
- const vehicles = (rows: typeof units) => rows.map((unit) => ({ type: TimeSubjectType.Unit, id: unit.Id, label: named(unit.UnitName, unit.CallSign) }));
- const gear = (rows: typeof equipment) => rows.map((item) => ({ type: TimeSubjectType.Equipment, id: item.Id, label: item.Name }));
- if (scope.manager) return [...people(personnel), ...vehicles(units), ...gear(equipment)];
- if (scope.activeUnitId) {
- const mine = units.filter((unit) => String(unit.UnitId) === scope.activeUnitId);
- if (mine.length > 0) {
- const ids = new Set(mine.map((unit) => unit.Id));
- return [
- ...people(personnel.filter((person) => !!person.DeploymentUnitId && ids.has(person.DeploymentUnitId))),
- ...vehicles(mine),
- ...gear(equipment.filter((item) => !!item.DeploymentUnitId && ids.has(item.DeploymentUnitId))),
- ];
- }
+export const scopeKey = (scope: TimeScope | null | undefined) => (!scope ? '' : scope.kind === 'crew' ? `crew:${scope.unitId}` : scope.kind === 'individual' ? `person:${scope.personnelId}` : 'deployment');
+
+export const reportScope = (report: Pick): TimeScope =>
+ report.Scope === TimeReportScope.Crew && report.DeploymentUnitId
+ ? { kind: 'crew', unitId: report.DeploymentUnitId }
+ : report.Scope === TimeReportScope.Individual && report.DeploymentPersonnelId
+ ? { kind: 'individual', personnelId: report.DeploymentPersonnelId }
+ : { kind: 'deployment' };
+
+// A report's date arrives as a date-time; match on the calendar day only.
+export const reportsOnDate = (reports: TimeReport[], dateKey: string) => reports.filter((report) => dateOf(report.ReportDate) === dateKey && report.Status !== TimeReportStatus.Void);
+
+export const reportForScope = (reports: TimeReport[], dateKey: string, scope: TimeScope | null) =>
+ scope ? (reportsOnDate(reports, dateKey).find((report) => scopeKey(reportScope(report)) === scopeKey(scope)) ?? null) : null;
+
+/** The report that already carries this subject's time today, if it is not the one being edited. */
+export const coveringReport = (reports: TimeReport[], dateKey: string, subjectId: string, exceptReportId?: string | null) =>
+ reportsOnDate(reports, dateKey).find((report) => report.Id !== exceptReportId && report.Entries.some((entry) => subjectIdOf(entry) === subjectId)) ?? null;
+
+/** The scopes this person may open, straight from the server's TimeAccess (never inferred on the device). */
+export const availableScopes = (deployment: Deployment): TimeScope[] => {
+ const access = deployment.TimeAccess;
+ if (!access) return [];
+ const activeUnits = new Set(deployment.Units.filter((unit) => unit.IsActive).map((unit) => unit.Id));
+ const scopes: TimeScope[] = [];
+ const crewUnits = access.CanManage ? deployment.Units.filter((unit) => unit.IsActive).map((unit) => unit.Id) : access.CrewUnitIds.filter((id) => activeUnits.has(id));
+ crewUnits.forEach((unitId) => scopes.push({ kind: 'crew', unitId }));
+ if (access.PersonnelId) scopes.push({ kind: 'individual', personnelId: access.PersonnelId });
+ if (access.CanManage) scopes.push({ kind: 'deployment' });
+ return scopes;
+};
+
+/**
+ * The scope a screen opens on: the active unit's crew report on the unit tablet; otherwise the crew the person
+ * is seated on (the crew boss files the CTR); otherwise their own report; otherwise a manager's first scope.
+ */
+export const defaultScope = (deployment: Deployment, activeUnitId: string | null): TimeScope | null => {
+ const scopes = availableScopes(deployment);
+ if (activeUnitId) {
+ const unit = deployment.Units.find((candidate) => candidate.IsActive && String(candidate.UnitId) === activeUnitId);
+ const crew = unit ? scopes.find((scope) => scope.kind === 'crew' && scope.unitId === unit.Id) : undefined;
+ if (crew) return crew;
+ }
+ const own = deployment.TimeAccess?.PersonnelId ? deployment.Personnel.find((person) => person.Id === deployment.TimeAccess?.PersonnelId) : undefined;
+ const ownCrew = own?.DeploymentUnitId ? scopes.find((scope) => scope.kind === 'crew' && scope.unitId === own.DeploymentUnitId) : undefined;
+ if (ownCrew) return ownCrew;
+ if (!deployment.TimeAccess?.CanManage) {
+ const seated = scopes.find((scope) => scope.kind === 'crew');
+ if (seated) return seated;
}
- return people(personnel.filter((person) => person.UserId === scope.userId));
+ return scopes.find((scope) => scope.kind === 'individual') ?? scopes[0] ?? null;
};
-export const subjectLabel = (entry: TimeEntry, subjects: EntrySubject[]) => {
- const id = entry.DeploymentPersonnelId ?? entry.DeploymentUnitId ?? entry.DeploymentEquipmentId ?? '';
- return subjects.find((subject) => subject.type === entry.SubjectType && subject.id === id)?.label ?? '';
+const named = (name: string, callSign?: string | null) => (callSign ? `${name} (${callSign})` : name);
+
+export const canWriteSubject = (deployment: Deployment, subjectId: string) => !!deployment.TimeAccess?.CanManage || (deployment.TimeAccess?.WritableSubjectIds ?? []).includes(subjectId);
+
+/** Who or what a report in this scope may carry that the person may write: the picker for new rows. */
+export const scopeSubjects = (deployment: Deployment, scope: TimeScope | null): EntrySubject[] => {
+ if (!scope) return [];
+ const inUnit = (unitId?: string | null) => scope.kind === 'deployment' || (scope.kind === 'crew' && unitId === scope.unitId);
+ const people = deployment.Personnel.filter((person) => person.IsActive && (scope.kind === 'individual' ? person.Id === scope.personnelId : inUnit(person.DeploymentUnitId)));
+ const units = scope.kind === 'individual' ? [] : deployment.Units.filter((unit) => unit.IsActive && inUnit(unit.Id));
+ const equipment = scope.kind === 'individual' ? [] : deployment.Equipment.filter((item) => item.IsActive && inUnit(item.DeploymentUnitId));
+ return [
+ ...units.map((unit) => ({ type: TimeSubjectType.Unit, id: unit.Id, label: named(unit.UnitName, unit.CallSign) })),
+ ...people.map((person) => ({ type: TimeSubjectType.Personnel, id: person.Id, label: named(person.Name, person.CallSign) })),
+ ...equipment.map((item) => ({ type: TimeSubjectType.Equipment, id: item.Id, label: item.Name })),
+ ].filter((subject) => canWriteSubject(deployment, subject.id));
};
-// A report's date arrives as a date-time; match on the calendar day only.
-export const reportForDate = (reports: TimeReport[], dateKey: string) => reports.find((report) => (report.ReportDate ?? '').slice(0, 10) === dateKey) ?? null;
+/** Every roster name by subject id (active or since released), so a stored row always has a label. */
+export const subjectNames = (deployment: Deployment) => {
+ const names: Record = {};
+ deployment.Units.forEach((unit) => (names[unit.Id] = named(unit.UnitName, unit.CallSign)));
+ deployment.Personnel.forEach((person) => (names[person.Id] = named(person.Name, person.CallSign)));
+ deployment.Equipment.forEach((item) => (names[item.Id] = item.Name));
+ return names;
+};
+
+export const scopeName = (deployment: Deployment, scope: TimeScope | null) => {
+ if (!scope || scope.kind === 'deployment') return '';
+ return subjectNames(deployment)[scope.kind === 'crew' ? scope.unitId : scope.personnelId] ?? '';
+};
+
+/**
+ * The CTR shortcut: most crews work the unit's shift together, so copy one row's times, breaks and agency
+ * supplies onto every other row of the same entry type the person may write. Rows that differ are then
+ * edited one by one.
+ */
+export const applyToCrew = (entries: TimeEntry[], sourceIndex: number, writable: (subjectId: string) => boolean): TimeEntry[] => {
+ const source = entries[sourceIndex];
+ if (!source) return entries;
+ return entries.map((entry, index) =>
+ index === sourceIndex || entry.EntryType !== source.EntryType || !writable(subjectIdOf(entry))
+ ? entry
+ : {
+ ...entry,
+ StartLocal: source.StartLocal,
+ EndLocal: source.EndLocal,
+ PaidBreakMinutes: source.PaidBreakMinutes,
+ UnpaidBreakMinutes: source.UnpaidBreakMinutes,
+ AgencySuppliedMeals: source.AgencySuppliedMeals,
+ AgencySuppliedAccommodation: source.AgencySuppliedAccommodation,
+ }
+ );
+};
+
+/** Hours per subject for the report summary (the crew boss's check before signing). */
+export const hoursBySubject = (entries: TimeEntry[]) =>
+ entries.reduce>((totals, entry) => {
+ const id = subjectIdOf(entry);
+ totals[id] = Math.round(((totals[id] ?? 0) + entryHours(entry)) * 100) / 100;
+ return totals;
+ }, {});
-export const isReportEditable = (report: TimeReport | null) => !!report && report.Status === 0;
+export const isReportEditable = (report: TimeReport | null) => !!report && report.Status === TimeReportStatus.Draft && report.CanAct;
export const operationsError = (error: unknown): string => {
const response = (error as { response?: { status?: number; headers?: Record; data?: { type?: string } } })?.response;
diff --git a/src/lib/poi-map-layers.ts b/src/lib/poi-map-layers.ts
index 2e6bbfc3..ef220217 100644
--- a/src/lib/poi-map-layers.ts
+++ b/src/lib/poi-map-layers.ts
@@ -9,6 +9,30 @@ export const createDefaultVisiblePoiLayerIds = (poiLayers: PoiLayerData[]) => {
return new Set(poiLayers.map((poiLayer) => getPoiMapLayerId(poiLayer.PoiTypeId)));
};
+/**
+ * Carries the user's POI layer toggles across a map data refresh.
+ *
+ * Layers the map already knew keep whatever visibility the user gave them, layers that are new in this refresh
+ * start visible (the default), and layers that disappeared are dropped. Returns `current` itself when nothing
+ * changed, so a background refetch does not re-filter or re-render the pins.
+ */
+export const mergeVisiblePoiLayerIds = (current: Set, previousPoiLayers: PoiLayerData[], nextPoiLayers: PoiLayerData[]) => {
+ const previousIds = new Set(previousPoiLayers.map((poiLayer) => getPoiMapLayerId(poiLayer.PoiTypeId)));
+
+ const merged = new Set();
+ nextPoiLayers.forEach((poiLayer) => {
+ const layerId = getPoiMapLayerId(poiLayer.PoiTypeId);
+ if (!previousIds.has(layerId) || current.has(layerId)) {
+ merged.add(layerId);
+ }
+ });
+
+ if (merged.size === current.size && Array.from(merged).every((layerId) => current.has(layerId))) {
+ return current;
+ }
+ return merged;
+};
+
export const filterMapPinsByPoiLayers = (pins: MapMakerInfoData[], visiblePoiLayerIds: Set) => {
return pins.filter((pin) => {
if (pin.Type !== MapMarkerEntityType.Poi || pin.PoiTypeId == null) {
diff --git a/src/lib/records/__tests__/uploads.test.ts b/src/lib/records/__tests__/uploads.test.ts
index f12e2637..1161b28f 100644
--- a/src/lib/records/__tests__/uploads.test.ts
+++ b/src/lib/records/__tests__/uploads.test.ts
@@ -1,4 +1,6 @@
-import { runUpload } from '@/lib/records/uploads';
+import { createHash } from 'crypto';
+
+import { FILE_READ_CHUNK_BYTES, hashFile, runUpload } from '@/lib/records/uploads';
// Shared conformance suite for resumable attachment upload (RMS plan RMS-1D). Identical in all four
// app repositories: the server owns the session, so every adapter must resume from the server's own
@@ -22,8 +24,10 @@ jest.mock('expo-file-system/legacy', () => ({
jest.mock('expo-crypto', () => ({
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
- CryptoEncoding: { HEX: 'hex' },
- digestStringAsync: jest.fn(),
+ digest: jest.fn(async (_algorithm: string, data: Uint8Array) => {
+ const digest = jest.requireActual('crypto').createHash('sha256').update(data).digest();
+ return digest.buffer.slice(digest.byteOffset, digest.byteOffset + digest.byteLength);
+ }),
}));
jest.mock('@/lib/logging', () => ({
@@ -36,6 +40,18 @@ const fs = jest.requireMock('expo-file-system/legacy');
// 9 bytes encodes to 12 base64 characters, so a 3-byte chunk size slices cleanly.
const NINE_BYTES_BASE64 = 'AAAAAAAAAAAA';
+/** Serves `bytes` as the device file, honouring the byte range a ranged read asks for. */
+const serveFile = (bytes: Buffer) => {
+ fs.getInfoAsync.mockResolvedValue({ exists: true, size: bytes.length });
+ fs.readAsStringAsync.mockImplementation(async (_uri: string, options?: { position?: number; length?: number }) => {
+ const start = options?.position ?? 0;
+ return (options?.length === undefined ? bytes.subarray(start) : bytes.subarray(start, start + options.length)).toString('base64');
+ });
+};
+
+/** The byte ranges the code asked the file system for. */
+const readLengths = (): (number | undefined)[] => fs.readAsStringAsync.mock.calls.map((call: unknown[]) => (call[1] as { length?: number } | undefined)?.length);
+
const pending = (overrides: Record = {}) => ({
localId: 'upload-1',
recordId: 'r1',
@@ -69,8 +85,7 @@ const session = (receivedBytes: number, chunkSize = 3) => ({
describe('Record attachment uploads', () => {
beforeEach(() => {
jest.clearAllMocks();
- fs.getInfoAsync.mockResolvedValue({ exists: true, size: 9 });
- fs.readAsStringAsync.mockResolvedValue(NINE_BYTES_BASE64);
+ serveFile(Buffer.from(NINE_BYTES_BASE64, 'base64'));
});
it('sends the file in chunks and completes it', async () => {
@@ -93,8 +108,7 @@ describe('Record attachment uploads', () => {
// 10 bytes: three full 3-byte chunks (4 base64 chars each) and a trailing single byte, which
// base64 pads to a full 4-character group ("AA==") that must reach the server intact.
const tenBytesBase64 = 'AAAAAAAAAAAAAA==';
- fs.getInfoAsync.mockResolvedValue({ exists: true, size: 10 });
- fs.readAsStringAsync.mockResolvedValue(tenBytesBase64);
+ serveFile(Buffer.from(tenBytesBase64, 'base64'));
api.beginRecordUpload.mockResolvedValue({ Data: { ...session(0).Data, DeclaredSize: 10, ChunkCount: 4 } });
api.uploadRecordChunk.mockResolvedValueOnce(session(3)).mockResolvedValueOnce(session(6)).mockResolvedValueOnce(session(9)).mockResolvedValueOnce(session(10));
api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a4' } });
@@ -108,6 +122,72 @@ describe('Record attachment uploads', () => {
expect(chunks.map((chunk) => chunk.Data).join('')).toBe(tenBytesBase64);
});
+ it('cuts chunks at the server chunk size even when it is not a multiple of 3', async () => {
+ // The server declares 512 KiB, which is not a multiple of 3; it refuses any chunk that is not exactly
+ // that size except the last, so the chunks are cut from the bytes, not from the base64 text.
+ const bytes = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
+ serveFile(bytes);
+ api.beginRecordUpload.mockResolvedValue(session(0, 4));
+ api.uploadRecordChunk.mockResolvedValueOnce(session(4, 4)).mockResolvedValueOnce(session(8, 4)).mockResolvedValueOnce(session(10, 4));
+ api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a5' } });
+
+ const outcome = await runUpload(pending({ byteSize: 10 }) as never);
+
+ expect(outcome.ok).toBe(true);
+ const chunks: { Offset: number; Data: string }[] = api.uploadRecordChunk.mock.calls.map((call: unknown[]) => call[0] as { Offset: number; Data: string });
+ expect(chunks.map((chunk) => chunk.Offset)).toEqual([0, 4, 8]);
+ expect(chunks.map((chunk) => Buffer.from(chunk.Data, 'base64').length)).toEqual([4, 4, 2]);
+ expect(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk.Data, 'base64')))).toEqual(bytes);
+ });
+
+ it('fails instead of resending forever when the server count does not advance', async () => {
+ api.beginRecordUpload.mockResolvedValue(session(0));
+ api.uploadRecordChunk.mockResolvedValueOnce(session(3)).mockResolvedValue(session(3));
+
+ const outcome = await runUpload(pending() as never);
+
+ expect(outcome).toMatchObject({ ok: false, code: 'chunk_stalled', sentBytes: 3, uploadId: 'session-1' });
+ expect(api.uploadRecordChunk).toHaveBeenCalledTimes(2);
+ expect(api.completeRecordUpload).not.toHaveBeenCalled();
+ });
+
+ it('hashes the decoded file bytes, the same bytes the server assembles', async () => {
+ const bytes = Buffer.from('synthetic attachment bytes');
+ serveFile(bytes);
+
+ await expect(hashFile('file:///photo.jpg')).resolves.toBe(createHash('sha256').update(bytes).digest('hex'));
+ });
+
+ it('reads a large file a range at a time, for the hash and for the upload', async () => {
+ const bytes = Buffer.alloc(FILE_READ_CHUNK_BYTES * 2 + 5, 7);
+ serveFile(bytes);
+
+ await expect(hashFile('file:///video.mp4')).resolves.toBe(createHash('sha256').update(bytes).digest('hex'));
+ expect(readLengths()).toEqual([FILE_READ_CHUNK_BYTES, FILE_READ_CHUNK_BYTES, 5]);
+
+ fs.readAsStringAsync.mockClear();
+ const chunkSize = FILE_READ_CHUNK_BYTES;
+ api.beginRecordUpload.mockResolvedValue(session(0, chunkSize));
+ api.uploadRecordChunk
+ .mockResolvedValueOnce(session(chunkSize, chunkSize))
+ .mockResolvedValueOnce(session(chunkSize * 2, chunkSize))
+ .mockResolvedValueOnce(session(bytes.length, chunkSize));
+ api.completeRecordUpload.mockResolvedValue({ Data: { AttachmentId: 'a6' } });
+
+ const outcome = await runUpload(pending({ byteSize: bytes.length }) as never);
+
+ expect(outcome.ok).toBe(true);
+ expect(readLengths()).toEqual([chunkSize, chunkSize, 5]);
+ });
+
+ it('refuses to hash a file that changed while it was being read', async () => {
+ const bytes = Buffer.from('synthetic attachment bytes');
+ serveFile(bytes);
+ fs.getInfoAsync.mockResolvedValue({ exists: true, size: bytes.length + 4 });
+
+ await expect(hashFile('file:///photo.jpg')).rejects.toThrow('The file changed while it was being read.');
+ });
+
it('resumes from the count the server reports, not the one the device remembers', async () => {
api.getRecordUpload.mockResolvedValue(session(6));
api.uploadRecordChunk.mockResolvedValue(session(9));
diff --git a/src/lib/records/uploads.ts b/src/lib/records/uploads.ts
index c5cc8f38..1978ffb1 100644
--- a/src/lib/records/uploads.ts
+++ b/src/lib/records/uploads.ts
@@ -1,3 +1,4 @@
+import { Buffer } from 'buffer';
import * as Crypto from 'expo-crypto';
import * as FileSystem from 'expo-file-system/legacy';
@@ -65,11 +66,14 @@ const problemMessage = (error: unknown): string => {
return response?.data?.title ?? (error instanceof Error ? error.message : 'Upload failed');
};
-/** SHA-256 of the file, hex lower-case, computed without holding the whole file as a string twice. */
-export const hashFile = async (fileUri: string): Promise => {
- const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
- return (await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, base64, { encoding: Crypto.CryptoEncoding.HEX })).toLowerCase();
-};
+/** The most of a file read at once; an attachment can be a long video, far too big to hold as one string. */
+export const FILE_READ_CHUNK_BYTES = 3 * 1024 * 1024;
+
+/**
+ * One byte range of the file as base64. Ranges are read and encoded on their own, so a chunk can start at
+ * any offset whatever it is modulo 3, and the file is never held as one base64 string.
+ */
+const readRange = (fileUri: string, position: number, length: number): Promise => FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64, position, length });
export const fileSize = async (fileUri: string): Promise => {
const info = await FileSystem.getInfoAsync(fileUri);
@@ -77,21 +81,24 @@ export const fileSize = async (fileUri: string): Promise => {
};
/**
- * The server hashes the assembled bytes, so the hash we declare has to be of the same bytes. Reading
- * base64 once and slicing it keeps the chunk boundaries aligned: base64 encodes 3 bytes as 4 chars,
- * so a chunk size that is a multiple of 3 slices cleanly without re-encoding anything.
+ * SHA-256 of the file's bytes, hex lower-case. The server hashes the assembled binary, so the digest is
+ * taken over the decoded bytes; hashing the base64 text would never match and every upload would fail.
+ * The digest needs every byte at once, but they are gathered a range at a time, so the file's base64 text
+ * is never held alongside them.
*/
-const chunkOf = (base64: string, offsetBytes: number, chunkBytes: number): string => {
- const start = (offsetBytes / 3) * 4;
- // Only the final chunk can be short of a multiple of 3; its trailing 1 or 2 bytes still occupy a
- // full padded 4-character group, and slice() would otherwise truncate the fraction and drop them.
- const length = Math.ceil(chunkBytes / 3) * 4;
- return base64.slice(start, start + length);
-};
-
-const alignChunkSize = (chunkSize: number): number => {
- const safe = Math.max(3, Math.min(chunkSize || 0, 3 * 1024 * 1024));
- return safe - (safe % 3);
+export const hashFile = async (fileUri: string): Promise => {
+ const size = await fileSize(fileUri);
+ const bytes = Buffer.alloc(size);
+ for (let offset = 0; offset < size; offset += FILE_READ_CHUNK_BYTES) {
+ const length = Math.min(FILE_READ_CHUNK_BYTES, size - offset);
+ const copied = Buffer.from(await readRange(fileUri, offset, length), 'base64').copy(bytes, offset);
+ if (copied !== length) {
+ // A short read means the file changed under us; a digest of it would only be refused at completion.
+ throw new Error('The file changed while it was being read.');
+ }
+ }
+ const digest = await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, bytes);
+ return Buffer.from(digest).toString('hex');
};
export interface UploadOptions {
@@ -146,8 +153,9 @@ export const runUpload = async (pending: PendingUpload, options: UploadOptions =
return { ok: false, code: 'no_session', message: 'The server did not open an upload.', uploadId: null };
}
- const chunkSize = alignChunkSize(session.ChunkSize);
- const base64 = await FileSystem.readAsStringAsync(pending.fileUri, { encoding: FileSystem.EncodingType.Base64 });
+ // The server accepts a chunk only at an offset that is a multiple of its own chunk size (only the last
+ // may be shorter), so each chunk is read from the file at exactly that size and offset.
+ const chunkSize = session.ChunkSize > 0 ? session.ChunkSize : pending.byteSize;
// The server's count is authoritative: it is the only thing that knows what actually arrived.
let sent = session.ReceivedBytes ?? 0;
options.onProgress?.({ sentBytes: sent, totalBytes: pending.byteSize });
@@ -157,11 +165,15 @@ export const runUpload = async (pending: PendingUpload, options: UploadOptions =
return { ok: false, code: 'cancelled', sentBytes: sent, uploadId: session.UploadId };
}
const size = Math.min(chunkSize, pending.byteSize - sent);
- const data = chunkOf(base64, sent, size);
+ const data = await readRange(pending.fileUri, sent, size);
const updated = (await uploadRecordChunk({ UploadId: session.UploadId, Offset: sent, Data: data }, options.signal))?.Data;
if (!updated) {
return { ok: false, code: 'chunk_failed', sentBytes: sent, uploadId: session.UploadId };
}
+ // A count that did not move means the chunk was not taken; sending it again would loop forever.
+ if (!(updated.ReceivedBytes > sent)) {
+ return { ok: false, code: 'chunk_stalled', sentBytes: sent, uploadId: session.UploadId };
+ }
// Trust the server's new count rather than adding locally, so a partially accepted chunk
// cannot leave the client and the server disagreeing about where the file is.
sent = updated.ReceivedBytes;
diff --git a/src/lib/server-url.ts b/src/lib/server-url.ts
index bfbef9c2..29602be0 100644
--- a/src/lib/server-url.ts
+++ b/src/lib/server-url.ts
@@ -1,4 +1,7 @@
+import { getSystemConfig } from '@/api/config';
import { Env } from '@/lib/env';
+import { logger } from '@/lib/logging';
+import { type ResgridSystemLocation } from '@/models/v4/configs/getSystemConfigResultData';
/**
* Shared helpers for resolving the Resgrid base API URL from either a selected
@@ -15,6 +18,28 @@ export const CUSTOM_SERVER_VALUE = '__custom__';
// and bearer tokens in cleartext.
export const URL_PATTERN = /^(https:\/\/.+|http:\/\/(localhost|127\.0\.0\.1)(:\d+)?([/?#].*)?)$/i;
+// The Resgrid hosted sites, mirroring the hosted /Config/GetSystemConfig response. They are
+// always offered in the dropdown so a user on an unreachable or self-hosted custom URL (whose
+// config can't be loaded, or doesn't list them) can still switch back to a hosted site.
+export const RESGRID_HOSTED_LOCATIONS: readonly ResgridSystemLocation[] = [
+ {
+ Name: 'US-West',
+ DisplayName: 'Resgrid North America (Global)',
+ LocationInfo: '',
+ IsDefault: true,
+ ApiUrl: 'https://api.resgrid.com',
+ AllowsFreeAccounts: true,
+ },
+ {
+ Name: 'EU-Central',
+ DisplayName: 'Resgrid Europe',
+ LocationInfo: '',
+ IsDefault: false,
+ ApiUrl: 'https://api-eu-central.resgrid.com',
+ AllowsFreeAccounts: false,
+ },
+];
+
// The API path suffix (e.g. /api/v4) appended to every base URL.
const API_PATH_SUFFIX = `/api/${Env.API_VERSION}`;
@@ -29,3 +54,36 @@ export const toBaseUrl = (url: string) => stripTrailingSlashes(url).replace(/\/a
// Build the full API URL (base + /api/vX) that gets persisted and read by the
// API client. Always produces exactly one API suffix to avoid /api/v4/api/v4.
export const buildApiUrl = (url: string) => `${toBaseUrl(url)}${API_PATH_SUFFIX}`;
+
+// Hostnames are case-insensitive, so compare bases the same way.
+const toComparableUrl = (url: string) => toBaseUrl(url).toLowerCase();
+
+// Whether two URLs (with or without the /api/vX suffix) point at the same server.
+export const isSameServerUrl = (a: string, b: string) => toComparableUrl(a) === toComparableUrl(b);
+
+// Find the site whose API URL matches the given (stored or entered) URL.
+export const findLocationByUrl = (locations: readonly ResgridSystemLocation[], url: string) => locations.find((location) => isSameServerUrl(location.ApiUrl, url));
+
+// Combine the sites reported by the server with the built-in hosted sites. Server-reported
+// entries come first and win on a duplicate URL or name (names are the dropdown values).
+export const mergeServerLocations = (fetched: readonly ResgridSystemLocation[]): ResgridSystemLocation[] => {
+ const valid = fetched.filter((location) => !!location?.Name && !!location?.ApiUrl);
+ const missing = RESGRID_HOSTED_LOCATIONS.filter((hosted) => !valid.some((location) => location.Name === hosted.Name || isSameServerUrl(location.ApiUrl, hosted.ApiUrl)));
+ return [...valid, ...missing];
+};
+
+// Load the selectable sites from the configured server, falling back to the built-in hosted
+// sites when it can't be reached so the dropdown is never left with only "Custom".
+export const loadServerLocations = async (): Promise => {
+ let fetched: ResgridSystemLocation[] = [];
+ try {
+ const result = await getSystemConfig();
+ fetched = result?.Data?.Locations ?? [];
+ } catch (error) {
+ logger.warn({
+ message: 'Failed to load Resgrid hosted sites, using built-in list',
+ context: { error },
+ });
+ }
+ return mergeServerLocations(fetched);
+};
diff --git a/src/lib/storage/__tests__/app.test.ts b/src/lib/storage/__tests__/app.test.ts
new file mode 100644
index 00000000..90e88a7c
--- /dev/null
+++ b/src/lib/storage/__tests__/app.test.ts
@@ -0,0 +1,47 @@
+const mockStore = new Map();
+
+jest.mock('@env', () => ({ Env: { BASE_API_URL: 'https://api.resgrid.com', API_VERSION: 'v4' } }));
+jest.mock('@/lib/storage', () => ({
+ getItem: (key: string) => {
+ const value = mockStore.get(key);
+ return value ? JSON.parse(value) : null;
+ },
+ setItem: async (key: string, value: unknown) => {
+ mockStore.set(key, JSON.stringify(value));
+ },
+ removeItem: async (key: string) => {
+ mockStore.delete(key);
+ },
+}));
+
+import { getBaseApiUrl, removeBaseApiUrl, setBaseApiUrl } from '../app';
+
+describe('base API url storage', () => {
+ beforeEach(() => {
+ mockStore.clear();
+ });
+
+ it('defaults to the environment url when nothing has been saved', () => {
+ expect(getBaseApiUrl()).toBe('https://api.resgrid.com/api/v4');
+ });
+
+ it('returns the saved url on later reads (e.g. after a reload)', async () => {
+ await setBaseApiUrl('https://api-eu-central.resgrid.com/api/v4');
+
+ expect(mockStore.get('baseUrl')).toBe(JSON.stringify('https://api-eu-central.resgrid.com/api/v4'));
+ expect(getBaseApiUrl()).toBe('https://api-eu-central.resgrid.com/api/v4');
+ });
+
+ it('normalizes whitespace and trailing slashes', async () => {
+ await setBaseApiUrl(' https://dispatch.example.org/api/v4/ ');
+
+ expect(getBaseApiUrl()).toBe('https://dispatch.example.org/api/v4');
+ });
+
+ it('falls back to the default after the saved url is removed', async () => {
+ await setBaseApiUrl('https://api-eu-central.resgrid.com/api/v4');
+ await removeBaseApiUrl();
+
+ expect(getBaseApiUrl()).toBe('https://api.resgrid.com/api/v4');
+ });
+});
diff --git a/src/lib/storage/app.tsx b/src/lib/storage/app.tsx
index 2f74eb82..30f56faa 100644
--- a/src/lib/storage/app.tsx
+++ b/src/lib/storage/app.tsx
@@ -5,15 +5,19 @@ import { getItem, removeItem, setItem } from '@/lib/storage';
const BASE_URL = 'baseUrl';
const DEVICE_UUID = 'unitDeviceUuid';
+// The selected server URL is persisted in MMKV on native and localStorage on web/Electron, so it
+// survives app restarts and leaving/returning to the site. Only an explicit save changes it.
+const normalizeStoredApiUrl = (value: string) => value.trim().replace(/\/+$/, '');
+
export const removeBaseApiUrl = () => removeItem(BASE_URL);
-export const setBaseApiUrl = (value: string) => setItem(BASE_URL, value);
+export const setBaseApiUrl = (value: string) => setItem(BASE_URL, normalizeStoredApiUrl(value));
export const getBaseApiUrl = () => {
const baseUrl = getItem(BASE_URL);
- if (!baseUrl) {
- return `${Env.BASE_API_URL}/api/${Env.API_VERSION}`;
+ if (!baseUrl || typeof baseUrl !== 'string' || !baseUrl.trim()) {
+ return normalizeStoredApiUrl(`${Env.BASE_API_URL}/api/${Env.API_VERSION}`);
}
- return baseUrl;
+ return normalizeStoredApiUrl(baseUrl);
};
export const removeDeviceUuid = () => removeItem(DEVICE_UUID);
diff --git a/src/lib/test-utils.tsx b/src/lib/test-utils.tsx
index 4203361e..c790fdea 100644
--- a/src/lib/test-utils.tsx
+++ b/src/lib/test-utils.tsx
@@ -1,18 +1,13 @@
import '@shopify/flash-list/jestSetup';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
-import { NavigationContainer } from '@react-navigation/native';
import type { RenderOptions } from '@testing-library/react-native';
import { render, userEvent } from '@testing-library/react-native';
import type { ReactElement } from 'react';
import React from 'react';
const createAppWrapper = () => {
- return ({ children }: { children: React.ReactNode }) => (
-
- {children}
-
- );
+ return ({ children }: { children: React.ReactNode }) => {children} ;
};
const customRender = (ui: ReactElement, options?: Omit) => {
diff --git a/src/lib/udf/__tests__/options.test.ts b/src/lib/udf/__tests__/options.test.ts
new file mode 100644
index 00000000..eb5d7d66
--- /dev/null
+++ b/src/lib/udf/__tests__/options.test.ts
@@ -0,0 +1,97 @@
+import { comboDisplayText, filterComboSuggestions, findComboOption, parseUdfOptions, selectedOptionKeys, UDF_FIELD_TYPE, type UdfOption } from '../options';
+
+// What the server stores in UdfField.ValidationRules (Newtonsoft, PascalCase).
+const serverRules = JSON.stringify({
+ MinLength: null,
+ MaxLength: null,
+ Regex: null,
+ Options: [
+ { Key: 'tx', Label: 'Transported' },
+ { Key: 'refused', Label: 'Refused care' },
+ { Key: 'Transported, ALS', Label: 'Transported, ALS' },
+ ],
+ CustomErrorMessage: null,
+});
+
+const options: UdfOption[] = [
+ { key: 'tx', label: 'Transported' },
+ { key: 'refused', label: 'Refused care' },
+];
+
+describe('parseUdfOptions', () => {
+ it('reads key/label pairs from the server JSON rules', () => {
+ expect(parseUdfOptions(serverRules)).toEqual([
+ { key: 'tx', label: 'Transported' },
+ { key: 'refused', label: 'Refused care' },
+ { key: 'Transported, ALS', label: 'Transported, ALS' },
+ ]);
+ });
+
+ it('accepts camelCase rules and falls back to the key for a missing label', () => {
+ expect(parseUdfOptions('{"options":[{"key":"a"},{"key":"b","label":"Bee"}]}')).toEqual([
+ { key: 'a', label: 'a' },
+ { key: 'b', label: 'Bee' },
+ ]);
+ });
+
+ it('drops options without a key', () => {
+ expect(parseUdfOptions('{"Options":[{"Key":" ","Label":"Blank"},{"Key":"ok","Label":"OK"}]}')).toEqual([{ key: 'ok', label: 'OK' }]);
+ });
+
+ it('returns nothing for empty, option-less or malformed JSON rules rather than splitting them on commas', () => {
+ expect(parseUdfOptions('')).toEqual([]);
+ expect(parseUdfOptions(null)).toEqual([]);
+ expect(parseUdfOptions('{"MinLength":3,"MaxLength":10}')).toEqual([]);
+ expect(parseUdfOptions('{"Options":[')).toEqual([]);
+ });
+
+ it('accepts a legacy comma-separated list', () => {
+ expect(parseUdfOptions('Red, Green ,Blue')).toEqual([
+ { key: 'Red', label: 'Red' },
+ { key: 'Green', label: 'Green' },
+ { key: 'Blue', label: 'Blue' },
+ ]);
+ });
+});
+
+describe('findComboOption', () => {
+ it('matches an exact key, then a label or key ignoring case', () => {
+ expect(findComboOption(options, 'tx')?.key).toBe('tx');
+ expect(findComboOption(options, ' refused CARE ')?.key).toBe('refused');
+ expect(findComboOption(options, 'TX')?.key).toBe('tx');
+ });
+
+ it('returns undefined for free text or nothing typed', () => {
+ expect(findComboOption(options, 'Referred to crisis line')).toBeUndefined();
+ expect(findComboOption(options, ' ')).toBeUndefined();
+ });
+});
+
+describe('comboDisplayText', () => {
+ it('shows a stored key as its label and free text as typed', () => {
+ expect(comboDisplayText(options, 'tx')).toBe('Transported');
+ expect(comboDisplayText(options, 'Referred to crisis line')).toBe('Referred to crisis line');
+ expect(comboDisplayText(options, 'REDACTED')).toBe('REDACTED');
+ expect(comboDisplayText(options, undefined)).toBe('');
+ });
+});
+
+describe('filterComboSuggestions', () => {
+ it('offers every option when nothing is typed or the text already names one', () => {
+ expect(filterComboSuggestions(options, '')).toEqual(options);
+ expect(filterComboSuggestions(options, 'Transported')).toEqual(options);
+ });
+
+ it('narrows to labels containing the typed text', () => {
+ expect(filterComboSuggestions(options, 'ref')).toEqual([{ key: 'refused', label: 'Refused care' }]);
+ expect(filterComboSuggestions(options, 'zzz')).toEqual([]);
+ });
+});
+
+describe('selectedOptionKeys', () => {
+ it('splits only a multi-select value', () => {
+ expect(selectedOptionKeys(UDF_FIELD_TYPE.MultiSelect, 'a, b,,c')).toEqual(['a', 'b', 'c']);
+ expect(selectedOptionKeys(UDF_FIELD_TYPE.Dropdown, 'Transported, ALS')).toEqual(['Transported, ALS']);
+ expect(selectedOptionKeys(UDF_FIELD_TYPE.Dropdown, '')).toEqual([]);
+ });
+});
diff --git a/src/lib/udf/options.ts b/src/lib/udf/options.ts
new file mode 100644
index 00000000..761c1aa9
--- /dev/null
+++ b/src/lib/udf/options.ts
@@ -0,0 +1,117 @@
+// User-defined field (UDF) option handling shared by the call, personnel, unit and contact custom-field UI.
+// Mirrors Resgrid.Model.Helpers.UdfValidationHelper on the server so what the app shows and sends matches what the
+// server stores.
+
+/** Server FieldDataType values (Resgrid.Model.UdfFieldDataType). */
+export const UDF_FIELD_TYPE = {
+ Text: 0,
+ Number: 1,
+ Decimal: 2,
+ Boolean: 3,
+ Date: 4,
+ DateTime: 5,
+ Dropdown: 6,
+ MultiSelect: 7,
+ Email: 8,
+ Phone: 9,
+ Url: 10,
+ /** Options are suggestions; a value naming an option is stored as its key, anything else as the typed text. */
+ ComboBox: 11,
+} as const;
+
+/** A predefined option. The key is what the server stores; the label is what a person reads. */
+export interface UdfOption {
+ key: string;
+ label: string;
+}
+
+interface RawUdfOption {
+ Key?: unknown;
+ Label?: unknown;
+ key?: unknown;
+ label?: unknown;
+}
+
+const asText = (value: unknown): string => (typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '');
+
+const toOption = (raw: RawUdfOption): UdfOption | null => {
+ const key = asText(raw.Key ?? raw.key).trim();
+ if (!key) return null;
+ const label = asText(raw.Label ?? raw.label).trim();
+ return { key, label: label || key };
+};
+
+/**
+ * Reads the options out of a field's ValidationRules. The server sends the rules as a JSON string
+ * ({"Options":[{"Key":"tx","Label":"Transported"}],...}); a plain comma-separated list is accepted for older data.
+ */
+export const parseUdfOptions = (validationRules: string | null | undefined): UdfOption[] => {
+ const rules = (validationRules ?? '').trim();
+ if (!rules) return [];
+
+ if (rules.startsWith('{')) {
+ try {
+ const parsed: unknown = JSON.parse(rules);
+ if (parsed && typeof parsed === 'object') {
+ const record = parsed as { Options?: unknown; options?: unknown };
+ const list = record.Options ?? record.options;
+ if (Array.isArray(list)) {
+ return list
+ .filter((o): o is RawUdfOption => Boolean(o) && typeof o === 'object')
+ .map(toOption)
+ .filter((o): o is UdfOption => o !== null);
+ }
+ }
+ return [];
+ } catch {
+ return [];
+ }
+ }
+
+ return rules
+ .split(',')
+ .map((o) => o.trim())
+ .filter((o) => o.length > 0)
+ .map((o) => ({ key: o, label: o }));
+};
+
+/**
+ * The option a combo box entry refers to: an exact key first, then a label or key ignoring case (the server's rule).
+ * Undefined means the entry is free text.
+ */
+export const findComboOption = (options: UdfOption[], value: string | null | undefined): UdfOption | undefined => {
+ const text = (value ?? '').trim();
+ if (!text) return undefined;
+ const lower = text.toLowerCase();
+ return options.find((o) => o.key === text) ?? options.find((o) => o.label.toLowerCase() === lower) ?? options.find((o) => o.key.toLowerCase() === lower);
+};
+
+/** The text a combo box input shows for a stored value: an option key shows its label, free text shows as typed. */
+export const comboDisplayText = (options: UdfOption[], value: string | null | undefined): string => {
+ const text = value ?? '';
+ const option = options.find((o) => o.key === text);
+ return option ? option.label : text;
+};
+
+/**
+ * Suggestions to offer under a combo box. Nothing typed, or text that already names an option, offers every option so
+ * the choice can be changed; otherwise the options whose label contains the text.
+ */
+export const filterComboSuggestions = (options: UdfOption[], text: string | null | undefined): UdfOption[] => {
+ const query = (text ?? '').trim().toLowerCase();
+ if (!query || findComboOption(options, query)) return options;
+ return options.filter((o) => o.label.toLowerCase().includes(query));
+};
+
+/** The keys a stored dropdown or multi-select value selects. */
+export const selectedOptionKeys = (fieldDataType: number, value: string | null | undefined): string[] => {
+ const text = (value ?? '').trim();
+ if (!text) return [];
+ if (fieldDataType === UDF_FIELD_TYPE.MultiSelect) {
+ return text
+ .split(',')
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0);
+ }
+ return [text];
+};
diff --git a/src/lib/unit-status-helpers.ts b/src/lib/unit-status-helpers.ts
index 282d1480..3ac28a47 100644
--- a/src/lib/unit-status-helpers.ts
+++ b/src/lib/unit-status-helpers.ts
@@ -11,9 +11,12 @@ import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
* type name) per group, so matching by id here is reliable — unlike the server's per-unit endpoint
* which resolves the type by name and silently falls back to the default statuses on a mismatch.
*
- * Only the statuses attached to the unit's type are returned. Falls back to the server-provided
- * scoped list, then the default group, when no custom-set group matches (i.e. the unit type has no
- * custom statuses).
+ * Only the statuses attached to the unit's type are returned. When no custom-set group matches (i.e.
+ * the unit type has no custom statuses), the department default group (`UnitType === '0'`) is used
+ * before the server-provided per-unit list: the default group carries the real default unit statuses
+ * with their destination `Detail` types (Responding / On Scene / Staging accept calls), whereas older
+ * servers answer the per-unit endpoint with a Detail-0 list that can never carry a call destination.
+ * The per-unit list is only the last resort, when the grouped response has no default group.
*/
export function resolveUnitStatusOptions(
unit: Pick | null | undefined,
@@ -34,10 +37,10 @@ export function resolveUnitStatusOptions(
if (byType?.Statuses?.length) return byType.Statuses;
}
- // 3. Server-provided per-unit list (already scoped for units without a custom set).
- if (fallback.length) return fallback;
-
- // 4. Department default unit statuses (the "0" group).
+ // 3. Department default unit statuses (the "0" group), which carry the proper Detail types.
const defaultGroup = list.find((group) => group.UnitType === '0');
- return defaultGroup?.Statuses ?? [];
+ if (defaultGroup?.Statuses?.length) return defaultGroup.Statuses;
+
+ // 4. Server-provided per-unit list (last resort when the grouped response has no default group).
+ return fallback;
}
diff --git a/src/models/v4/calls/callExtraDataResult.ts b/src/models/v4/calls/callExtraDataResult.ts
index bb7f0061..6eaa07ec 100644
--- a/src/models/v4/calls/callExtraDataResult.ts
+++ b/src/models/v4/calls/callExtraDataResult.ts
@@ -1,35 +1,10 @@
import { BaseV4Request } from '../baseV4Request';
import { CallExtraDataResultData } from './callExtraDataResultData';
-export interface CallExtraDataResult {
- Success: boolean;
- Message: string | null;
- CallExtraData: {
- AdditionalInfo: Record;
- ContactInfo: {
- Name: string;
- Phone: string;
- Email: string;
- };
- Protocols: {
- Name: string;
- Description: string;
- }[];
- Dispatched: {
- Name: string;
- Unit: string;
- Status: string;
- }[];
- Timeline: {
- Timestamp: string;
- User: string;
- Unit: string;
- Status: string;
- }[];
- // Add other fields as needed
- };
-}
-
+/**
+ * Response of `/Calls/GetCallExtraData`: the standard v4 envelope plus the call's form data,
+ * `Activity` (unit/personnel status events on the call), `Dispatches`, priority and protocols.
+ */
export class CallExtraDataResult extends BaseV4Request {
public Data: CallExtraDataResultData = new CallExtraDataResultData();
}
diff --git a/src/models/v4/calls/dispatchedEventResultData.ts b/src/models/v4/calls/dispatchedEventResultData.ts
index 36ff38fe..67ae00f5 100644
--- a/src/models/v4/calls/dispatchedEventResultData.ts
+++ b/src/models/v4/calls/dispatchedEventResultData.ts
@@ -11,4 +11,13 @@ export class DispatchedEventResultData {
public StatusText: string = '';
public StatusColor: string = '';
+
+ /**
+ * How a status entry was tied to this call (server `StatusDestinationSources`, see `@/lib/activity-link`):
+ * 1 Explicit (the sender chose the call); 2 CarryForward, 3 Dispatch, 4 Unit (auto-linked: sent without a
+ * call and linked by the server from the previous status, the one open dispatch, or the unit a person rode);
+ * 5 Inferred (set with no destination by a unit/person dispatched to this call while working it; derived at
+ * read time). Null/absent on older rows and non-status entries.
+ */
+ public DestinationSource?: number | null = null;
}
diff --git a/src/models/v4/contacts/contactResultData.ts b/src/models/v4/contacts/contactResultData.ts
index 483658e6..3a1451cc 100644
--- a/src/models/v4/contacts/contactResultData.ts
+++ b/src/models/v4/contacts/contactResultData.ts
@@ -66,4 +66,36 @@ export interface ContactResultData {
* across the list: a field withheld on one contact must not mark it on every other one.
*/
RedactedFields?: string[];
+ IsProtected?: boolean;
+ ProtectedReason?: string | null;
+ /** The contact category's name and color (both list rows and the detail read). */
+ CategoryName?: string | null;
+ CategoryColor?: string | null;
+ /** Resolved addresses (GetContactById only). */
+ PhysicalAddress?: ContactAddressData | null;
+ MailingAddress?: ContactAddressData | null;
+ /** Mobile-visible custom field values with their labels, in form order (GetContactById only). */
+ CustomFields?: ContactCustomFieldData[];
+}
+
+export interface ContactAddressData {
+ Address1?: string | null;
+ City?: string | null;
+ State?: string | null;
+ PostalCode?: string | null;
+ Country?: string | null;
+ /** One-line form for display and for a maps app. */
+ Formatted?: string | null;
+}
+
+export interface ContactCustomFieldData {
+ UdfFieldId: string;
+ Label: string;
+ /** The stored value; for option fields this is the option key. */
+ Value: string;
+ /** What to show: option keys resolved to labels, booleans as Yes/No. Older servers do not send it. */
+ DisplayValue?: string | null;
+ FieldDataType: number;
+ GroupName?: string | null;
+ SortOrder: number;
}
diff --git a/src/models/v4/operations/index.ts b/src/models/v4/operations/index.ts
index 4e81c640..0ca658ad 100644
--- a/src/models/v4/operations/index.ts
+++ b/src/models/v4/operations/index.ts
@@ -12,6 +12,9 @@ export interface OperationsResult {
export const DeploymentStatus = { Planned: 0, Standby: 1, Active: 2, Demobilizing: 3, Completed: 4, Cancelled: 5 } as const;
export const DeploymentFinanceMode = { OperationalOnly: 0, CostRecovery: 1, Billable: 2 } as const;
export const TimeReportStatus = { Draft: 0, Submitted: 1, Approved: 2, Billed: 3, Void: 4 } as const;
+/** Who a daily time report covers: the whole deployment (manager's DTR), one unit's crew (CTR) or one person. */
+export const TimeReportScope = { Deployment: 0, Crew: 1, Individual: 2 } as const;
+export const ExpenseType = { PerDiemMeal: 0, Accommodation: 1, PrivateAccommodation: 2, Ferry: 3, Fuel: 4, SupplyRestock: 5, Other: 6 } as const;
export const TimeSubjectType = { Personnel: 0, Unit: 1, Equipment: 2 } as const;
export const TimeEntryType = { Deployment: 0, Standby: 1, Travel: 2 } as const;
export const UsagePhase = { Mobilization: 0, Standby: 1, Incident: 2, Return: 3 } as const;
@@ -66,6 +69,22 @@ export interface Deployment {
Units: DeploymentUnit[];
Personnel: DeploymentPersonnel[];
Equipment: DeploymentEquipment[];
+ /** The caller's time scope, computed by the server (detail reads only). */
+ TimeAccess?: DeploymentTimeAccess | null;
+}
+
+/**
+ * What the signed-in person may do with this deployment's time: their own roster row (individual report),
+ * the deployed units they crew — on the deployment roster for that unit, or seated on the apparatus through
+ * an active unit role — (crew time report) and every subject id they may write. The server re-checks it all.
+ */
+export interface DeploymentTimeAccess {
+ CanManage: boolean;
+ CanApprove: boolean;
+ PersonnelId?: string | null;
+ CrewUnitIds: string[];
+ WritableSubjectIds: string[];
+ TimeZone?: string | null;
}
export interface TimeEntry {
@@ -75,8 +94,12 @@ export interface TimeEntry {
DeploymentUnitId?: string | null;
DeploymentEquipmentId?: string | null;
EntryType: number;
- StartTime: string;
- EndTime: string;
+ /** UTC instant from the server; the field apps read and write the local wall clock below. */
+ StartTime?: string | null;
+ EndTime?: string | null;
+ /** Department-local wall clock "yyyy-MM-ddTHH:mm"; the server converts it in the department's zone. */
+ StartLocal: string;
+ EndLocal: string;
PaidBreakMinutes: number;
UnpaidBreakMinutes: number;
CrewSizeSnapshot?: number | null;
@@ -95,15 +118,64 @@ export interface TimeReport {
DeploymentId: string;
ReportNumber: number;
ReportDate: string;
+ /** TimeReportScope value. */
+ Scope: number;
+ DeploymentUnitId?: string | null;
+ DeploymentPersonnelId?: string | null;
+ /** The caller may write, sign and submit this report (its scope is theirs). */
+ CanAct: boolean;
Status: number;
IncidentNumber?: string | null;
+ ResourceOrderNumber?: string | null;
RequestNumber?: string | null;
+ NoClear8?: boolean;
+ UnsafeConditionsStandDown?: boolean;
+ ContractorSignedByUserId?: string | null;
+ ContractorSignedOn?: string | null;
+ CustomerSignerName?: string | null;
+ CustomerSignedOn?: string | null;
+ SubmittedByUserId?: string | null;
SubmittedOn?: string | null;
+ ApprovedByUserId?: string | null;
ApprovedOn?: string | null;
Notes?: string | null;
Entries: TimeEntry[];
}
+export interface Expense {
+ Id: string;
+ DeploymentId: string;
+ TimeReportId?: string | null;
+ ExpenseDate: string;
+ ExpenseType: number;
+ MealCode?: string | null;
+ City?: string | null;
+ Description?: string | null;
+ Amount: number;
+ Currency?: string | null;
+ PreApproved: boolean;
+ Billable: boolean;
+ ReceiptAttachmentId?: number | null;
+ AddedByUserId?: string | null;
+ AddedOn?: string | null;
+}
+
+export interface ExpenseInput {
+ Id?: string | null;
+ DeploymentId: string;
+ TimeReportId?: string | null;
+ ExpenseDate: string;
+ ExpenseType: number;
+ City?: string | null;
+ Description?: string | null;
+ Amount: number;
+ Currency?: string | null;
+ /** Receipt photo, base64 (the server files it as a Receipt attachment). */
+ ReceiptData?: string | null;
+ ReceiptFileName?: string | null;
+ ReceiptContentType?: string | null;
+}
+
export interface TimeReportIssue {
Code: string;
SubjectId?: string | null;
diff --git a/src/models/v4/userDefinedFields/udfFieldResultData.ts b/src/models/v4/userDefinedFields/udfFieldResultData.ts
index a473beea..48b0892a 100644
--- a/src/models/v4/userDefinedFields/udfFieldResultData.ts
+++ b/src/models/v4/userDefinedFields/udfFieldResultData.ts
@@ -1,5 +1,6 @@
-// FieldDataType enum
-// Text=0, Number=1, Decimal=2, Boolean=3, Date=4, DateTime=5, Dropdown=6, MultiSelect=7, Email=8, Phone=9, Url=10
+// FieldDataType enum (see UDF_FIELD_TYPE in @/lib/udf/options)
+// Text=0, Number=1, Decimal=2, Boolean=3, Date=4, DateTime=5, Dropdown=6, MultiSelect=7, Email=8, Phone=9, Url=10, ComboBox=11
+// Dropdown, MultiSelect and ComboBox options live in ValidationRules (a JSON string); read them with parseUdfOptions.
export class UdfFieldResultData {
public UdfFieldId: string = '';
public UdfDefinitionId: string = '';
@@ -18,5 +19,4 @@ export class UdfFieldResultData {
public IsVisibleOnReports: boolean = true;
public IsEnabled: boolean = true;
public Visibility: number = 0;
- public Options: string[] = [];
}
diff --git a/src/services/__tests__/audio.service.test.ts b/src/services/__tests__/audio.service.test.ts
index 3f57d7ac..546663aa 100644
--- a/src/services/__tests__/audio.service.test.ts
+++ b/src/services/__tests__/audio.service.test.ts
@@ -1,10 +1,12 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
const mockSound = {
- setPositionAsync: jest.fn(),
- playAsync: jest.fn(),
- unloadAsync: jest.fn(),
- getStatusAsync: jest.fn(),
+ seekTo: jest.fn(),
+ play: jest.fn(),
+ remove: jest.fn(),
+ isLoaded: true,
+ loop: false,
+ volume: 1,
} as any;
const mockAsset = {
@@ -27,19 +29,10 @@ jest.mock('expo-asset', () => ({
},
}));
-// Mock expo-av
-jest.mock('expo-av', () => ({
- Audio: {
- setAudioModeAsync: jest.fn(),
- Sound: {
- createAsync: jest.fn(),
- },
- },
- InterruptionModeIOS: {
- DoNotMix: 'doNotMix',
- DuckOthers: 'duckOthers',
- MixWithOthers: 'mixWithOthers',
- },
+// Mock expo-audio
+jest.mock('expo-audio', () => ({
+ setAudioModeAsync: jest.fn(),
+ createAudioPlayer: jest.fn(),
}));
// Mock react-native
@@ -68,14 +61,14 @@ jest.mock('@assets/audio/ui/software_interface_start.mp3', () => 'mocked-connect
jest.mock('@assets/audio/ui/software_interface_back.mp3', () => 'mocked-disconnected-from-audio-room-sound', { virtual: true });
import { Asset } from 'expo-asset';
-import { Audio, InterruptionModeIOS } from 'expo-av';
+import { createAudioPlayer, setAudioModeAsync } from 'expo-audio';
import { Platform } from 'react-native';
import { logger } from '@/lib/logging';
const mockAssetLoadAsync = Asset.loadAsync as jest.MockedFunction;
const mockAssetFromModule = Asset.fromModule as jest.MockedFunction;
-const mockAudioSetAudioModeAsync = Audio.setAudioModeAsync as jest.MockedFunction;
-const mockSoundCreateAsync = Audio.Sound.createAsync as jest.MockedFunction;
+const mockSetAudioModeAsync = setAudioModeAsync as jest.MockedFunction;
+const mockCreateAudioPlayer = createAudioPlayer as jest.MockedFunction;
describe('AudioService', () => {
let audioService: any;
@@ -86,13 +79,12 @@ describe('AudioService', () => {
// Set up mocks with proper return values BEFORE importing the service
mockAssetLoadAsync.mockResolvedValue([] as any);
mockAssetFromModule.mockReturnValue(mockAsset);
- (mockAudioSetAudioModeAsync as jest.MockedFunction).mockResolvedValue(undefined);
- (mockSoundCreateAsync as jest.MockedFunction).mockResolvedValue({ sound: mockSound, status: {} });
+ mockSetAudioModeAsync.mockResolvedValue(undefined);
+ (mockCreateAudioPlayer as jest.MockedFunction).mockReturnValue(mockSound);
mockAsset.downloadAsync.mockResolvedValue(undefined);
- mockSound.setPositionAsync.mockResolvedValue({} as any);
- mockSound.playAsync.mockResolvedValue({} as any);
- mockSound.unloadAsync.mockResolvedValue({} as any);
- mockSound.getStatusAsync.mockResolvedValue({ isLoaded: true } as any);
+ mockSound.seekTo.mockResolvedValue(undefined);
+ mockSound.play.mockReturnValue(undefined);
+ mockSound.remove.mockReturnValue(undefined);
// Clear the module cache to ensure fresh imports
delete require.cache[require.resolve('../audio.service')];
@@ -114,13 +106,12 @@ describe('AudioService', () => {
});
it('should set audio mode correctly', () => {
- expect(mockAudioSetAudioModeAsync).toHaveBeenCalledWith({
- allowsRecordingIOS: true,
- staysActiveInBackground: true,
- playsInSilentModeIOS: true,
- shouldDuckAndroid: true,
- playThroughEarpieceAndroid: true,
- interruptionModeIOS: 'doNotMix',
+ expect(mockSetAudioModeAsync).toHaveBeenCalledWith({
+ allowsRecording: true,
+ shouldPlayInBackground: true,
+ playsInSilentMode: true,
+ shouldRouteThroughEarpiece: true,
+ interruptionMode: 'doNotMix',
});
});
@@ -130,7 +121,8 @@ describe('AudioService', () => {
it('should load all audio files', () => {
expect(mockAssetFromModule).toHaveBeenCalledTimes(5);
- expect(mockSoundCreateAsync).toHaveBeenCalledTimes(5);
+ expect(mockCreateAudioPlayer).toHaveBeenCalledTimes(5);
+ expect(mockCreateAudioPlayer).toHaveBeenCalledWith({ uri: 'mock://local-uri' }, { keepAudioSessionActive: true });
});
});
@@ -140,13 +132,15 @@ describe('AudioService', () => {
await audioService.playStartTransmittingSound();
- expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0);
- expect(mockSound.playAsync).toHaveBeenCalled();
+ expect(mockSound.seekTo).toHaveBeenCalledWith(0);
+ expect(mockSound.play).toHaveBeenCalled();
});
it('should handle start transmitting sound playback errors', async () => {
jest.clearAllMocks();
- mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed'));
+ mockSound.play.mockImplementationOnce(() => {
+ throw new Error('Playback failed');
+ });
await audioService.playStartTransmittingSound();
@@ -163,13 +157,15 @@ describe('AudioService', () => {
await audioService.playStopTransmittingSound();
- expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0);
- expect(mockSound.playAsync).toHaveBeenCalled();
+ expect(mockSound.seekTo).toHaveBeenCalledWith(0);
+ expect(mockSound.play).toHaveBeenCalled();
});
it('should handle stop transmitting sound playback errors', async () => {
jest.clearAllMocks();
- mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed'));
+ mockSound.play.mockImplementationOnce(() => {
+ throw new Error('Playback failed');
+ });
await audioService.playStopTransmittingSound();
@@ -186,13 +182,15 @@ describe('AudioService', () => {
await audioService.playConnectedDeviceSound();
- expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0);
- expect(mockSound.playAsync).toHaveBeenCalled();
+ expect(mockSound.seekTo).toHaveBeenCalledWith(0);
+ expect(mockSound.play).toHaveBeenCalled();
});
it('should handle connected device sound playback errors', async () => {
jest.clearAllMocks();
- mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed'));
+ mockSound.play.mockImplementationOnce(() => {
+ throw new Error('Playback failed');
+ });
await audioService.playConnectedDeviceSound();
@@ -209,13 +207,15 @@ describe('AudioService', () => {
await audioService.playConnectToAudioRoomSound();
- expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0);
- expect(mockSound.playAsync).toHaveBeenCalled();
+ expect(mockSound.seekTo).toHaveBeenCalledWith(0);
+ expect(mockSound.play).toHaveBeenCalled();
});
it('should handle connect to audio room sound playback errors', async () => {
jest.clearAllMocks();
- mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed'));
+ mockSound.play.mockImplementationOnce(() => {
+ throw new Error('Playback failed');
+ });
await audioService.playConnectToAudioRoomSound();
@@ -232,13 +232,15 @@ describe('AudioService', () => {
await audioService.playDisconnectedFromAudioRoomSound();
- expect(mockSound.setPositionAsync).toHaveBeenCalledWith(0);
- expect(mockSound.playAsync).toHaveBeenCalled();
+ expect(mockSound.seekTo).toHaveBeenCalledWith(0);
+ expect(mockSound.play).toHaveBeenCalled();
});
it('should handle disconnected from audio room sound playback errors', async () => {
jest.clearAllMocks();
- mockSound.playAsync.mockRejectedValueOnce(new Error('Playback failed'));
+ mockSound.play.mockImplementationOnce(() => {
+ throw new Error('Playback failed');
+ });
await audioService.playDisconnectedFromAudioRoomSound();
@@ -255,7 +257,7 @@ describe('AudioService', () => {
await audioService.cleanup();
- expect(mockSound.unloadAsync).toHaveBeenCalledTimes(5);
+ expect(mockSound.remove).toHaveBeenCalledTimes(5);
expect(logger.info).toHaveBeenCalledWith({
message: 'Audio service cleaned up',
});
@@ -264,7 +266,9 @@ describe('AudioService', () => {
it('should handle cleanup errors gracefully', async () => {
// Set up a fresh service instance for this test
jest.clearAllMocks();
- mockSound.unloadAsync.mockRejectedValueOnce(new Error('Unload failed'));
+ mockSound.remove.mockImplementationOnce(() => {
+ throw new Error('Unload failed');
+ });
// Clear module cache and re-import to get fresh instance
delete require.cache[require.resolve('../audio.service')];
@@ -284,17 +288,20 @@ describe('AudioService', () => {
describe('error handling', () => {
it('should handle null sound objects gracefully', async () => {
- // Create a new service instance with createAsync that doesn't return sound
+ // Create a new service instance whose players fail to be created
jest.clearAllMocks();
delete require.cache[require.resolve('../audio.service')];
- // Mock createAsync to return null sound to simulate failed sound creation
- (mockSoundCreateAsync as jest.MockedFunction).mockResolvedValue({ sound: null, status: {} });
+ // Make createAudioPlayer throw to simulate failed sound creation
+ mockCreateAudioPlayer.mockImplementation(() => {
+ throw new Error('Player creation failed');
+ });
const AudioServiceModule = require('../audio.service');
const testService = AudioServiceModule.audioService;
- (testService as any).isInitialized = false;
+ // Release the players from the previous initialization so re-initialization has to recreate them
+ await testService.cleanup();
await testService.initialize();
await testService.playStartTransmittingSound();
@@ -305,7 +312,7 @@ describe('AudioService', () => {
it('should handle initialization failures', async () => {
jest.clearAllMocks();
- (mockAudioSetAudioModeAsync as jest.MockedFunction).mockRejectedValueOnce(new Error('Audio mode failed'));
+ mockSetAudioModeAsync.mockRejectedValueOnce(new Error('Audio mode failed'));
// Re-import to trigger new initialization
delete require.cache[require.resolve('../audio.service')];
diff --git a/src/services/__tests__/signalr.service.geolocation.test.ts b/src/services/__tests__/signalr.service.geolocation.test.ts
new file mode 100644
index 00000000..ef9f1350
--- /dev/null
+++ b/src/services/__tests__/signalr.service.geolocation.test.ts
@@ -0,0 +1,197 @@
+import { type HubConnection, HubConnectionBuilder, HubConnectionState } from '@microsoft/signalr';
+
+import { type SignalRHubConnectConfig, SignalRService, signalRService } from '../signalr.service';
+
+jest.mock('@/lib/env', () => ({
+ Env: {
+ REALTIME_GEO_HUB_NAME: 'geolocationHub',
+ },
+}));
+
+jest.mock('@/stores/auth/store', () => {
+ const mockGetState = jest.fn(() => ({
+ accessToken: 'mock-token',
+ refreshAccessToken: jest.fn().mockResolvedValue(undefined),
+ }));
+ return {
+ __esModule: true,
+ default: { getState: mockGetState },
+ useAuthStore: { getState: mockGetState },
+ };
+});
+
+jest.mock('@microsoft/signalr');
+jest.mock('@/lib/logging');
+
+const mockHubConnectionBuilder = HubConnectionBuilder as jest.MockedClass;
+
+const geoConfig: SignalRHubConnectConfig = {
+ name: 'geolocationHub',
+ eventingUrl: 'https://api.example.com/',
+ hubName: 'geolocationHub',
+ methods: ['onUnitLocationUpdated', 'onPersonnelLocationUpdated', 'onGeolocationConnect'],
+};
+
+describe('SignalRService lifecycle signals used by the geolocation hub', () => {
+ let connections: jest.Mocked[];
+
+ const buildConnection = (): jest.Mocked =>
+ ({
+ start: jest.fn().mockResolvedValue(undefined),
+ stop: jest.fn().mockResolvedValue(undefined),
+ invoke: jest.fn().mockResolvedValue(undefined),
+ on: jest.fn(),
+ off: jest.fn(),
+ onclose: jest.fn(),
+ onreconnecting: jest.fn(),
+ onreconnected: jest.fn(),
+ state: HubConnectionState.Connected,
+ }) as any;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (signalRService as any).connections.clear();
+ (signalRService as any).reconnectAttempts.clear();
+ (signalRService as any).hubConfigs.clear();
+ (signalRService as any).connectionLocks.clear();
+ (signalRService as any).reconnectingHubs.clear();
+ (signalRService as any).hubStates.clear();
+ signalRService.removeAllListeners();
+
+ connections = [];
+ mockHubConnectionBuilder.mockImplementation(
+ () =>
+ ({
+ withUrl: jest.fn().mockReturnThis(),
+ withAutomaticReconnect: jest.fn().mockReturnThis(),
+ withServerTimeout: jest.fn().mockReturnThis(),
+ configureLogging: jest.fn().mockReturnThis(),
+ build: jest.fn(() => {
+ const connection = buildConnection();
+ connections.push(connection);
+ return connection;
+ }),
+ }) as any
+ );
+ });
+
+ it('invokes a hub method with no arguments when none are given', async () => {
+ await signalRService.connectToHubWithEventingUrl(geoConfig);
+
+ await signalRService.invoke('geolocationHub', 'GeolocationConnect');
+
+ expect(connections[0].invoke).toHaveBeenCalledWith('GeolocationConnect');
+ expect(connections[0].invoke.mock.calls[0]).toHaveLength(1);
+ });
+
+ it('announces every new connection so subscribers can re-join their groups', async () => {
+ const onConnected = jest.fn();
+ signalRService.on(`${SignalRService.HUB_CONNECTED_EVENT}:geolocationHub`, onConnected);
+
+ await signalRService.connectToHubWithEventingUrl(geoConfig);
+
+ expect(onConnected).toHaveBeenCalledTimes(1);
+ });
+
+ it('announces reconnecting and reconnected from the automatic reconnect', async () => {
+ const onReconnecting = jest.fn();
+ const onReconnected = jest.fn();
+ signalRService.on(`${SignalRService.HUB_RECONNECTING_EVENT}:geolocationHub`, onReconnecting);
+ signalRService.on(`${SignalRService.HUB_RECONNECTED_EVENT}:geolocationHub`, onReconnected);
+
+ await signalRService.connectToHubWithEventingUrl(geoConfig);
+ const reconnectingHandler = connections[0].onreconnecting.mock.calls[0][0];
+ const reconnectedHandler = connections[0].onreconnected.mock.calls[0][0];
+
+ reconnectingHandler(new Error('transport lost'));
+ reconnectedHandler('connection-2');
+
+ expect(onReconnecting).toHaveBeenCalledTimes(1);
+ expect(onReconnected).toHaveBeenCalledTimes(1);
+ });
+
+ it('announces the rebuild after a close (e.g. token expiry) as a new connection', async () => {
+ jest.useFakeTimers();
+ try {
+ const onConnected = jest.fn();
+ signalRService.on(`${SignalRService.HUB_CONNECTED_EVENT}:geolocationHub`, onConnected);
+
+ await signalRService.connectToHubWithEventingUrl(geoConfig);
+ (connections[0] as any).state = HubConnectionState.Disconnected;
+ const closeHandler = connections[0].onclose.mock.calls[0][0];
+
+ closeHandler(new Error('authentication expired'));
+ await jest.advanceTimersByTimeAsync(5000);
+
+ expect(connections).toHaveLength(2);
+ expect(onConnected).toHaveBeenCalledTimes(2);
+ } finally {
+ signalRService.removeAllListeners();
+ (signalRService as any).cancelAllPendingReconnects();
+ jest.useRealTimers();
+ }
+ });
+
+ it('rebuilds a connection that closed while the page was hidden when it becomes visible again', async () => {
+ const onConnected = jest.fn();
+ signalRService.on(`${SignalRService.HUB_CONNECTED_EVENT}:geolocationHub`, onConnected);
+
+ await signalRService.connectToHubWithEventingUrl(geoConfig);
+ // The close happened while hidden, so the scheduled rebuild skipped and left the dead entry mapped.
+ (connections[0] as any).state = HubConnectionState.Disconnected;
+
+ await (signalRService as any).checkAndReconnectOnVisibilityResume();
+
+ expect(connections).toHaveLength(2);
+ expect(connections[1].start).toHaveBeenCalled();
+ expect(signalRService.isHubConnected('geolocationHub')).toBe(true);
+ expect(onConnected).toHaveBeenCalledTimes(2);
+ });
+
+ it('falls back to the token-refreshing rebuild when the visibility-resume connect fails', async () => {
+ jest.useFakeTimers();
+ try {
+ await signalRService.connectToHubWithEventingUrl(geoConfig);
+ (connections[0] as any).state = HubConnectionState.Disconnected;
+ let failNextStart = true;
+ mockHubConnectionBuilder.mockImplementation(
+ () =>
+ ({
+ withUrl: jest.fn().mockReturnThis(),
+ withAutomaticReconnect: jest.fn().mockReturnThis(),
+ withServerTimeout: jest.fn().mockReturnThis(),
+ configureLogging: jest.fn().mockReturnThis(),
+ build: jest.fn(() => {
+ const connection = buildConnection();
+ if (failNextStart) {
+ failNextStart = false;
+ connection.start.mockRejectedValue(new Error('401 Unauthorized'));
+ }
+ connections.push(connection);
+ return connection;
+ }),
+ }) as any
+ );
+
+ await (signalRService as any).checkAndReconnectOnVisibilityResume();
+ expect(signalRService.isHubConnected('geolocationHub')).toBe(false);
+
+ await jest.advanceTimersByTimeAsync(5000);
+
+ expect(connections).toHaveLength(3);
+ expect(signalRService.isHubConnected('geolocationHub')).toBe(true);
+ } finally {
+ (signalRService as any).cancelAllPendingReconnects();
+ jest.useRealTimers();
+ }
+ });
+
+ it('leaves a connection that is still inside its automatic reconnect alone on visibility resume', async () => {
+ await signalRService.connectToHubWithEventingUrl(geoConfig);
+ (connections[0] as any).state = HubConnectionState.Reconnecting;
+
+ await (signalRService as any).checkAndReconnectOnVisibilityResume();
+
+ expect(connections).toHaveLength(1);
+ });
+});
diff --git a/src/services/__tests__/signalr.service.test.ts b/src/services/__tests__/signalr.service.test.ts
index d01f9c30..b02a71b5 100644
--- a/src/services/__tests__/signalr.service.test.ts
+++ b/src/services/__tests__/signalr.service.test.ts
@@ -532,6 +532,43 @@ describe('SignalRService', () => {
});
});
+ describe('message logging', () => {
+ it('does not log location pushes or any message payload', async () => {
+ const unitListener = jest.fn();
+ signalRService.on('onUnitLocationUpdated', unitListener);
+
+ try {
+ await signalRService.connectToHubWithEventingUrl({
+ name: 'loggingHub',
+ eventingUrl: 'https://api.example.com/',
+ hubName: 'eventingHub',
+ methods: ['onUnitLocationUpdated', 'onPersonnelLocationUpdated', 'callsUpdated'],
+ });
+
+ const handlerFor = (method: string) => mockConnection.on.mock.calls.find((call) => call[0] === method)?.[1] as (...args: unknown[]) => void;
+ const unitPush = { departmentId: 7, unitId: '12', latitude: 47.6062123, longitude: -122.3321456, recordId: 'r1', timestamp: '2026-09-25T14:03:11Z' };
+ const personPush = { departmentId: 7, userId: 'user-1', latitude: 47.6062123, longitude: -122.3321456, recordId: 'r2', timestamp: '2026-09-25T14:03:12Z' };
+
+ handlerFor('onUnitLocationUpdated')(unitPush);
+ handlerFor('onPersonnelLocationUpdated')(personPush);
+ handlerFor('callsUpdated')({ body: 'private call notes' });
+
+ const logged = JSON.stringify([mockLogger.debug, mockLogger.info, mockLogger.warn, mockLogger.error].flatMap((log) => log.mock.calls));
+ expect(logged).not.toContain('47.6062123');
+ expect(logged).not.toContain('-122.3321456');
+ expect(logged).not.toContain('private call notes');
+ expect(logged).not.toMatch(/Received on(Unit|Personnel)LocationUpdated/);
+ expect(mockLogger.debug).toHaveBeenCalledWith({ message: 'Received callsUpdated message from hub: loggingHub', context: { method: 'callsUpdated' } });
+
+ // Logging less must not deliver less.
+ expect(unitListener).toHaveBeenCalledWith(unitPush);
+ } finally {
+ signalRService.off('onUnitLocationUpdated', unitListener);
+ await signalRService.disconnectFromHub('loggingHub');
+ }
+ });
+ });
+
describe('hub availability and reconnecting state', () => {
const mockConfig: SignalRHubConnectConfig = {
name: 'testHub',
diff --git a/src/services/audio.service.ts b/src/services/audio.service.ts
index 03dcfbb8..f0874f3a 100644
--- a/src/services/audio.service.ts
+++ b/src/services/audio.service.ts
@@ -1,16 +1,16 @@
import { Asset } from 'expo-asset';
-import { Audio, type AVPlaybackSource, InterruptionModeIOS } from 'expo-av';
+import { type AudioPlayer, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
import { Platform } from 'react-native';
import { logger } from '@/lib/logging';
class AudioService {
private static instance: AudioService;
- private startTransmittingSound: Audio.Sound | null = null;
- private stopTransmittingSound: Audio.Sound | null = null;
- private connectedDeviceSound: Audio.Sound | null = null;
- private connectToAudioRoomSound: Audio.Sound | null = null;
- private disconnectedFromAudioRoomSound: Audio.Sound | null = null;
+ private startTransmittingSound: AudioPlayer | null = null;
+ private stopTransmittingSound: AudioPlayer | null = null;
+ private connectedDeviceSound: AudioPlayer | null = null;
+ private connectToAudioRoomSound: AudioPlayer | null = null;
+ private disconnectedFromAudioRoomSound: AudioPlayer | null = null;
private isInitialized = false;
private constructor() {
@@ -35,13 +35,12 @@ class AudioService {
try {
// Configure audio mode for production builds
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: true,
- staysActiveInBackground: true,
- playsInSilentModeIOS: true,
- shouldDuckAndroid: true,
- playThroughEarpieceAndroid: true,
- interruptionModeIOS: InterruptionModeIOS.DoNotMix,
+ await setAudioModeAsync({
+ allowsRecording: true,
+ shouldPlayInBackground: true,
+ playsInSilentMode: true,
+ shouldRouteThroughEarpiece: true,
+ interruptionMode: Platform.OS === 'android' ? 'duckOthers' : 'doNotMix',
});
// Pre-load audio assets for production builds
@@ -90,55 +89,45 @@ class AudioService {
const startTransmittingSoundAsset = Asset.fromModule(require('@assets/audio/ui/space_notification1.mp3'));
await startTransmittingSoundAsset.downloadAsync();
- const { sound: startSound } = await Audio.Sound.createAsync({ uri: startTransmittingSoundAsset.localUri || startTransmittingSoundAsset.uri } as AVPlaybackSource, {
- shouldPlay: false,
- isLooping: false,
- volume: 1.0,
- });
+ const startSound = createAudioPlayer({ uri: startTransmittingSoundAsset.localUri || startTransmittingSoundAsset.uri }, { keepAudioSessionActive: true });
+ startSound.loop = false;
+ startSound.volume = 1.0;
this.startTransmittingSound = startSound;
// Load stop transmitting sound
const stopTransmittingSoundAsset = Asset.fromModule(require('@assets/audio/ui/space_notification2.mp3'));
await stopTransmittingSoundAsset.downloadAsync();
- const { sound: stopSound } = await Audio.Sound.createAsync({ uri: stopTransmittingSoundAsset.localUri || stopTransmittingSoundAsset.uri } as AVPlaybackSource, {
- shouldPlay: false,
- isLooping: false,
- volume: 1.0,
- });
+ const stopSound = createAudioPlayer({ uri: stopTransmittingSoundAsset.localUri || stopTransmittingSoundAsset.uri }, { keepAudioSessionActive: true });
+ stopSound.loop = false;
+ stopSound.volume = 1.0;
this.stopTransmittingSound = stopSound;
// Load connected device sound
const connectedDeviceSoundAsset = Asset.fromModule(require('@assets/audio/ui/positive_interface_beep.mp3'));
await connectedDeviceSoundAsset.downloadAsync();
- const { sound: connectedSound } = await Audio.Sound.createAsync({ uri: connectedDeviceSoundAsset.localUri || connectedDeviceSoundAsset.uri } as AVPlaybackSource, {
- shouldPlay: false,
- isLooping: false,
- volume: 1.0,
- });
+ const connectedSound = createAudioPlayer({ uri: connectedDeviceSoundAsset.localUri || connectedDeviceSoundAsset.uri }, { keepAudioSessionActive: true });
+ connectedSound.loop = false;
+ connectedSound.volume = 1.0;
this.connectedDeviceSound = connectedSound;
// Load connect to audio room sound
const connectToAudioRoomSoundAsset = Asset.fromModule(require('@assets/audio/ui/software_interface_start.mp3'));
await connectToAudioRoomSoundAsset.downloadAsync();
- const { sound: connectToRoomSound } = await Audio.Sound.createAsync({ uri: connectToAudioRoomSoundAsset.localUri || connectToAudioRoomSoundAsset.uri } as AVPlaybackSource, {
- shouldPlay: false,
- isLooping: false,
- volume: 1.0,
- });
+ const connectToRoomSound = createAudioPlayer({ uri: connectToAudioRoomSoundAsset.localUri || connectToAudioRoomSoundAsset.uri }, { keepAudioSessionActive: true });
+ connectToRoomSound.loop = false;
+ connectToRoomSound.volume = 1.0;
this.connectToAudioRoomSound = connectToRoomSound;
// Load disconnect from audio room sound
const disconnectedFromAudioRoomSoundAsset = Asset.fromModule(require('@assets/audio/ui/software_interface_back.mp3'));
await disconnectedFromAudioRoomSoundAsset.downloadAsync();
- const { sound: disconnectFromRoomSound } = await Audio.Sound.createAsync({ uri: disconnectedFromAudioRoomSoundAsset.localUri || disconnectedFromAudioRoomSoundAsset.uri } as AVPlaybackSource, {
- shouldPlay: false,
- isLooping: false,
- volume: 1.0,
- });
+ const disconnectFromRoomSound = createAudioPlayer({ uri: disconnectedFromAudioRoomSoundAsset.localUri || disconnectedFromAudioRoomSoundAsset.uri }, { keepAudioSessionActive: true });
+ disconnectFromRoomSound.loop = false;
+ disconnectFromRoomSound.volume = 1.0;
this.disconnectedFromAudioRoomSound = disconnectFromRoomSound;
logger.debug({
@@ -152,7 +141,7 @@ class AudioService {
}
}
- private async playSound(sound: Audio.Sound | null, soundName: string): Promise {
+ private async playSound(sound: AudioPlayer | null, soundName: string): Promise {
try {
if (!sound) {
logger.warn({
@@ -167,8 +156,10 @@ class AudioService {
}
// Reset to start and play
- await sound.setPositionAsync(0);
- await sound.playAsync();
+ if (sound.isLoaded) {
+ await sound.seekTo(0);
+ }
+ sound.play();
logger.debug({
message: 'Sound played successfully',
@@ -263,31 +254,31 @@ class AudioService {
try {
// Unload start transmitting sound
if (this.startTransmittingSound) {
- await this.startTransmittingSound.unloadAsync();
+ this.startTransmittingSound.remove();
this.startTransmittingSound = null;
}
// Unload stop transmitting sound
if (this.stopTransmittingSound) {
- await this.stopTransmittingSound.unloadAsync();
+ this.stopTransmittingSound.remove();
this.stopTransmittingSound = null;
}
// Unload connected device sound
if (this.connectedDeviceSound) {
- await this.connectedDeviceSound.unloadAsync();
+ this.connectedDeviceSound.remove();
this.connectedDeviceSound = null;
}
// Unload connect to audio room sound
if (this.connectToAudioRoomSound) {
- await this.connectToAudioRoomSound.unloadAsync();
+ this.connectToAudioRoomSound.remove();
this.connectToAudioRoomSound = null;
}
// Unload disconnect from audio room sound
if (this.disconnectedFromAudioRoomSound) {
- await this.disconnectedFromAudioRoomSound.unloadAsync();
+ this.disconnectedFromAudioRoomSound.remove();
this.disconnectedFromAudioRoomSound = null;
}
diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts
index cef04545..b8aee4d9 100644
--- a/src/services/signalr.service.ts
+++ b/src/services/signalr.service.ts
@@ -6,6 +6,12 @@ import { logger } from '@/lib/logging';
import { isElectron } from '@/lib/platform';
import useAuthStore from '@/stores/auth/store';
+// Location pushes arrive every few seconds for every unit and person, so they are not logged at all:
+// a per-message line would flood the console and push useful breadcrumbs out of error reports.
+const UNLOGGED_HUB_METHODS = new Set(['onunitlocationupdated', 'onpersonnellocationupdated']);
+
+export const isUnloggedHubMethod = (method: string): boolean => UNLOGGED_HUB_METHODS.has(method.toLowerCase());
+
export interface SignalRHubConfig {
name: string;
url: string;
@@ -54,6 +60,14 @@ class SignalRService {
*/
public static readonly HUB_DISCONNECTED_EVENT = '__hubDisconnected';
public static readonly HUB_RECONNECTED_EVENT = '__hubReconnected';
+ /** The transport dropped and the client's automatic reconnect is running; the connection is in no group. */
+ public static readonly HUB_RECONNECTING_EVENT = '__hubReconnecting';
+ /**
+ * A brand-new connection finished starting — the first connect, the rebuild after a close, or the rebuild on
+ * visibility resume. Unlike HUB_RECONNECTED_EVENT (the client's own automatic reconnect), this is a fresh
+ * connection object with a fresh token, and it too belongs to no server-side group yet.
+ */
+ public static readonly HUB_CONNECTED_EVENT = '__hubConnected';
private connections: Map = new Map();
private reconnectAttempts: Map = new Map();
@@ -208,6 +222,15 @@ class SignalRService {
// Reset reconnect attempts on visibility resume to give fresh attempts
this.reconnectAttempts.set(hubName, 0);
+ // A close while the page was hidden skips the scheduled rebuild and leaves the dead connection
+ // mapped, and connecting over a mapped entry is a no-op — the hub would stay down. Drop a fully
+ // closed connection so a fresh one is built. One still inside its automatic reconnect is left
+ // alone: it will either recover or close and schedule its own rebuild.
+ if (connection && connection.state === HubConnectionState.Disconnected) {
+ this.cleanupHubMethodHandlers(hubName, connection);
+ this.connections.delete(hubName);
+ }
+
try {
await this.connectToHubWithEventingUrl(config);
} catch (error) {
@@ -215,6 +238,9 @@ class SignalRService {
message: `Failed to reconnect hub ${hubName} after visibility resume`,
context: { error },
});
+ // Typically the token expired while the page was hidden. Hand over to the close path, which
+ // refreshes the token and retries with backoff, instead of leaving the hub down until reload.
+ this.handleConnectionClose(hubName);
}
}
}
@@ -422,6 +448,7 @@ class SignalRService {
message: `Reconnecting to hub: ${config.name}`,
context: { error },
});
+ this.emitHubLifecycle(SignalRService.HUB_RECONNECTING_EVENT, config.name);
});
connection.onreconnected((connectionId) => {
@@ -446,10 +473,13 @@ class SignalRService {
});
const handler = (...args: unknown[]) => {
- logger.info({
- message: `Received ${method} message from hub: ${config.name}`,
- context: { method, args },
- });
+ // Never log payloads: they carry personal data (chat, call details, precise coordinates).
+ if (!isUnloggedHubMethod(method)) {
+ logger.debug({
+ message: `Received ${method} message from hub: ${config.name}`,
+ context: { method },
+ });
+ }
this.handleMessage(config.name, method, args);
};
@@ -482,6 +512,10 @@ class SignalRService {
logger.info({
message: `Connected to hub: ${config.name}`,
});
+
+ // Every successful start is a new connection id outside every group — including the rebuilds after a
+ // close and on visibility resume, which raise no reconnected event. Subscribers re-announce on this.
+ this.emitHubLifecycle(SignalRService.HUB_CONNECTED_EVENT, config.name);
} catch (error) {
// Clear the direct-connecting state on failed connection
this.setHubState(config.name, HubConnectingState.IDLE);
@@ -633,10 +667,13 @@ class SignalRService {
});
const handler = (...args: unknown[]) => {
- logger.info({
- message: `Received ${method} message from hub: ${config.name}`,
- context: { method, args },
- });
+ // Never log payloads: they carry personal data (chat, call details, precise coordinates).
+ if (!isUnloggedHubMethod(method)) {
+ logger.debug({
+ message: `Received ${method} message from hub: ${config.name}`,
+ context: { method },
+ });
+ }
this.handleMessage(config.name, method, args);
};
@@ -836,10 +873,6 @@ class SignalRService {
}
private handleMessage(hubName: string, method: string, args: unknown[]): void {
- logger.debug({
- message: `Received message from hub: ${hubName}`,
- context: { method, args },
- });
// Emit event for subscribers using the method name as the event name. Hub methods
// can send more than one argument (chatPresenceChanged sends `userId, isOnline`),
// so forward every argument to the listeners.
diff --git a/src/stores/app/__tests__/audio-stream-store.test.ts b/src/stores/app/__tests__/audio-stream-store.test.ts
index 55210791..04a5f221 100644
--- a/src/stores/app/__tests__/audio-stream-store.test.ts
+++ b/src/stores/app/__tests__/audio-stream-store.test.ts
@@ -11,24 +11,21 @@ jest.mock('@/lib/logging', () => ({
},
}));
-jest.mock('expo-av', () => ({
- Audio: {
- setAudioModeAsync: jest.fn(),
- Sound: {
- createAsync: jest.fn(),
- },
- },
+jest.mock('expo-audio', () => ({
+ setAudioModeAsync: jest.fn(),
+ createAudioPlayer: jest.fn(),
}));
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
-import { Audio } from 'expo-av';
+import { createAudioPlayer, setAudioModeAsync } from 'expo-audio';
import { getDepartmentAudioStreams } from '@/api/voice';
import { logger } from '@/lib/logging';
import { type DepartmentAudioResultStreamData } from '@/models/v4/voice/departmentAudioResultStreamData';
import { useAudioStreamStore } from '../audio-stream-store';
const mockGetDepartmentAudioStreams = getDepartmentAudioStreams as jest.MockedFunction;
-const mockAudio = Audio as jest.Mocked;
+const mockSetAudioModeAsync = setAudioModeAsync as jest.MockedFunction;
+const mockCreateAudioPlayer = createAudioPlayer as jest.MockedFunction;
const mockLogger = logger as jest.Mocked;
describe('AudioStreamStore', () => {
@@ -40,12 +37,22 @@ describe('AudioStreamStore', () => {
};
const mockSoundObject = {
- playAsync: jest.fn(() => Promise.resolve()),
- pauseAsync: jest.fn(() => Promise.resolve()),
- unloadAsync: jest.fn(() => Promise.resolve()),
- replayAsync: jest.fn(() => Promise.resolve()),
+ play: jest.fn(),
+ pause: jest.fn(),
+ remove: jest.fn(),
+ replace: jest.fn(),
+ addListener: jest.fn(),
+ loop: false,
+ volume: 1,
+ muted: false,
} as any;
+ // Returns the playbackStatusUpdate listener the store registered on the player
+ const getStatusListener = () => {
+ const call = mockSoundObject.addListener.mock.calls.find(([event]: [string]) => event === 'playbackStatusUpdate');
+ return call[1] as (status: Record) => void;
+ };
+
beforeEach(() => {
jest.clearAllMocks();
@@ -62,16 +69,15 @@ describe('AudioStreamStore', () => {
});
// Reset mock implementations
- mockSoundObject.playAsync.mockImplementation(() => Promise.resolve());
- mockSoundObject.pauseAsync.mockImplementation(() => Promise.resolve());
- mockSoundObject.unloadAsync.mockImplementation(() => Promise.resolve());
- mockSoundObject.replayAsync.mockImplementation(() => Promise.resolve());
-
- // Mock Audio methods
- mockAudio.setAudioModeAsync.mockResolvedValue(undefined);
- mockAudio.Sound.createAsync.mockResolvedValue({
- sound: mockSoundObject,
- } as any);
+ mockSoundObject.play.mockImplementation(() => undefined);
+ mockSoundObject.pause.mockImplementation(() => undefined);
+ mockSoundObject.remove.mockImplementation(() => undefined);
+ mockSoundObject.replace.mockImplementation(() => undefined);
+ mockSoundObject.addListener.mockImplementation(() => ({ remove: jest.fn() }));
+
+ // Mock expo-audio functions
+ mockSetAudioModeAsync.mockResolvedValue(undefined);
+ (mockCreateAudioPlayer as jest.MockedFunction).mockReturnValue(mockSoundObject);
});
describe('initial state', () => {
@@ -211,27 +217,24 @@ describe('AudioStreamStore', () => {
expect(state.isLoading).toBe(false);
expect(state.isBuffering).toBe(false);
- expect(mockAudio.setAudioModeAsync).toHaveBeenCalledWith({
- allowsRecordingIOS: false,
- staysActiveInBackground: true,
- playsInSilentModeIOS: true,
- shouldDuckAndroid: true,
- playThroughEarpieceAndroid: false,
+ expect(mockSetAudioModeAsync).toHaveBeenCalledWith({
+ allowsRecording: false,
+ shouldPlayInBackground: true,
+ playsInSilentMode: true,
+ shouldRouteThroughEarpiece: false,
+ interruptionMode: 'mixWithOthers',
});
- expect(mockAudio.Sound.createAsync).toHaveBeenCalledWith(
+ expect(mockCreateAudioPlayer).toHaveBeenCalledWith(
{ uri: mockStream.Url },
{
- shouldPlay: false,
- isLooping: false,
- volume: 1.0,
- isMuted: false,
- progressUpdateIntervalMillis: 1000,
- },
- expect.any(Function)
+ updateInterval: 1000,
+ keepAudioSessionActive: true,
+ }
);
+ expect(mockSoundObject.addListener).toHaveBeenCalledWith('playbackStatusUpdate', expect.any(Function));
- expect(mockSoundObject.playAsync).toHaveBeenCalled();
+ expect(mockSoundObject.play).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith({
message: 'Starting audio stream',
@@ -256,13 +259,15 @@ describe('AudioStreamStore', () => {
await useAudioStreamStore.getState().playStream(newStream);
- expect(mockSoundObject.pauseAsync).toHaveBeenCalled();
- expect(mockSoundObject.unloadAsync).toHaveBeenCalled();
+ expect(mockSoundObject.pause).toHaveBeenCalled();
+ expect(mockSoundObject.remove).toHaveBeenCalled();
});
it('should handle play stream error', async () => {
const mockError = new Error('Play failed');
- mockAudio.Sound.createAsync.mockRejectedValue(mockError);
+ mockCreateAudioPlayer.mockImplementation(() => {
+ throw mockError;
+ });
await useAudioStreamStore.getState().playStream(mockStream);
@@ -280,6 +285,98 @@ describe('AudioStreamStore', () => {
});
});
+ describe('playback status updates', () => {
+ it('should tear down the player when playback reports an error', async () => {
+ await useAudioStreamStore.getState().playStream(mockStream);
+
+ getStatusListener()({ playing: false, isBuffering: false, didJustFinish: false, error: 'Source error' });
+
+ const state = useAudioStreamStore.getState();
+ expect(mockSoundObject.remove).toHaveBeenCalled();
+ expect(state.soundObject).toBeNull();
+ expect(state.currentStream).toBeNull();
+ expect(state.isPlaying).toBe(false);
+ expect(mockLogger.error).toHaveBeenCalledWith({
+ message: 'Audio playback error',
+ context: { error: 'Source error', streamName: mockStream.Name },
+ });
+ });
+
+ it('should mirror playing and buffering state from the player', async () => {
+ await useAudioStreamStore.getState().playStream(mockStream);
+
+ getStatusListener()({ playing: false, isBuffering: true, didJustFinish: false, error: null });
+
+ const state = useAudioStreamStore.getState();
+ expect(state.isPlaying).toBe(false);
+ expect(state.isBuffering).toBe(true);
+ });
+
+ it('should reconnect a live stream that finishes by re-pointing the player at its source', async () => {
+ jest.useFakeTimers();
+ try {
+ await useAudioStreamStore.getState().playStream(mockStream);
+ mockSoundObject.play.mockClear();
+
+ getStatusListener()({ playing: false, isBuffering: false, didJustFinish: true, error: null });
+ jest.advanceTimersByTime(1000);
+
+ expect(mockSoundObject.replace).toHaveBeenCalledWith({ uri: mockStream.Url });
+ expect(mockSoundObject.play).toHaveBeenCalled();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('should ignore a late status from a player that has been replaced', async () => {
+ await useAudioStreamStore.getState().playStream(mockStream);
+ const staleListener = getStatusListener();
+
+ const nextSound = { ...mockSoundObject, play: jest.fn(), pause: jest.fn(), remove: jest.fn(), addListener: jest.fn(() => ({ remove: jest.fn() })) };
+ mockCreateAudioPlayer.mockReturnValue(nextSound);
+ const nextStream = { ...mockStream, Id: '2', Name: 'Next Stream' };
+ await useAudioStreamStore.getState().playStream(nextStream);
+
+ staleListener({ playing: false, isBuffering: true, didJustFinish: false, error: 'Source error' });
+
+ const state = useAudioStreamStore.getState();
+ expect(state.soundObject).toBe(nextSound);
+ expect(state.currentStream).toBe(nextStream);
+ expect(state.isPlaying).toBe(true);
+ expect(state.isBuffering).toBe(false);
+ expect(nextSound.remove).not.toHaveBeenCalled();
+ });
+
+ it('should not mark a player as playing when it reported an error while starting', async () => {
+ mockSoundObject.play.mockImplementation(() => {
+ getStatusListener()({ playing: false, isBuffering: false, didJustFinish: false, error: 'Source error' });
+ });
+
+ await useAudioStreamStore.getState().playStream(mockStream);
+
+ const state = useAudioStreamStore.getState();
+ expect(state.soundObject).toBeNull();
+ expect(state.currentStream).toBeNull();
+ expect(state.isPlaying).toBe(false);
+ expect(state.isLoading).toBe(false);
+ });
+
+ it('should not reconnect once the stream has been stopped', async () => {
+ jest.useFakeTimers();
+ try {
+ await useAudioStreamStore.getState().playStream(mockStream);
+
+ getStatusListener()({ playing: false, isBuffering: false, didJustFinish: true, error: null });
+ await useAudioStreamStore.getState().stopStream();
+ jest.advanceTimersByTime(1000);
+
+ expect(mockSoundObject.replace).not.toHaveBeenCalled();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+ });
+
describe('stopStream', () => {
it('should stop stream successfully', async () => {
// Set up playing stream
@@ -298,8 +395,8 @@ describe('AudioStreamStore', () => {
expect(state.isLoading).toBe(false);
expect(state.isBuffering).toBe(false);
- expect(mockSoundObject.pauseAsync).toHaveBeenCalled();
- expect(mockSoundObject.unloadAsync).toHaveBeenCalled();
+ expect(mockSoundObject.pause).toHaveBeenCalled();
+ expect(mockSoundObject.remove).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith({
message: 'Audio stream stopped',
@@ -309,7 +406,9 @@ describe('AudioStreamStore', () => {
it('should handle stop stream error', async () => {
const mockError = new Error('Stop failed');
- mockSoundObject.pauseAsync.mockRejectedValue(mockError);
+ mockSoundObject.pause.mockImplementation(() => {
+ throw mockError;
+ });
useAudioStreamStore.setState({
soundObject: mockSoundObject,
@@ -339,8 +438,8 @@ describe('AudioStreamStore', () => {
expect(state.currentStream).toBeNull();
expect(state.isPlaying).toBe(false);
- expect(mockSoundObject.pauseAsync).not.toHaveBeenCalled();
- expect(mockSoundObject.unloadAsync).not.toHaveBeenCalled();
+ expect(mockSoundObject.pause).not.toHaveBeenCalled();
+ expect(mockSoundObject.remove).not.toHaveBeenCalled();
});
});
@@ -359,8 +458,8 @@ describe('AudioStreamStore', () => {
expect(state.currentStream).toBeNull();
expect(state.isPlaying).toBe(false);
- expect(mockSoundObject.pauseAsync).toHaveBeenCalled();
- expect(mockSoundObject.unloadAsync).toHaveBeenCalled();
+ expect(mockSoundObject.pause).toHaveBeenCalled();
+ expect(mockSoundObject.remove).toHaveBeenCalled();
expect(mockLogger.debug).toHaveBeenCalledWith({
message: 'Audio stream store cleaned up',
@@ -369,7 +468,9 @@ describe('AudioStreamStore', () => {
it('should handle cleanup error', async () => {
const mockError = new Error('Cleanup failed');
- mockSoundObject.pauseAsync.mockRejectedValue(mockError);
+ mockSoundObject.pause.mockImplementation(() => {
+ throw mockError;
+ });
useAudioStreamStore.setState({
soundObject: mockSoundObject,
diff --git a/src/stores/app/__tests__/livekit-store.test.ts b/src/stores/app/__tests__/livekit-store.test.ts
index 31431e43..962c45d8 100644
--- a/src/stores/app/__tests__/livekit-store.test.ts
+++ b/src/stores/app/__tests__/livekit-store.test.ts
@@ -1,4 +1,4 @@
-// Mock expo-asset and expo-av first (before any imports)
+// Mock expo-asset first (before any imports)
jest.mock('expo-asset', () => ({
Asset: {
fromModule: jest.fn(),
@@ -6,15 +6,6 @@ jest.mock('expo-asset', () => ({
},
}));
-jest.mock('expo-av', () => ({
- Audio: {
- setAudioModeAsync: jest.fn(),
- getStatusAsync: jest.fn(),
- loadAsync: jest.fn(),
- createAsync: jest.fn(),
- },
-}));
-
// Mock audio service
jest.mock('../../../services/audio.service', () => ({
playAudio: jest.fn(),
diff --git a/src/stores/app/audio-stream-store.ts b/src/stores/app/audio-stream-store.ts
index 98259dc1..35ea06fa 100644
--- a/src/stores/app/audio-stream-store.ts
+++ b/src/stores/app/audio-stream-store.ts
@@ -1,4 +1,5 @@
-import { Audio, type AVPlaybackSource, type AVPlaybackStatus } from 'expo-av';
+import { type AudioPlayer, type AudioStatus, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
+import { Platform } from 'react-native';
import { create } from 'zustand';
import { getDepartmentAudioStreams } from '@/api/voice';
@@ -22,7 +23,7 @@ interface AudioStreamState {
// Current stream
currentStream: DepartmentAudioResultStreamData | null;
- soundObject: Audio.Sound | null;
+ soundObject: AudioPlayer | null;
isPlaying: boolean;
isLoading: boolean;
isBuffering: boolean;
@@ -102,79 +103,100 @@ export const useAudioStreamStore = create((set, get) => ({
});
// Configure audio mode for streaming
- await Audio.setAudioModeAsync({
- allowsRecordingIOS: false,
- staysActiveInBackground: true,
- playsInSilentModeIOS: true,
- shouldDuckAndroid: true,
- playThroughEarpieceAndroid: false,
+ await setAudioModeAsync({
+ allowsRecording: false,
+ shouldPlayInBackground: true,
+ playsInSilentMode: true,
+ shouldRouteThroughEarpiece: false,
+ interruptionMode: Platform.OS === 'android' ? 'duckOthers' : 'mixWithOthers',
});
- // Create new sound object
- const { sound } = await Audio.Sound.createAsync(
- { uri: stream.Url } as AVPlaybackSource,
- {
- shouldPlay: false,
- isLooping: false,
- volume: 1.0,
- isMuted: false,
- progressUpdateIntervalMillis: 1000,
- },
- (status: AVPlaybackStatus) => {
- if (status.isLoaded) {
- const { isPlaying, isBuffering } = get();
-
- if (status.isPlaying !== isPlaying) {
- set({ isPlaying: status.isPlaying });
- }
-
- if (status.isBuffering !== isBuffering) {
- set({ isBuffering: status.isBuffering });
- }
-
- // Handle stream ended/error scenarios
- if (status.didJustFinish) {
- logger.info({
- message: 'Audio stream finished',
- context: { streamName: stream.Name },
- });
-
- // For live streams, try to reconnect
- const { currentStream } = get();
- if (currentStream?.Id === stream.Id) {
- clearReplayTimeout();
- replayTimeout = setTimeout(async () => {
- replayTimeout = null;
- try {
- await sound.replayAsync();
- } catch (replayError) {
- logger.error({
- message: 'Failed to restart audio stream',
- context: { error: replayError, streamName: stream.Name },
- });
- }
- }, 1000);
+ // Create new player
+ const source = { uri: stream.Url };
+ const sound = createAudioPlayer(source, {
+ updateInterval: 1000,
+ keepAudioSessionActive: true,
+ });
+ sound.loop = false;
+ sound.volume = 1.0;
+ sound.muted = false;
+
+ // Active before any status can arrive, so the listener can tell this player's events from a superseded one's.
+ set({ soundObject: sound, currentStream: stream });
+
+ sound.addListener('playbackStatusUpdate', (status: AudioStatus) => {
+ // A stopped or replaced player can still emit a late status; only the active player may touch the state.
+ if (get().soundObject !== sound) {
+ return;
+ }
+
+ if (status.error) {
+ // Handle error state
+ logger.error({
+ message: 'Audio playback error',
+ context: { error: status.error, streamName: stream.Name },
+ });
+ try {
+ sound.remove();
+ } catch {
+ // The player may already have been released.
+ }
+ set({
+ soundObject: null,
+ currentStream: null,
+ isPlaying: false,
+ isLoading: false,
+ isBuffering: false,
+ });
+ return;
+ }
+
+ const { isPlaying, isBuffering } = get();
+
+ if (status.playing !== isPlaying) {
+ set({ isPlaying: status.playing });
+ }
+
+ if (status.isBuffering !== isBuffering) {
+ set({ isBuffering: status.isBuffering });
+ }
+
+ // Handle stream ended scenarios
+ if (status.didJustFinish) {
+ logger.info({
+ message: 'Audio stream finished',
+ context: { streamName: stream.Name },
+ });
+
+ // For live streams, try to reconnect
+ const { currentStream } = get();
+ if (currentStream?.Id === stream.Id) {
+ clearReplayTimeout();
+ replayTimeout = setTimeout(() => {
+ replayTimeout = null;
+ try {
+ // Re-point the player at the source rather than seeking: a live stream has
+ // nothing buffered to seek back into, so only a fresh connection resumes it.
+ sound.replace(source);
+ sound.play();
+ } catch (replayError) {
+ logger.error({
+ message: 'Failed to restart audio stream',
+ context: { error: replayError, streamName: stream.Name },
+ });
}
- }
- } else {
- // Handle error state
- logger.error({
- message: 'Audio playback error',
- context: { error: 'Failed to load audio', streamName: stream.Name },
- });
- set({
- soundObject: null,
- currentStream: null,
- isPlaying: false,
- isLoading: false,
- isBuffering: false,
- });
+ }, 1000);
}
}
- );
+ });
// Start playing
- await sound.playAsync();
+ sound.play();
+
+ // An error status reported while starting has already released this player.
+ if (get().soundObject !== sound) {
+ return;
+ }
logger.info({
message: 'Audio stream started successfully',
@@ -182,8 +204,6 @@ export const useAudioStreamStore = create((set, get) => ({
});
set({
- soundObject: sound,
- currentStream: stream,
isPlaying: true,
isLoading: false,
isBuffering: false,
@@ -211,8 +231,8 @@ export const useAudioStreamStore = create((set, get) => ({
const { soundObject, currentStream } = get();
if (soundObject) {
- await soundObject.pauseAsync();
- await soundObject.unloadAsync();
+ soundObject.pause();
+ soundObject.remove();
logger.info({
message: 'Audio stream stopped',
diff --git a/src/stores/calls/detail-store.ts b/src/stores/calls/detail-store.ts
index 1ceddb48..9b2c7417 100644
--- a/src/stores/calls/detail-store.ts
+++ b/src/stores/calls/detail-store.ts
@@ -85,7 +85,7 @@ export const useCallDetailStore = create((set, get) => ({
});
} else {
set({
- error: callResult.Message || callExtraDataResult.Message || 'Failed to fetch call details',
+ error: callResult?.Message || 'Failed to fetch call details',
isLoading: false,
});
}
diff --git a/src/stores/contacts/store.ts b/src/stores/contacts/store.ts
index 024279d9..cf6ec8ed 100644
--- a/src/stores/contacts/store.ts
+++ b/src/stores/contacts/store.ts
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { getContactNotes } from '@/api/contacts/contactNotes';
-import { getAllContacts } from '@/api/contacts/contacts';
+import { getAllContacts, getContact } from '@/api/contacts/contacts';
import { type ContactNoteResultData } from '@/models/v4/contacts/contactNoteResultData';
import { type ContactResultData } from '@/models/v4/contacts/contactResultData';
@@ -10,13 +10,18 @@ interface ContactsState {
contactNotes: Record;
searchQuery: string;
selectedContactId: string | null;
+ // Full record from GetContactById — the GetAllContacts list payload is slim (no address, custom fields
+ // or category), so the details sheet needs this to show every field
+ selectedContactDetails: ContactResultData | null;
isDetailsOpen: boolean;
isLoading: boolean;
+ isDetailsLoading: boolean;
isNotesLoading: boolean;
error: string | null;
// Actions
fetchContacts: (forceRefresh?: boolean) => Promise;
- fetchContactNotes: (contactId: string) => Promise;
+ fetchContactDetails: (contactId: string) => Promise;
+ fetchContactNotes: (contactId: string, force?: boolean) => Promise;
setSearchQuery: (query: string) => void;
selectContact: (id: string) => void;
closeDetails: () => void;
@@ -27,8 +32,10 @@ export const useContactsStore = create((set, get) => ({
contactNotes: {},
searchQuery: '',
selectedContactId: null,
+ selectedContactDetails: null,
isDetailsOpen: false,
isLoading: false,
+ isDetailsLoading: false,
isNotesLoading: false,
error: null,
@@ -42,11 +49,12 @@ export const useContactsStore = create((set, get) => ({
}
},
- fetchContactNotes: async (contactId: string) => {
+ fetchContactNotes: async (contactId: string, force = false) => {
const { contactNotes } = get();
- // Don't fetch if we already have notes for this contact
- if (contactNotes[contactId]) {
+ // Don't fetch if we already have notes for this contact, unless the sheet asks for a fresh read
+ // (opening a contact, or after protected data was unlocked, when the cached notes may be redacted).
+ if (contactNotes[contactId] && !force) {
return;
}
@@ -68,9 +76,29 @@ export const useContactsStore = create((set, get) => ({
}
},
+ fetchContactDetails: async (contactId: string) => {
+ set({ isDetailsLoading: true, selectedContactDetails: null });
+ try {
+ const response = await getContact(contactId);
+ if (get().selectedContactId !== contactId) {
+ return; // stale response — a newer contact was selected meanwhile
+ }
+ set({ selectedContactDetails: response.Data || null, isDetailsLoading: false });
+ } catch {
+ if (get().selectedContactId !== contactId) {
+ return;
+ }
+ // The sheet falls back to the slim list record already in the store
+ set({ isDetailsLoading: false, selectedContactDetails: null });
+ }
+ },
+
setSearchQuery: (query) => set({ searchQuery: query }),
- selectContact: (id) => set({ selectedContactId: id, isDetailsOpen: true }),
+ selectContact: (id) => {
+ set({ selectedContactId: id, isDetailsOpen: true });
+ void get().fetchContactDetails(id);
+ },
- closeDetails: () => set({ isDetailsOpen: false }),
+ closeDetails: () => set({ isDetailsOpen: false, selectedContactDetails: null }),
}));
diff --git a/src/stores/dispatch/__tests__/personnel-actions-store.test.ts b/src/stores/dispatch/__tests__/personnel-actions-store.test.ts
new file mode 100644
index 00000000..17c6676e
--- /dev/null
+++ b/src/stores/dispatch/__tests__/personnel-actions-store.test.ts
@@ -0,0 +1,96 @@
+import { savePersonsStatuses } from '@/api/personnel/personnelStatuses';
+import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers';
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { type GroupResultData } from '@/models/v4/groups/groupsResultData';
+import { type PersonnelInfoResultData } from '@/models/v4/personnel/personnelInfoResultData';
+import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
+
+import { usePersonnelActionsStore } from '../personnel-actions-store';
+
+jest.mock('@/api/personnel/personnelStatuses', () => ({
+ savePersonsStatuses: jest.fn(),
+}));
+
+jest.mock('@/api/personnel/personnelStaffing', () => ({
+ savePersonsStaffings: jest.fn(),
+}));
+
+const mockSavePersonsStatuses = savePersonsStatuses as jest.MockedFunction;
+
+const person = (UserId: string): PersonnelInfoResultData => ({ UserId, FirstName: 'Pat', LastName: UserId, StatusDestinationId: '' }) as PersonnelInfoResultData;
+const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Text, Detail, Note: 0 }) as StatusesResultData;
+const call: CallResultData = { CallId: '1234', Number: '26-1', Name: 'Structure Fire', State: 0 } as CallResultData;
+const station: GroupResultData = { GroupId: '55', Name: 'Station 5' } as GroupResultData;
+
+const responding = status(2, 'Responding', CustomStateDetailType.CallsAndStations);
+const onScene = status(3, 'On Scene', CustomStateDetailType.Calls);
+const standingBy = status(4, 'Standing By', CustomStateDetailType.Stations);
+const notResponding = status(1, 'Not Responding', CustomStateDetailType.None);
+
+const lastPayload = () => mockSavePersonsStatuses.mock.calls[mockSavePersonsStatuses.mock.calls.length - 1][0];
+
+describe('usePersonnelActionsStore', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSavePersonsStatuses.mockResolvedValue({} as any);
+ usePersonnelActionsStore.getState().reset();
+ });
+
+ it('keeps the call destination sticky after a successful submit so the next status carries it too', async () => {
+ usePersonnelActionsStore.getState().openActions(person('p1'));
+ usePersonnelActionsStore.getState().setStatusSelectedCall(call);
+
+ await expect(usePersonnelActionsStore.getState().submitStatus({ status: responding })).resolves.toBe(true);
+ expect(lastPayload()).toMatchObject({ UserIds: ['p1'], Type: '2', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call });
+
+ const afterSubmit = usePersonnelActionsStore.getState();
+ expect(afterSubmit.selectedStatus).toBeNull();
+ expect(afterSubmit.statusDestinationType).toBe('call');
+ expect(afterSubmit.statusSelectedCall).toBe(call);
+
+ await usePersonnelActionsStore.getState().submitStatus({ status: onScene });
+ expect(lastPayload()).toMatchObject({ Type: '3', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call });
+ });
+
+ it('does not send a destination the status Detail does not support', async () => {
+ usePersonnelActionsStore.getState().openActions(person('p1'));
+ usePersonnelActionsStore.getState().setStatusSelectedCall(call);
+
+ await usePersonnelActionsStore.getState().submitStatus({ status: notResponding });
+ expect(lastPayload()).toMatchObject({ Type: '1', RespondingTo: '', RespondingToType: null });
+
+ await usePersonnelActionsStore.getState().submitStatus({ status: standingBy });
+ expect(lastPayload()).toMatchObject({ Type: '4', RespondingTo: '', RespondingToType: null });
+
+ usePersonnelActionsStore.getState().setStatusSelectedStation(station);
+ await usePersonnelActionsStore.getState().submitStatus({ status: onScene });
+ expect(lastPayload()).toMatchObject({ Type: '3', RespondingTo: '', RespondingToType: null });
+
+ await usePersonnelActionsStore.getState().submitStatus({ status: standingBy });
+ expect(lastPayload()).toMatchObject({ Type: '4', RespondingTo: '55', RespondingToType: DestinationEntityType.Station });
+ });
+
+ it('presets the call for an explicit call context and sends it even when the status Detail does not list calls', async () => {
+ usePersonnelActionsStore.getState().openActions(person('p1'), { callContext: call });
+
+ expect(usePersonnelActionsStore.getState().statusDestinationType).toBe('call');
+ expect(usePersonnelActionsStore.getState().statusSelectedCall).toBe(call);
+
+ await usePersonnelActionsStore.getState().submitStatus({ status: notResponding });
+ expect(lastPayload()).toMatchObject({ UserIds: ['p1'], Type: '1', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call });
+ });
+
+ it('starts a fresh session and form when opened for another person', () => {
+ usePersonnelActionsStore.getState().openActions(person('p1'), { callContext: call });
+ const firstSession = usePersonnelActionsStore.getState().actionsSessionId;
+
+ usePersonnelActionsStore.getState().openActions(person('p2'));
+ const reopened = usePersonnelActionsStore.getState();
+
+ expect(reopened.actionsSessionId).toBe(firstSession + 1);
+ expect(reopened.selectedPersonnel?.UserId).toBe('p2');
+ expect(reopened.callContext).toBeNull();
+ expect(reopened.statusDestinationType).toBe('none');
+ expect(reopened.statusSelectedCall).toBeNull();
+ });
+});
diff --git a/src/stores/dispatch/__tests__/unit-actions-store.test.ts b/src/stores/dispatch/__tests__/unit-actions-store.test.ts
new file mode 100644
index 00000000..45a87e60
--- /dev/null
+++ b/src/stores/dispatch/__tests__/unit-actions-store.test.ts
@@ -0,0 +1,122 @@
+import { saveUnitStatus } from '@/api/units/unitStatuses';
+import { CustomStateDetailType, DestinationEntityType } from '@/lib/destination-helpers';
+import { type CallResultData } from '@/models/v4/calls/callResultData';
+import { type GroupResultData } from '@/models/v4/groups/groupsResultData';
+import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData';
+import { type UnitInfoResultData } from '@/models/v4/units/unitInfoResultData';
+
+import { useUnitActionsStore } from '../unit-actions-store';
+
+jest.mock('@/api/units/unitStatuses', () => ({
+ saveUnitStatus: jest.fn(),
+}));
+
+const mockSaveUnitStatus = saveUnitStatus as jest.MockedFunction;
+
+const unit = (UnitId: string): UnitInfoResultData => ({ UnitId, Name: `Unit ${UnitId}`, CurrentDestinationId: '' }) as UnitInfoResultData;
+const status = (Id: number, Text: string, Detail: number): StatusesResultData => ({ Id, Text, Detail, Note: 0 }) as StatusesResultData;
+const call: CallResultData = { CallId: '1234', Number: '26-1', Name: 'Structure Fire', State: 0 } as CallResultData;
+const station: GroupResultData = { GroupId: '55', Name: 'Station 5' } as GroupResultData;
+
+const responding = status(3, 'Responding', CustomStateDetailType.Calls);
+const onScene = status(6, 'On Scene', CustomStateDetailType.Calls);
+const returning = status(7, 'Returning', CustomStateDetailType.Stations);
+const outOfService = status(8, 'Out of Service', CustomStateDetailType.None);
+
+const lastPayload = () => mockSaveUnitStatus.mock.calls[mockSaveUnitStatus.mock.calls.length - 1][0];
+
+describe('useUnitActionsStore', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockSaveUnitStatus.mockResolvedValue({} as any);
+ useUnitActionsStore.getState().reset();
+ });
+
+ it('keeps the call destination sticky after a successful submit so the next status carries it too', async () => {
+ const store = useUnitActionsStore.getState();
+ store.openActions(unit('u1'));
+ store.setStatusSelectedCall(call);
+
+ await expect(useUnitActionsStore.getState().submitStatus({ status: responding })).resolves.toBe(true);
+ expect(lastPayload()).toMatchObject({ Id: 'u1', Type: '3', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call });
+
+ const afterSubmit = useUnitActionsStore.getState();
+ expect(afterSubmit.selectedStatus).toBeNull();
+ expect(afterSubmit.statusNote).toBe('');
+ expect(afterSubmit.statusDestinationType).toBe('call');
+ expect(afterSubmit.statusSelectedCall).toBe(call);
+
+ await useUnitActionsStore.getState().submitStatus({ status: onScene });
+ expect(lastPayload()).toMatchObject({ Type: '6', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call });
+ });
+
+ it('does not send a destination the status Detail does not support', async () => {
+ const store = useUnitActionsStore.getState();
+ store.openActions(unit('u1'));
+ store.setStatusSelectedCall(call);
+
+ await useUnitActionsStore.getState().submitStatus({ status: outOfService });
+ expect(lastPayload()).toMatchObject({ Type: '8', RespondingTo: '', RespondingToType: null });
+
+ await useUnitActionsStore.getState().submitStatus({ status: returning });
+ expect(lastPayload()).toMatchObject({ Type: '7', RespondingTo: '', RespondingToType: null });
+
+ useUnitActionsStore.getState().setStatusSelectedStation(station);
+ await useUnitActionsStore.getState().submitStatus({ status: returning });
+ expect(lastPayload()).toMatchObject({ Type: '7', RespondingTo: '55', RespondingToType: DestinationEntityType.Station });
+
+ await useUnitActionsStore.getState().submitStatus({ status: onScene });
+ expect(lastPayload()).toMatchObject({ Type: '6', RespondingTo: '', RespondingToType: null });
+ });
+
+ it('presets the call for an explicit call context and sends it even when the status Detail does not list calls', async () => {
+ useUnitActionsStore.getState().openActions(unit('u1'), { callContext: call });
+
+ const opened = useUnitActionsStore.getState();
+ expect(opened.callContext).toBe(call);
+ expect(opened.statusDestinationType).toBe('call');
+ expect(opened.statusSelectedCall).toBe(call);
+
+ await useUnitActionsStore.getState().submitStatus({ status: outOfService });
+ expect(lastPayload()).toMatchObject({ Id: 'u1', Type: '8', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call });
+
+ await useUnitActionsStore.getState().submitStatus({ status: responding });
+ expect(lastPayload()).toMatchObject({ Type: '3', RespondingTo: '1234', RespondingToType: DestinationEntityType.Call });
+ });
+
+ it('starts a fresh session and form when opened for another unit, dropping the previous call context', () => {
+ const store = useUnitActionsStore.getState();
+ store.openActions(unit('u1'), { callContext: call });
+ const firstSession = useUnitActionsStore.getState().actionsSessionId;
+
+ useUnitActionsStore.getState().openActions(unit('u2'));
+ const reopened = useUnitActionsStore.getState();
+
+ expect(reopened.actionsSessionId).toBe(firstSession + 1);
+ expect(reopened.selectedUnit?.UnitId).toBe('u2');
+ expect(reopened.callContext).toBeNull();
+ expect(reopened.statusDestinationType).toBe('none');
+ expect(reopened.statusSelectedCall).toBeNull();
+ });
+
+ it('clears the call context when the actions are closed', () => {
+ useUnitActionsStore.getState().openActions(unit('u1'), { callContext: call });
+ useUnitActionsStore.getState().closeActions();
+
+ expect(useUnitActionsStore.getState().isActionsOpen).toBe(false);
+ expect(useUnitActionsStore.getState().callContext).toBeNull();
+ });
+
+ it('keeps the form (including the destination) when the save fails', async () => {
+ mockSaveUnitStatus.mockRejectedValueOnce(new Error('offline'));
+ useUnitActionsStore.getState().openActions(unit('u1'));
+ useUnitActionsStore.getState().setStatusSelectedCall(call);
+ useUnitActionsStore.getState().setSelectedStatus(responding);
+
+ await expect(useUnitActionsStore.getState().submitStatus()).resolves.toBe(false);
+ const state = useUnitActionsStore.getState();
+ expect(state.statusError).toBe('offline');
+ expect(state.selectedStatus).toBe(responding);
+ expect(state.statusSelectedCall).toBe(call);
+ });
+});
diff --git a/src/stores/dispatch/personnel-actions-store.ts b/src/stores/dispatch/personnel-actions-store.ts
index f8e2c708..f8552c13 100644
--- a/src/stores/dispatch/personnel-actions-store.ts
+++ b/src/stores/dispatch/personnel-actions-store.ts
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { savePersonsStaffings } from '@/api/personnel/personnelStaffing';
import { savePersonsStatuses } from '@/api/personnel/personnelStatuses';
-import { DestinationEntityType, type DestinationSelectionType } from '@/lib/destination-helpers';
+import { type DestinationSelectionType, getStatusDestinationPayload } from '@/lib/destination-helpers';
import { type CallResultData } from '@/models/v4/calls/callResultData';
import { type GroupResultData } from '@/models/v4/groups/groupsResultData';
import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
@@ -12,6 +12,14 @@ import { type StatusesResultData } from '@/models/v4/statuses/statusesResultData
export type PersonnelActionTab = 'status' | 'staffing';
export type DestinationType = DestinationSelectionType;
+export interface OpenPersonnelActionsOptions {
+ /**
+ * Explicit call context (the dashboard "+" set-status-for-call action). The destination is preset
+ * to this call and it is sent with the status even when the status's Detail does not list calls.
+ */
+ callContext?: CallResultData | null;
+}
+
interface PersonnelActionsState {
// Panel visibility
isActionsOpen: boolean;
@@ -19,6 +27,14 @@ interface PersonnelActionsState {
// Selected personnel
selectedPersonnel: PersonnelInfoResultData | null;
+ // Explicit call context the panel was opened from (null for a plain personnel selection)
+ callContext: CallResultData | null;
+
+ // Incremented on every openActions so the panel re-initialises its default destination per open
+ actionsSessionId: number;
+ // The session whose default destination has been applied (kept here so a remount does not re-apply it)
+ destinationInitializedSessionId: number | null;
+
// Current tab
activeTab: PersonnelActionTab;
@@ -49,8 +65,9 @@ interface PersonnelActionsState {
staffingError: string | null;
// Actions
- openActions: (personnel: PersonnelInfoResultData) => void;
+ openActions: (personnel: PersonnelInfoResultData, options?: OpenPersonnelActionsOptions) => void;
closeActions: () => void;
+ markDestinationInitialized: (sessionId: number) => void;
setActiveTab: (tab: PersonnelActionTab) => void;
// Status actions
@@ -84,6 +101,9 @@ interface PersonnelActionsState {
const initialState = {
isActionsOpen: false,
selectedPersonnel: null,
+ callContext: null,
+ actionsSessionId: 0,
+ destinationInitializedSessionId: null,
activeTab: 'status' as PersonnelActionTab,
selectedStatus: null,
statusDestinationType: 'none' as DestinationType,
@@ -108,15 +128,18 @@ const initialState = {
export const usePersonnelActionsStore = create((set, get) => ({
...initialState,
- openActions: (personnel) => {
- set({
+ openActions: (personnel, options) => {
+ const callContext = options?.callContext ?? null;
+ set((state) => ({
isActionsOpen: true,
selectedPersonnel: personnel,
+ callContext,
+ actionsSessionId: state.actionsSessionId + 1,
activeTab: 'status',
- // Reset form states when opening for new personnel
+ // Reset form states when opening for new personnel; an explicit call context presets the destination
selectedStatus: null,
- statusDestinationType: 'none',
- statusSelectedCall: null,
+ statusDestinationType: callContext ? 'call' : 'none',
+ statusSelectedCall: callContext,
statusSelectedStation: null,
statusSelectedPoi: null,
statusNote: '',
@@ -124,16 +147,19 @@ export const usePersonnelActionsStore = create((set, get)
staffingNote: '',
statusError: null,
staffingError: null,
- });
+ }));
},
closeActions: () => {
set({
isActionsOpen: false,
selectedPersonnel: null,
+ callContext: null,
});
},
+ markDestinationInitialized: (sessionId) => set({ destinationInitializedSessionId: sessionId }),
+
setActiveTab: (tab) => set({ activeTab: tab }),
// Status actions
@@ -189,7 +215,7 @@ export const usePersonnelActionsStore = create((set, get)
const storeState = get();
const selectedPersonnel = overrides?.personnel ?? storeState.selectedPersonnel;
const selectedStatus = overrides?.status ?? storeState.selectedStatus;
- const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote } = storeState;
+ const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote, callContext } = storeState;
if (!selectedPersonnel || !selectedStatus) {
set({ statusError: 'Please select a status' });
@@ -200,25 +226,17 @@ export const usePersonnelActionsStore = create((set, get)
try {
const date = new Date();
- let respondingTo = '';
-
- if (statusDestinationType === 'call' && statusSelectedCall) {
- respondingTo = statusSelectedCall.CallId;
- } else if (statusDestinationType === 'station' && statusSelectedStation) {
- respondingTo = statusSelectedStation.GroupId;
- } else if (statusDestinationType === 'poi' && statusSelectedPoi) {
- respondingTo = statusSelectedPoi.PoiId.toString();
- }
-
- let respondingToType: number | null = null;
-
- if (statusDestinationType === 'call' && statusSelectedCall) {
- respondingToType = DestinationEntityType.Call;
- } else if (statusDestinationType === 'station' && statusSelectedStation) {
- respondingToType = DestinationEntityType.Station;
- } else if (statusDestinationType === 'poi' && statusSelectedPoi) {
- respondingToType = DestinationEntityType.Poi;
- }
+ // Only send a destination the chosen status supports (an explicit call context always may carry its call)
+ const { respondingTo, respondingToType } = getStatusDestinationPayload(
+ {
+ selectedDestinationType: statusDestinationType,
+ selectedCall: statusSelectedCall,
+ selectedStation: statusSelectedStation,
+ selectedPoi: statusSelectedPoi,
+ },
+ selectedStatus.Detail,
+ !!callContext
+ );
await savePersonsStatuses({
UserIds: [selectedPersonnel.UserId],
@@ -238,14 +256,11 @@ export const usePersonnelActionsStore = create((set, get)
EventId: '',
});
- // Reset the status form after successful submission
+ // Reset the status and note after a successful submission. The destination stays sticky so a
+ // follow-up status for the same person (e.g. Responding -> On Scene) keeps the same call.
set({
isSubmittingStatus: false,
selectedStatus: null,
- statusDestinationType: 'none',
- statusSelectedCall: null,
- statusSelectedStation: null,
- statusSelectedPoi: null,
statusNote: '',
});
diff --git a/src/stores/dispatch/unit-actions-store.ts b/src/stores/dispatch/unit-actions-store.ts
index f78fb2ef..663c3439 100644
--- a/src/stores/dispatch/unit-actions-store.ts
+++ b/src/stores/dispatch/unit-actions-store.ts
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { saveUnitStatus } from '@/api/units/unitStatuses';
-import { DestinationEntityType, type DestinationSelectionType } from '@/lib/destination-helpers';
+import { type DestinationSelectionType, getStatusDestinationPayload } from '@/lib/destination-helpers';
import { type CallResultData } from '@/models/v4/calls/callResultData';
import { type GroupResultData } from '@/models/v4/groups/groupsResultData';
import { type PoiResultData } from '@/models/v4/mapping/poiResultData';
@@ -11,6 +11,14 @@ import { SaveUnitStatusInput } from '@/models/v4/unitStatus/saveUnitStatusInput'
export type DestinationType = DestinationSelectionType;
+export interface OpenUnitActionsOptions {
+ /**
+ * Explicit call context (the dashboard "+" set-status-for-call action). The destination is preset
+ * to this call and it is sent with the status even when the status's Detail does not list calls.
+ */
+ callContext?: CallResultData | null;
+}
+
interface UnitActionsState {
// Panel visibility
isActionsOpen: boolean;
@@ -18,6 +26,14 @@ interface UnitActionsState {
// Selected unit
selectedUnit: UnitInfoResultData | null;
+ // Explicit call context the panel was opened from (null for a plain unit selection)
+ callContext: CallResultData | null;
+
+ // Incremented on every openActions so the panel re-initialises its default destination per open
+ actionsSessionId: number;
+ // The session whose default destination has been applied (kept here so a remount does not re-apply it)
+ destinationInitializedSessionId: number | null;
+
// Status action state
selectedStatus: StatusesResultData | null;
statusDestinationType: DestinationType;
@@ -38,8 +54,9 @@ interface UnitActionsState {
statusError: string | null;
// Actions
- openActions: (unit: UnitInfoResultData) => void;
+ openActions: (unit: UnitInfoResultData, options?: OpenUnitActionsOptions) => void;
closeActions: () => void;
+ markDestinationInitialized: (sessionId: number) => void;
// Status actions
setSelectedStatus: (status: StatusesResultData | null) => void;
@@ -65,6 +82,9 @@ interface UnitActionsState {
const initialState = {
isActionsOpen: false,
selectedUnit: null,
+ callContext: null,
+ actionsSessionId: 0,
+ destinationInitializedSessionId: null,
selectedStatus: null,
statusDestinationType: 'none' as DestinationType,
statusSelectedCall: null,
@@ -83,28 +103,34 @@ const initialState = {
export const useUnitActionsStore = create((set, get) => ({
...initialState,
- openActions: (unit) => {
- set({
+ openActions: (unit, options) => {
+ const callContext = options?.callContext ?? null;
+ set((state) => ({
isActionsOpen: true,
selectedUnit: unit,
- // Reset form states when opening for new unit
+ callContext,
+ actionsSessionId: state.actionsSessionId + 1,
+ // Reset form states when opening for new unit; an explicit call context presets the destination
selectedStatus: null,
- statusDestinationType: 'none',
- statusSelectedCall: null,
+ statusDestinationType: callContext ? 'call' : 'none',
+ statusSelectedCall: callContext,
statusSelectedStation: null,
statusSelectedPoi: null,
statusNote: '',
statusError: null,
- });
+ }));
},
closeActions: () => {
set({
isActionsOpen: false,
selectedUnit: null,
+ callContext: null,
});
},
+ markDestinationInitialized: (sessionId) => set({ destinationInitializedSessionId: sessionId }),
+
// Status actions
setSelectedStatus: (status) => set({ selectedStatus: status, statusError: null }),
@@ -158,7 +184,7 @@ export const useUnitActionsStore = create((set, get) => ({
const storeState = get();
const selectedUnit = overrides?.unit ?? storeState.selectedUnit;
const selectedStatus = overrides?.status ?? storeState.selectedStatus;
- const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote } = storeState;
+ const { statusDestinationType, statusSelectedCall, statusSelectedStation, statusSelectedPoi, statusNote, callContext } = storeState;
if (!selectedUnit || !selectedStatus) {
set({ statusError: 'Please select a status' });
@@ -169,19 +195,17 @@ export const useUnitActionsStore = create((set, get) => ({
try {
const date = new Date();
- let respondingTo = '';
- let respondingToType: number | null = null;
-
- if (statusDestinationType === 'call' && statusSelectedCall) {
- respondingTo = statusSelectedCall.CallId;
- respondingToType = DestinationEntityType.Call;
- } else if (statusDestinationType === 'station' && statusSelectedStation) {
- respondingTo = statusSelectedStation.GroupId;
- respondingToType = DestinationEntityType.Station;
- } else if (statusDestinationType === 'poi' && statusSelectedPoi) {
- respondingTo = statusSelectedPoi.PoiId.toString();
- respondingToType = DestinationEntityType.Poi;
- }
+ // Only send a destination the chosen status supports (an explicit call context always may carry its call)
+ const { respondingTo, respondingToType } = getStatusDestinationPayload(
+ {
+ selectedDestinationType: statusDestinationType,
+ selectedCall: statusSelectedCall,
+ selectedStation: statusSelectedStation,
+ selectedPoi: statusSelectedPoi,
+ },
+ selectedStatus.Detail,
+ !!callContext
+ );
const input = new SaveUnitStatusInput();
input.Id = selectedUnit.UnitId;
@@ -204,14 +228,11 @@ export const useUnitActionsStore = create((set, get) => ({
await saveUnitStatus(input);
- // Reset the status form after successful submission
+ // Reset the status and note after a successful submission. The destination stays sticky so a
+ // follow-up status for the same unit (e.g. Responding -> On Scene) keeps the same call.
set({
isSubmittingStatus: false,
selectedStatus: null,
- statusDestinationType: 'none',
- statusSelectedCall: null,
- statusSelectedStation: null,
- statusSelectedPoi: null,
statusNote: '',
});
diff --git a/src/stores/operations/__tests__/store.test.ts b/src/stores/operations/__tests__/store.test.ts
index 5a9eec49..a417b866 100644
--- a/src/stores/operations/__tests__/store.test.ts
+++ b/src/stores/operations/__tests__/store.test.ts
@@ -6,6 +6,11 @@ jest.mock('@/api/operations/operations', () => ({
newTimeReport: jest.fn(),
saveTimeEntries: jest.fn(),
submitTimeReport: jest.fn(),
+ signTimeReport: jest.fn(),
+ approveTimeReport: jest.fn(),
+ getExpenses: jest.fn(),
+ saveExpense: jest.fn(),
+ deleteExpense: jest.fn(),
getFieldCostAccess: jest.fn(),
getResourceUsage: jest.fn(),
addResourceUsage: jest.fn(),
@@ -14,7 +19,9 @@ jest.mock('@/api/operations/operations', () => ({
buildF42: jest.fn(),
validateCalOesMarsWorkItem: jest.fn(),
}));
-jest.mock('@/lib/operations/capabilities', () => ({ operationsCapabilities: { editTime: true, recordUsage: true, draftF42: true, homeRoute: '/', useActiveUnitId: () => null } }));
+jest.mock('@/lib/operations/capabilities', () => ({
+ operationsCapabilities: { editTime: true, recordUsage: true, recordExpenses: true, approveTime: true, draftF42: true, homeRoute: '/', useActiveUnitId: () => null },
+}));
jest.mock('@/stores/auth/store', () => {
const { create } = jest.requireActual('zustand');
return { __esModule: true, default: create(() => ({ userId: 'me' })) };
@@ -30,18 +37,41 @@ import useAuthStore from '@/stores/auth/store';
import { useOperationsStore } from '@/stores/operations/store';
const server = jest.mocked(api);
-const deployment = { Id: 'dep-1', Name: 'LNU', Status: 2, FinanceMode: 1, CallId: 9, Units: [], Personnel: [{ Id: 'dp-1', UserId: 'me', Name: 'Me', IsActive: true }], Equipment: [] };
-const report = (status = 0, entries: unknown[] = []) => ({ Id: 'r-1', DeploymentId: 'dep-1', ReportNumber: 1, ReportDate: '2026-09-19T00:00:00', Status: status, Entries: entries });
+const deployment = {
+ Id: 'dep-1',
+ Name: 'LNU',
+ Status: 2,
+ FinanceMode: 1,
+ CallId: 9,
+ Currency: 'USD',
+ Units: [{ Id: 'du-1', UnitId: 12, UnitName: 'E-41', IsActive: true }],
+ Personnel: [{ Id: 'dp-1', UserId: 'me', Name: 'Me', DeploymentUnitId: 'du-1', IsActive: true }],
+ Equipment: [],
+ TimeAccess: { CanManage: false, CanApprove: true, PersonnelId: 'dp-1', CrewUnitIds: ['du-1'], WritableSubjectIds: ['dp-1', 'du-1'] },
+};
+const report = (status = 0, entries: unknown[] = [], patch: Record = {}) => ({
+ Id: 'r-1',
+ DeploymentId: 'dep-1',
+ ReportNumber: 1,
+ ReportDate: '2026-09-19T00:00:00',
+ Scope: 1,
+ DeploymentUnitId: 'du-1',
+ CanAct: true,
+ Status: status,
+ Entries: entries,
+ ...patch,
+});
+const answer = (data: unknown, errors: unknown[] = [], warnings: unknown[] = []) => ({ Data: data, Status: 'Ok', PageSize: 1, Errors: errors, Warnings: warnings }) as never;
beforeEach(() => {
jest.clearAllMocks();
- useOperationsStore.setState({ identity: null, access: null, deployment: null, reports: [], report: null, entries: [], dirty: false, issues: [], warnings: [], error: null });
+ useOperationsStore.setState({ identity: null, access: null, deployment: null, reports: [], scope: null, report: null, entries: [], dirty: false, issues: [], warnings: [], expenses: [], error: null });
server.getDeploymentAccess.mockResolvedValue({ Enabled: true, CanManage: false, CanApproveTimeReports: false, ContractorBilling: false });
server.getFieldCostAccess.mockResolvedValue({ Enabled: true, CanViewInternalCosts: false, CanRecordUsage: true });
server.getCalOesMarsAccess.mockRejectedValue(new Error('no add-on'));
server.getDeployment.mockResolvedValue(deployment as never);
server.getTimeReports.mockResolvedValue([]);
- server.newTimeReport.mockResolvedValue({ Data: report(), Status: 'Ok', PageSize: 1, Errors: [], Warnings: [] } as never);
+ server.newTimeReport.mockResolvedValue(answer(report()));
});
it('loads access without letting an unavailable add-on hide the deployments, and drops state on identity change', async () => {
@@ -57,18 +87,19 @@ it('loads access without letting an unavailable add-on hide the deployments, and
useAuthStore.setState({ userId: 'me' });
});
-it('creates the day report on demand, refuses to save invalid entries locally and submits only a clean saved report', async () => {
+it('opens the crew report for the scope on demand, refuses invalid entries locally and submits only a clean saved report', async () => {
await useOperationsStore.getState().loadAccess();
await useOperationsStore.getState().open('dep-1');
- await useOperationsStore.getState().openReport('2026-09-19', false);
+ useOperationsStore.getState().setScope({ kind: 'crew', unitId: 'du-1' }, '2026-09-19');
expect(useOperationsStore.getState().report).toBeNull();
+ await useOperationsStore.getState().openReport('2026-09-19', false);
expect(server.newTimeReport).not.toHaveBeenCalled();
await useOperationsStore.getState().openReport('2026-09-19', true);
- expect(server.newTimeReport).toHaveBeenCalledWith('dep-1', '2026-09-19');
+ expect(server.newTimeReport).toHaveBeenCalledWith('dep-1', '2026-09-19', { deploymentUnitId: 'du-1', deploymentPersonnelId: null });
expect(useOperationsStore.getState().report?.Id).toBe('r-1');
- const bad = { ...newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0), EndTime: '2026-09-19T06:00:00' };
+ const bad = { ...newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0), EndLocal: '2026-09-19T06:00' };
useOperationsStore.getState().setEntries([bad]);
expect(await useOperationsStore.getState().save()).toBe(false);
expect(server.saveTimeEntries).not.toHaveBeenCalled();
@@ -76,17 +107,70 @@ it('creates the day report on demand, refuses to save invalid entries locally an
expect(await useOperationsStore.getState().submit()).toBe(false);
const good = newEntry({ type: 0, id: 'dp-1', label: 'Me' }, '2026-09-19', 0);
- server.saveTimeEntries.mockResolvedValue({ Data: report(0, [{ ...good, Id: 'e-1', Hours: 10 }]), Status: 'Ok', PageSize: 1, Errors: [], Warnings: [{ Code: 'long_day' }] } as never);
+ server.saveTimeEntries.mockResolvedValue(answer(report(0, [{ ...good, Id: 'e-1', Hours: 10 }]), [], [{ Code: 'break_rule' }]));
useOperationsStore.getState().setEntries([good]);
expect(await useOperationsStore.getState().save()).toBe(true);
expect(server.saveTimeEntries).toHaveBeenCalledWith('r-1', [good]);
expect(useOperationsStore.getState().dirty).toBe(false);
- expect(useOperationsStore.getState().warnings).toEqual([{ Code: 'long_day' }]);
+ expect(useOperationsStore.getState().warnings).toEqual([{ Code: 'break_rule' }]);
+
+ server.signTimeReport.mockResolvedValue(answer(report(0, [{ ...good, Id: 'e-1' }], { ContractorSignedOn: '2026-09-19T20:00:00Z' })));
+ expect(await useOperationsStore.getState().sign(true)).toBe(true);
+ expect(server.signTimeReport).toHaveBeenCalledWith('r-1', true, undefined);
+ expect(useOperationsStore.getState().report?.ContractorSignedOn).toBe('2026-09-19T20:00:00Z');
- server.submitTimeReport.mockResolvedValue({ Data: report(1, [{ ...good, Id: 'e-1' }]), Status: 'Ok', PageSize: 1, Errors: [], Warnings: [] } as never);
+ server.submitTimeReport.mockResolvedValue(answer(report(1, [{ ...good, Id: 'e-1' }])));
expect(await useOperationsStore.getState().submit()).toBe(true);
expect(useOperationsStore.getState().report?.Status).toBe(1);
- expect(useOperationsStore.getState().reports[0]?.Status).toBe(1);
+ expect(useOperationsStore.getState().reports.find((candidate) => candidate.Id === 'r-1')?.Status).toBe(1);
+
+ server.approveTimeReport.mockResolvedValue(answer(report(2, [{ ...good, Id: 'e-1' }])));
+ expect(await useOperationsStore.getState().approve('r-1')).toBe(true);
+ expect(useOperationsStore.getState().report?.Status).toBe(2);
+});
+
+it('shows why a create was refused instead of adding an empty report to the day', async () => {
+ await useOperationsStore.getState().open('dep-1');
+ useOperationsStore.getState().setScope({ kind: 'crew', unitId: 'du-1' }, '2026-09-19');
+ server.newTimeReport.mockResolvedValue(answer(null, [{ Code: 'deployment_closed' }], [{ Code: 'late_entry' }]));
+
+ await useOperationsStore.getState().openReport('2026-09-19', true);
+
+ const state = useOperationsStore.getState();
+ expect(state.reports).toEqual([]);
+ expect(state.report).toBeNull();
+ expect(state.issues).toEqual([{ Code: 'deployment_closed' }]);
+ expect(state.warnings).toEqual([{ Code: 'late_entry' }]);
+});
+
+it('keeps validation refusals from the server on the report and switches scope without leaking entries', async () => {
+ server.getTimeReports.mockResolvedValue([report(0, [{ Id: 'e-1', SubjectType: 0, DeploymentPersonnelId: 'dp-1', EntryType: 0, StartLocal: '2026-09-19T08:00', EndLocal: '2026-09-19T18:00' }])] as never);
+ await useOperationsStore.getState().open('dep-1');
+ useOperationsStore.getState().setScope({ kind: 'crew', unitId: 'du-1' }, '2026-09-19');
+ expect(useOperationsStore.getState().entries).toHaveLength(1);
+
+ server.saveTimeEntries.mockResolvedValue(answer(report(0, []), [{ Code: 'subject_on_other_report', SubjectId: 'dp-1' }]));
+ useOperationsStore.getState().setEntries([...useOperationsStore.getState().entries]);
+ expect(await useOperationsStore.getState().save()).toBe(false);
+ expect(useOperationsStore.getState().issues).toEqual([{ Code: 'subject_on_other_report', SubjectId: 'dp-1' }]);
+
+ useOperationsStore.getState().setScope({ kind: 'individual', personnelId: 'dp-1' }, '2026-09-19');
+ expect(useOperationsStore.getState().report).toBeNull();
+ expect(useOperationsStore.getState().entries).toEqual([]);
+ expect(useOperationsStore.getState().issues).toEqual([]);
+});
+
+it('files expenses against the open deployment and removes only by id', async () => {
+ await useOperationsStore.getState().open('dep-1');
+ server.saveExpense.mockResolvedValue({ Id: 'x-1', DeploymentId: 'dep-1', ExpenseDate: '2026-09-19T00:00:00', ExpenseType: 4, Amount: 88.4, PreApproved: false, Billable: true } as never);
+ expect(await useOperationsStore.getState().addExpense({ ExpenseDate: '2026-09-19', ExpenseType: 4, Amount: 88.4, ReceiptData: 'AAAA', ReceiptFileName: 'r.jpg', ReceiptContentType: 'image/jpeg' })).toBe(true);
+ expect(server.saveExpense).toHaveBeenCalledWith(expect.objectContaining({ DeploymentId: 'dep-1', ExpenseType: 4, ReceiptData: 'AAAA' }));
+ expect(useOperationsStore.getState().expenses.map((expense) => expense.Id)).toEqual(['x-1']);
+
+ server.deleteExpense.mockResolvedValue({} as never);
+ expect(await useOperationsStore.getState().removeExpense('x-1')).toBe(true);
+ expect(server.deleteExpense).toHaveBeenCalledWith('x-1');
+ expect(useOperationsStore.getState().expenses).toEqual([]);
});
it('stamps usage readings with the open deployment and surfaces server refusals as reason codes', async () => {
diff --git a/src/stores/operations/store.ts b/src/stores/operations/store.ts
index bf89c85f..b6fa7a1b 100644
--- a/src/stores/operations/store.ts
+++ b/src/stores/operations/store.ts
@@ -2,42 +2,50 @@ import { create } from 'zustand';
import {
addResourceUsage,
+ approveTimeReport,
buildF42,
+ deleteExpense,
getCalOesMarsAccess,
getCalOesMarsQueue,
getDeployment,
getDeploymentAccess,
getDeployments,
+ getExpenses,
getFieldCostAccess,
getResourceUsage,
getTimeReports,
newTimeReport,
+ saveExpense,
saveTimeEntries,
+ signTimeReport,
submitTimeReport,
validateCalOesMarsWorkItem,
} from '@/api/operations/operations';
import { operationsCapabilities } from '@/lib/operations/capabilities';
-import { operationsError, reportForDate, validateEntries } from '@/lib/operations/time';
+import { operationsError, reportForScope, scopeKey, type TimeScope, validateEntries } from '@/lib/operations/time';
import type {
CalOesMarsAccess,
CalOesMarsQueueItem,
CalOesMarsValidation,
Deployment,
DeploymentAccess,
+ Expense,
+ ExpenseInput,
FieldCostAccess,
ResourceUsage,
ResourceUsageInput,
TimeEntry,
TimeReport,
TimeReportIssue,
+ TimeReportResponse,
} from '@/models/v4/operations';
import useAuthStore from '@/stores/auth/store';
import { securityStore } from '@/stores/security/store';
-// One store for the field-operations surface: the deployment list, the open deployment with its
-// daily time reports, resource usage readings and the CAL OES MARS work items that belong to it.
-// Everything shown came from the server's own scoping (a rostered member sees their deployments,
-// a manager sees the department's); this store never decides what the person may see.
+// One store for the field-operations surface: the deployment list, the open deployment with its crew and
+// individual time reports, expenses, resource usage readings and the CAL OES MARS work items that belong to
+// it. Everything shown came from the server's own scoping (a rostered member or the seated crew of a deployed
+// unit sees their deployments, a manager the department's); this store never decides what the person may see.
export interface OperationsState {
identity: string | null;
@@ -48,11 +56,13 @@ export interface OperationsState {
includeClosed: boolean;
deployment: Deployment | null;
reports: TimeReport[];
+ scope: TimeScope | null;
report: TimeReport | null;
entries: TimeEntry[];
dirty: boolean;
issues: TimeReportIssue[];
warnings: TimeReportIssue[];
+ expenses: Expense[];
usage: ResourceUsage[];
marsItems: CalOesMarsQueueItem[];
validation: CalOesMarsValidation | null;
@@ -61,10 +71,17 @@ export interface OperationsState {
loadAccess: () => Promise;
loadDeployments: (includeClosed?: boolean) => Promise;
open: (id: string) => Promise;
+ setScope: (scope: TimeScope | null, dateKey: string) => void;
openReport: (dateKey: string, create: boolean) => Promise;
+ selectReport: (reportId: string) => void;
setEntries: (entries: TimeEntry[]) => void;
save: () => Promise;
submit: () => Promise;
+ sign: (crewBossSigned: boolean, customerSignerName?: string | null) => Promise;
+ approve: (reportId: string) => Promise;
+ loadExpenses: () => Promise;
+ addExpense: (input: Omit) => Promise;
+ removeExpense: (id: string) => Promise;
loadUsage: () => Promise;
addUsage: (input: Omit) => Promise;
loadMars: () => Promise;
@@ -73,23 +90,29 @@ export interface OperationsState {
close: () => void;
}
-const initial = {
- identity: null as string | null,
- access: null,
- costAccess: null,
- marsAccess: null,
- deployments: [],
- includeClosed: false,
+const closed = {
deployment: null,
reports: [],
+ scope: null,
report: null,
entries: [],
dirty: false,
issues: [],
warnings: [],
+ expenses: [],
usage: [],
marsItems: [],
validation: null,
+};
+
+const initial = {
+ ...closed,
+ identity: null as string | null,
+ access: null,
+ costAccess: null,
+ marsAccess: null,
+ deployments: [],
+ includeClosed: false,
busy: false,
error: null,
};
@@ -114,128 +137,182 @@ const currentIdentity = (): string | null => {
return userId && departmentId ? `${userId}:${departmentId}` : null;
};
-export const useOperationsStore = create()((set, get) => ({
- ...initial,
- loadAccess: async () => {
- const identity = currentIdentity();
- if (get().identity !== identity) set({ ...initial, identity });
- await settle(async () => {
- const access = await getDeploymentAccess();
- // Usage and MARS access are optional add-ons; a failure there must not hide the deployments.
- const [costAccess, marsAccess] = await Promise.all([
- operationsCapabilities.recordUsage ? getFieldCostAccess().catch(() => null) : Promise.resolve(null),
- operationsCapabilities.draftF42 ? getCalOesMarsAccess().catch(() => null) : Promise.resolve(null),
- ]);
- set({ access, costAccess, marsAccess });
- });
- },
- loadDeployments: async (includeClosed = get().includeClosed) => {
- await settle(async () => {
- const deployments = await getDeployments(!includeClosed);
- set({ deployments, includeClosed });
- });
- },
- open: async (id) => {
- await settle(async () => {
- const deployment = await getDeployment(id);
- const reports = await getTimeReports(id);
- set({ deployment, reports, report: null, entries: [], dirty: false, issues: [], warnings: [], usage: [], marsItems: [], validation: null });
- });
- },
- openReport: async (dateKey, create) => {
- const deployment = get().deployment;
- if (!deployment) return;
- await settle(async () => {
- let report = reportForDate(get().reports, dateKey);
- if (!report && create && operationsCapabilities.editTime) {
- const response = await newTimeReport(deployment.Id, dateKey);
- report = response.Data;
- set({ reports: [...get().reports, report] });
- }
- set({ report, entries: report ? report.Entries.map((entry) => ({ ...entry })) : [], dirty: false, issues: [], warnings: [] });
- });
- },
- setEntries: (entries) => set({ entries, dirty: true, issues: [] }),
- save: async () => {
- const report = get().report;
- if (!report) return false;
- const local = validateEntries(get().entries);
- if (local.length > 0) {
- set({ issues: local.map((issue) => ({ Code: issue.code, EntryId: String(issue.index) })) });
+const copyEntries = (report: TimeReport | null) => (report ? report.Entries.map((entry) => ({ ...entry })) : []);
+
+export const useOperationsStore = create()((set, get) => {
+ /**
+ * Adopt a report the server just answered with: refresh it in the day list and, when it is open, in the editor.
+ * A validation refusal comes back with the stored report; keep the person's unsaved edits and show the issues.
+ */
+ const adopt = (response: TimeReportResponse) => {
+ const errors = response.Errors ?? [];
+ if (errors.length > 0) {
+ set({ issues: errors, warnings: response.Warnings ?? [] });
return false;
}
- const saved = await settle(async () => {
- const response = await saveTimeEntries(report.Id, get().entries);
- const next = response.Data;
- set({
- report: next,
- entries: next.Entries.map((entry) => ({ ...entry })),
- dirty: false,
- issues: response.Errors ?? [],
- warnings: response.Warnings ?? [],
- reports: get().reports.map((existing) => (existing.Id === next.Id ? next : existing)),
- });
- return (response.Errors ?? []).length === 0;
+ const next = response.Data;
+ const open = get().report?.Id === next?.Id;
+ set({
+ reports: next ? [...get().reports.filter((existing) => existing.Id !== next.Id), next] : get().reports,
+ ...(open || !get().report ? { report: next, entries: copyEntries(next), dirty: false } : {}),
+ issues: [],
+ warnings: response.Warnings ?? [],
});
- return saved === true;
- },
- submit: async () => {
- const report = get().report;
- if (!report || get().dirty) return false;
- const submitted = await settle(async () => {
- const response = await submitTimeReport(report.Id);
- const next = response.Data;
- set({
- report: next,
- entries: next.Entries.map((entry) => ({ ...entry })),
- issues: response.Errors ?? [],
- warnings: response.Warnings ?? [],
- reports: get().reports.map((existing) => (existing.Id === next.Id ? next : existing)),
+ return true;
+ };
+
+ return {
+ ...initial,
+ loadAccess: async () => {
+ const identity = currentIdentity();
+ if (get().identity !== identity) set({ ...initial, identity });
+ await settle(async () => {
+ const access = await getDeploymentAccess();
+ // Usage and MARS access are optional add-ons; a failure there must not hide the deployments.
+ const [costAccess, marsAccess] = await Promise.all([
+ operationsCapabilities.recordUsage ? getFieldCostAccess().catch(() => null) : Promise.resolve(null),
+ operationsCapabilities.draftF42 ? getCalOesMarsAccess().catch(() => null) : Promise.resolve(null),
+ ]);
+ set({ access, costAccess, marsAccess });
});
- return (response.Errors ?? []).length === 0;
- });
- return submitted === true;
- },
- loadUsage: async () => {
- const deployment = get().deployment;
- if (!deployment || !get().costAccess?.Enabled) return;
- await settle(async () => set({ usage: await getResourceUsage(deployment.Id) }));
- },
- addUsage: async (input) => {
- const deployment = get().deployment;
- if (!deployment) return false;
- const added = await settle(async () => {
- const reading = await addResourceUsage({ ...input, DeploymentId: deployment.Id, CallId: deployment.CallId ?? null });
- set({ usage: [reading, ...get().usage] });
- return true;
- });
- return added === true;
- },
- loadMars: async () => {
- const deployment = get().deployment;
- if (!deployment || !get().marsAccess?.Enabled) return;
- await settle(async () => {
- const queue = await getCalOesMarsQueue();
- set({ marsItems: queue.filter((item) => item.DeploymentId === deployment.Id) });
- });
- },
- draftF42: async () => {
- const deployment = get().deployment;
- if (!deployment) return false;
- const built = await settle(async () => {
- await buildF42(deployment.Id);
- set({ marsItems: (await getCalOesMarsQueue()).filter((item) => item.DeploymentId === deployment.Id) });
- return true;
- });
- return built === true;
- },
- validate: async (workItemId) => {
- const validated = await settle(async () => {
- const validation = await validateCalOesMarsWorkItem(workItemId);
- set({ validation });
- return validation.IsReadyForPortal;
- });
- return validated === true;
- },
- close: () => set({ deployment: null, reports: [], report: null, entries: [], dirty: false, issues: [], warnings: [], usage: [], marsItems: [], validation: null, error: null }),
-}));
+ },
+ loadDeployments: async (includeClosed = get().includeClosed) => {
+ await settle(async () => {
+ const deployments = await getDeployments(!includeClosed);
+ set({ deployments, includeClosed });
+ });
+ },
+ open: async (id) => {
+ await settle(async () => {
+ const [deployment, reports] = await Promise.all([getDeployment(id), getTimeReports(id)]);
+ set({ ...closed, deployment, reports });
+ });
+ },
+ setScope: (scope, dateKey) => {
+ const report = reportForScope(get().reports, dateKey, scope);
+ set({ scope, report, entries: copyEntries(report), dirty: false, issues: [], warnings: [] });
+ },
+ openReport: async (dateKey, create) => {
+ const { deployment, scope } = get();
+ if (!deployment) return;
+ let report = reportForScope(get().reports, dateKey, scope);
+ let issues: TimeReportIssue[] = [];
+ let warnings: TimeReportIssue[] = [];
+ if (!report && create && scope && operationsCapabilities.editTime) {
+ const response = await settle(() =>
+ newTimeReport(deployment.Id, dateKey, {
+ deploymentUnitId: scope.kind === 'crew' ? scope.unitId : null,
+ deploymentPersonnelId: scope.kind === 'individual' ? scope.personnelId : null,
+ })
+ );
+ issues = response?.Errors ?? [];
+ warnings = response?.Warnings ?? [];
+ // A refused create answers with its reasons and no report: nothing joins the day list, and the reasons show.
+ if (response?.Data && issues.length === 0) {
+ report = response.Data;
+ set({ reports: [...get().reports, response.Data] });
+ }
+ }
+ // The scope can change while the create is in flight; only the report that still matches it opens.
+ if (scopeKey(get().scope) !== scopeKey(scope)) return;
+ set({ report, entries: copyEntries(report), dirty: false, issues, warnings });
+ },
+ selectReport: (reportId) => {
+ const report = get().reports.find((candidate) => candidate.Id === reportId) ?? null;
+ set({ report, entries: copyEntries(report), dirty: false, issues: [], warnings: [] });
+ },
+ setEntries: (entries) => set({ entries, dirty: true, issues: [] }),
+ save: async () => {
+ const report = get().report;
+ if (!report) return false;
+ const local = validateEntries(get().entries);
+ if (local.length > 0) {
+ set({ issues: local.map((issue) => ({ Code: issue.code, EntryId: String(issue.index) })) });
+ return false;
+ }
+ const saved = await settle(async () => adopt(await saveTimeEntries(report.Id, get().entries)));
+ return saved === true;
+ },
+ submit: async () => {
+ const report = get().report;
+ if (!report || get().dirty) return false;
+ const submitted = await settle(async () => adopt(await submitTimeReport(report.Id)));
+ return submitted === true;
+ },
+ sign: async (crewBossSigned, customerSignerName) => {
+ const report = get().report;
+ if (!report || (!crewBossSigned && !customerSignerName)) return false;
+ const signed = await settle(async () => adopt(await signTimeReport(report.Id, crewBossSigned, customerSignerName)));
+ return signed === true;
+ },
+ approve: async (reportId) => {
+ const approved = await settle(async () => adopt(await approveTimeReport(reportId)));
+ return approved === true;
+ },
+ loadExpenses: async () => {
+ const deployment = get().deployment;
+ if (!deployment || !operationsCapabilities.recordExpenses) return;
+ await settle(async () => set({ expenses: await getExpenses(deployment.Id) }));
+ },
+ addExpense: async (input) => {
+ const deployment = get().deployment;
+ if (!deployment) return false;
+ const added = await settle(async () => {
+ const expense = await saveExpense({ ...input, DeploymentId: deployment.Id });
+ set({ expenses: [expense, ...get().expenses.filter((existing) => existing.Id !== expense.Id)] });
+ return true;
+ });
+ return added === true;
+ },
+ removeExpense: async (id) => {
+ const removed = await settle(async () => {
+ await deleteExpense(id);
+ set({ expenses: get().expenses.filter((expense) => expense.Id !== id) });
+ return true;
+ });
+ return removed === true;
+ },
+ loadUsage: async () => {
+ const deployment = get().deployment;
+ if (!deployment || !get().costAccess?.Enabled) return;
+ await settle(async () => set({ usage: await getResourceUsage(deployment.Id) }));
+ },
+ addUsage: async (input) => {
+ const deployment = get().deployment;
+ if (!deployment) return false;
+ const added = await settle(async () => {
+ const reading = await addResourceUsage({ ...input, DeploymentId: deployment.Id, CallId: deployment.CallId ?? null });
+ set({ usage: [reading, ...get().usage] });
+ return true;
+ });
+ return added === true;
+ },
+ loadMars: async () => {
+ const deployment = get().deployment;
+ if (!deployment || !get().marsAccess?.Enabled) return;
+ await settle(async () => {
+ const queue = await getCalOesMarsQueue();
+ set({ marsItems: queue.filter((item) => item.DeploymentId === deployment.Id) });
+ });
+ },
+ draftF42: async () => {
+ const deployment = get().deployment;
+ if (!deployment) return false;
+ const built = await settle(async () => {
+ await buildF42(deployment.Id);
+ set({ marsItems: (await getCalOesMarsQueue()).filter((item) => item.DeploymentId === deployment.Id) });
+ return true;
+ });
+ return built === true;
+ },
+ validate: async (workItemId) => {
+ const validated = await settle(async () => {
+ const validation = await validateCalOesMarsWorkItem(workItemId);
+ set({ validation });
+ return validation.IsReadyForPortal;
+ });
+ return validated === true;
+ },
+ close: () => set({ ...closed, error: null }),
+ };
+});
diff --git a/src/stores/records/__tests__/store.test.ts b/src/stores/records/__tests__/store.test.ts
index 8b5df6f7..c4c29cf1 100644
--- a/src/stores/records/__tests__/store.test.ts
+++ b/src/stores/records/__tests__/store.test.ts
@@ -137,6 +137,53 @@ describe('Field Records store conformance', () => {
expect(useRecordsStore.getState().pendingDrafts).toEqual({});
});
+ it('sends a protected definition online without ever keeping it on the device', async () => {
+ useRecordsStore.setState({ catalog: { ContractVersion: 'field-catalog.v1', OriginClient: 'Dispatch', Ok: true, Reasons: [], ContextVerified: true, Definitions: [goldenCatalogEntry({ RequiresProtectedGrant: true })], Exclusions: [], ServerTimestampMs: 0 } });
+ const draft = { clientRecordId: 'draft-p', recordId: null, definitionKey: 'shift-log', definitionVersion: 3, name: 'Shift log', values: [], updatedOn: '2026-09-06T00:00:00Z' };
+ recordsApi.createRecordDraft.mockRejectedValueOnce({ response: { status: 500, data: { title: 'Unavailable' } } }).mockResolvedValueOnce({ Data: { RecordId: 'server-p', DefinitionVersion: 3, State: RmsRecordState.Draft, RowVersion: 1 } });
+ fieldApi.syncFieldRecords.mockResolvedValue({ Data: { Ok: true, ResetRequired: false, ScopeStamp: 'scope-1', ServerTimestampMs: 10, Records: [], Tombstones: [], Drafts: [], Assignments: [] } });
+
+ useRecordsStore.getState().stageDraft(draft);
+ const failed = await useRecordsStore.getState().pushDraft('draft-p', draft);
+
+ expect(failed).toMatchObject({ ok: false, error: 'Unavailable' });
+ expect(useRecordsStore.getState().pendingDrafts).toEqual({});
+
+ const sent = await useRecordsStore.getState().pushDraft('draft-p', draft);
+
+ expect(sent).toMatchObject({ ok: true, recordId: 'server-p' });
+ expect(recordsApi.createRecordDraft).toHaveBeenLastCalledWith(expect.objectContaining({ DefinitionKey: 'shift-log', IdempotencyKey: 'draft-p' }));
+ expect(useRecordsStore.getState().pendingDrafts).toEqual({});
+ });
+
+ describe('before the catalog has loaded', () => {
+ const draft = { clientRecordId: 'draft-u', recordId: null, definitionKey: 'shift-log', definitionVersion: 3, name: 'Shift log', values: [], updatedOn: '2026-09-06T00:00:00Z' };
+
+ it('does not stage a draft, since its definition may be one that seals values', () => {
+ useRecordsStore.getState().stageDraft(draft);
+
+ expect(useRecordsStore.getState().pendingDrafts).toEqual({});
+ });
+
+ it('does not keep a failed send that was never staged', async () => {
+ recordsApi.createRecordDraft.mockRejectedValueOnce({ response: { status: 500, data: { title: 'Unavailable' } } });
+
+ const result = await useRecordsStore.getState().pushDraft('draft-u', draft);
+
+ expect(result).toMatchObject({ ok: false, error: 'Unavailable' });
+ expect(useRecordsStore.getState().pendingDrafts).toEqual({});
+ });
+
+ it('keeps a draft staged earlier when its send fails', async () => {
+ useRecordsStore.setState({ pendingDrafts: { 'draft-u': draft } });
+ recordsApi.createRecordDraft.mockRejectedValueOnce({ response: { status: 500, data: { title: 'Unavailable' } } });
+
+ await useRecordsStore.getState().pushAllDrafts();
+
+ expect(useRecordsStore.getState().pendingDrafts['draft-u']).toMatchObject({ clientRecordId: 'draft-u', lastError: 'Unavailable' });
+ });
+ });
+
it('keeps a conflicted draft and never replays it in the batch push', async () => {
useRecordsStore.setState({ catalog: { ContractVersion: 'field-catalog.v1', OriginClient: 'Dispatch', Ok: true, Reasons: [], ContextVerified: true, Definitions: [goldenCatalogEntry()], Exclusions: [], ServerTimestampMs: 0 } });
recordsApi.createRecordDraft.mockRejectedValue({ response: { status: 409, data: { type: 'record_concurrency', title: 'Changed' } } });
diff --git a/src/stores/records/store.ts b/src/stores/records/store.ts
index 1c20ddc4..2a4a3a02 100644
--- a/src/stores/records/store.ts
+++ b/src/stores/records/store.ts
@@ -116,7 +116,8 @@ interface RecordsState {
stageDraft: (draft: PendingRecordDraft) => void;
discardDraft: (clientRecordId: string) => void;
- pushDraft: (clientRecordId: string) => Promise<{ ok: boolean; recordId?: string; conflict?: FieldRecordConflictKind; error?: string }>;
+ /** Sends a staged draft, or `draft` itself when it was not staged (a definition that seals values never is). */
+ pushDraft: (clientRecordId: string, draft?: PendingRecordDraft) => Promise<{ ok: boolean; recordId?: string; conflict?: FieldRecordConflictKind; error?: string }>;
pushAllDrafts: () => Promise;
submitForReview: (recordId: string, rowVersion: number) => Promise<{ ok: boolean; error?: string }>;
finalize: (recordId: string, rowVersion: number, attested: boolean) => Promise<{ ok: boolean; error?: string }>;
@@ -173,6 +174,12 @@ const messageFrom = (error: unknown): string => {
return response?.data?.title ?? (error instanceof Error ? error.message : 'Request failed');
};
+/**
+ * Only a definition the catalog confirms may be authored offline leaves its values on the device. An unknown
+ * one (the catalog is not persisted, so it is often not loaded yet) is refused: it may be one that seals values.
+ */
+const mayKeepOnDevice = (entry: FieldRecordCatalogEntry | null): boolean => canAuthorOffline(entry);
+
export const useRecordsStore = create()(
persist(
(set, get) => ({
@@ -335,10 +342,9 @@ export const useRecordsStore = create()(
},
stageDraft: (draft) => {
- const entry = get().entryFor(draft.definitionKey, draft.definitionVersion);
- if (entry && !canAuthorOffline(entry)) {
- // A definition that seals values never leaves plaintext on the device, so it is not staged.
- logger.info({ message: 'Draft not staged offline: definition requires a live protected-data grant', context: { definitionKey: draft.definitionKey } });
+ if (!mayKeepOnDevice(get().entryFor(draft.definitionKey, draft.definitionVersion))) {
+ // A definition that seals values never leaves plaintext on the device, so one not confirmed safe is not staged.
+ logger.info({ message: 'Draft not staged offline: definition is not confirmed to allow offline authoring', context: { definitionKey: draft.definitionKey } });
return;
}
set({ pendingDrafts: { ...get().pendingDrafts, [draft.clientRecordId]: { ...draft, updatedOn: new Date().toISOString() } } });
@@ -350,8 +356,8 @@ export const useRecordsStore = create()(
set({ pendingDrafts: pending });
},
- pushDraft: async (clientRecordId) => {
- const draft = get().pendingDrafts[clientRecordId];
+ pushDraft: async (clientRecordId, supplied) => {
+ const draft = supplied ?? get().pendingDrafts[clientRecordId];
if (!draft) {
return { ok: false, error: 'not_found' };
}
@@ -384,13 +390,18 @@ export const useRecordsStore = create()(
return { ok: true, recordId };
} catch (error) {
const conflict = conflictFrom(error);
- // Never replayed silently: the draft is kept and flagged so a person decides what happens.
- set({
- pendingDrafts: {
- ...get().pendingDrafts,
- [clientRecordId]: { ...draft, lastError: messageFrom(error), conflict: conflict ?? null },
- },
- });
+ // Never replayed silently: the draft is kept and flagged so a person decides what happens —
+ // unless its definition seals values, which are never left on the device. A draft already staged
+ // passed that check when it was staged, so a catalog that has not loaded yet does not drop it.
+ const entry = get().entryFor(draft.definitionKey, draft.definitionVersion);
+ if (entry ? mayKeepOnDevice(entry) : clientRecordId in get().pendingDrafts) {
+ set({
+ pendingDrafts: {
+ ...get().pendingDrafts,
+ [clientRecordId]: { ...draft, lastError: messageFrom(error), conflict: conflict ?? null },
+ },
+ });
+ }
logger.error({ message: 'Record draft push failed', context: { error, clientRecordId, conflict } });
get().report({ EventType: 'draft_saved', Outcome: conflict ?? 'failed', DefinitionKey: draft.definitionKey, DefinitionVersion: draft.definitionVersion });
if (conflict) {
diff --git a/src/stores/signalr/__tests__/signalr-store.geolocation.test.ts b/src/stores/signalr/__tests__/signalr-store.geolocation.test.ts
new file mode 100644
index 00000000..0aa0a128
--- /dev/null
+++ b/src/stores/signalr/__tests__/signalr-store.geolocation.test.ts
@@ -0,0 +1,327 @@
+import { act } from '@testing-library/react-native';
+
+// Hub events are delivered through the service's event bus; this stand-in keeps real listeners so the tests can
+// raise pushes and lifecycle signals exactly as the service would.
+const mockListeners = new Map void>>();
+const mockEmit = (event: string, ...args: unknown[]) => {
+ Array.from(mockListeners.get(event) ?? []).forEach((listener) => listener(...args));
+};
+
+jest.mock('@/services/signalr.service', () => {
+ const instance = {
+ connectToHubWithEventingUrl: jest.fn(),
+ disconnectFromHub: jest.fn(),
+ invoke: jest.fn(),
+ on: jest.fn((event: string, listener: (...args: unknown[]) => void) => {
+ if (!mockListeners.has(event)) mockListeners.set(event, new Set());
+ mockListeners.get(event)!.add(listener);
+ }),
+ off: jest.fn((event: string, listener: (...args: unknown[]) => void) => {
+ mockListeners.get(event)?.delete(listener);
+ }),
+ isHubConnected: jest.fn(),
+ getTotalEventListenerCount: jest.fn(() => 0),
+ };
+ return {
+ signalRService: instance,
+ SignalRService: {
+ HUB_CONNECTED_EVENT: '__hubConnected',
+ HUB_RECONNECTED_EVENT: '__hubReconnected',
+ HUB_RECONNECTING_EVENT: '__hubReconnecting',
+ HUB_DISCONNECTED_EVENT: '__hubDisconnected',
+ },
+ };
+});
+
+jest.mock('../../app/core-store', () => ({
+ useCoreStore: {
+ getState: () => ({ config: { EventingUrl: 'https://eventing.example.com/' } }),
+ },
+}));
+
+jest.mock('../../security/store', () => ({
+ securityStore: { getState: () => ({ rights: { DepartmentId: '123' } }) },
+ useSecurityStore: { getState: () => ({ rights: { DepartmentId: '123' } }) },
+}));
+
+jest.mock('@/lib/logging', () => ({
+ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
+}));
+
+jest.mock('@/lib/env', () => ({
+ Env: {
+ CHANNEL_HUB_NAME: 'eventingHub',
+ REALTIME_GEO_HUB_NAME: 'geolocationHub',
+ CHAT_HUB_NAME: 'chatHub',
+ },
+}));
+
+jest.mock('@/lib', () => ({
+ useAuthStore: { getState: jest.fn(() => ({ accessToken: 'mock-token' })) },
+}));
+
+import { signalRService } from '@/services/signalr.service';
+
+import { useSignalRStore } from '../signalr-store';
+
+const HUB = 'geolocationHub';
+const mockService = signalRService as unknown as {
+ connectToHubWithEventingUrl: jest.Mock;
+ disconnectFromHub: jest.Mock;
+ invoke: jest.Mock;
+ isHubConnected: jest.Mock;
+};
+
+const joinCalls = () => mockService.invoke.mock.calls.filter(([hub, method]) => hub === HUB && method === 'GeolocationConnect');
+
+/** Lets pending promise continuations (the join invoke and its bookkeeping) run. */
+const flush = async () => {
+ for (let i = 0; i < 5; i += 1) {
+ await Promise.resolve();
+ }
+};
+
+const unitPush = (overrides: Record = {}) => ({ departmentId: 123, unitId: '12', latitude: 39.5, longitude: -119.8, recordId: 'r1', timestamp: '2026-09-25T14:03:11.000Z', ...overrides });
+
+describe('useSignalRStore geolocation hub', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockListeners.clear();
+ // A real start raises the connected lifecycle signal before connectToHubWithEventingUrl resolves.
+ mockService.connectToHubWithEventingUrl.mockImplementation(async (config: { name: string }) => {
+ mockEmit(`__hubConnected:${config.name}`, config.name);
+ });
+ mockService.disconnectFromHub.mockResolvedValue(undefined);
+ mockService.invoke.mockResolvedValue(undefined);
+ mockService.isHubConnected.mockReturnValue(true);
+ useSignalRStore.setState({ isGeolocationHubConnected: false, liveLocations: {}, lastGeolocationMessage: null, lastGeolocationTimestamp: 0, lastGeolocationJoinTimestamp: 0, error: null });
+ });
+
+ afterEach(async () => {
+ await act(async () => {
+ await useSignalRStore.getState().disconnectGeolocationHub();
+ });
+ jest.useRealTimers();
+ });
+
+ const connect = async () => {
+ await act(async () => {
+ await useSignalRStore.getState().connectGeolocationHub();
+ });
+ };
+
+ describe('connect', () => {
+ it('opens the geolocation hub, subscribes to its pushes and joins with zero arguments', async () => {
+ await connect();
+
+ expect(mockService.connectToHubWithEventingUrl).toHaveBeenCalledWith({
+ name: HUB,
+ eventingUrl: 'https://eventing.example.com/',
+ hubName: HUB,
+ methods: expect.arrayContaining(['onUnitLocationUpdated', 'onPersonnelLocationUpdated', 'onGeolocationConnect']),
+ });
+ expect(joinCalls()).toEqual([[HUB, 'GeolocationConnect']]);
+ });
+
+ it('joins explicitly when the service already had the hub open', async () => {
+ mockService.connectToHubWithEventingUrl.mockResolvedValue(undefined);
+
+ await connect();
+
+ expect(joinCalls()).toHaveLength(1);
+ });
+
+ it('reports connected only after the hub acknowledges the join', async () => {
+ await connect();
+ expect(useSignalRStore.getState().isGeolocationHubConnected).toBe(false);
+
+ act(() => mockEmit('onGeolocationConnect', 'connection-1'));
+
+ expect(useSignalRStore.getState().isGeolocationHubConnected).toBe(true);
+ expect(useSignalRStore.getState().lastGeolocationJoinTimestamp).toBeGreaterThan(0);
+ });
+
+ it('does not let a stale connected flag block a repair', async () => {
+ useSignalRStore.setState({ isGeolocationHubConnected: true });
+ mockService.isHubConnected.mockReturnValue(false);
+
+ await connect();
+
+ expect(mockService.connectToHubWithEventingUrl).toHaveBeenCalledTimes(1);
+ expect(joinCalls()).toHaveLength(1);
+ });
+
+ it('skips reconnecting while the flag and the transport agree it is up', async () => {
+ useSignalRStore.setState({ isGeolocationHubConnected: true });
+
+ await connect();
+
+ expect(mockService.connectToHubWithEventingUrl).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('reconnect paths', () => {
+ it('re-joins after the client automatic reconnect', async () => {
+ await connect();
+ act(() => mockEmit('onGeolocationConnect', 'connection-1'));
+
+ act(() => mockEmit(`__hubReconnecting:${HUB}`, HUB));
+ expect(useSignalRStore.getState().isGeolocationHubConnected).toBe(false);
+
+ await act(async () => {
+ mockEmit(`__hubReconnected:${HUB}`, HUB);
+ await flush();
+ });
+
+ expect(joinCalls()).toHaveLength(2);
+ });
+
+ it("re-joins after the service's rebuild following a close (fresh token, new connection)", async () => {
+ await connect();
+ act(() => mockEmit('onGeolocationConnect', 'connection-1'));
+
+ act(() => mockEmit(`__hubDisconnected:${HUB}`, HUB));
+ expect(useSignalRStore.getState().isGeolocationHubConnected).toBe(false);
+
+ await act(async () => {
+ mockEmit(`__hubConnected:${HUB}`, HUB);
+ await flush();
+ });
+
+ expect(joinCalls()).toHaveLength(2);
+ });
+
+ it('retries a failed join and gives up after the retry budget', async () => {
+ jest.useFakeTimers();
+ mockService.invoke.mockRejectedValue(new Error('not connected'));
+
+ await connect();
+ expect(joinCalls()).toHaveLength(1);
+
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await flush();
+ });
+ expect(joinCalls()).toHaveLength(2);
+
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await flush();
+ });
+ expect(joinCalls()).toHaveLength(3);
+
+ await act(async () => {
+ jest.advanceTimersByTime(20000);
+ await flush();
+ });
+ expect(joinCalls()).toHaveLength(3);
+ expect(useSignalRStore.getState().isGeolocationHubConnected).toBe(false);
+ });
+
+ it('gives a repair of a session that gave up a fresh retry budget while the transport stays open', async () => {
+ jest.useFakeTimers();
+ mockService.invoke.mockRejectedValue(new Error('not connected'));
+
+ await connect();
+ for (let retry = 0; retry < 3; retry += 1) {
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await flush();
+ });
+ }
+ expect(joinCalls()).toHaveLength(3);
+
+ // The socket never dropped, so the repair finds the hub already open and no new connection announces itself.
+ mockService.connectToHubWithEventingUrl.mockResolvedValue(undefined);
+ await connect();
+ expect(joinCalls()).toHaveLength(4);
+
+ await act(async () => {
+ jest.advanceTimersByTime(5000);
+ await flush();
+ });
+ expect(joinCalls()).toHaveLength(5);
+ });
+
+ it('does not retry a join for a connection that has since dropped', async () => {
+ jest.useFakeTimers();
+ mockService.invoke.mockRejectedValue(new Error('not connected'));
+
+ await connect();
+ act(() => mockEmit(`__hubDisconnected:${HUB}`, HUB));
+
+ await act(async () => {
+ jest.advanceTimersByTime(20000);
+ await flush();
+ });
+
+ expect(joinCalls()).toHaveLength(1);
+ });
+ });
+
+ describe('location pushes', () => {
+ it('stores unit and personnel positions per pin', async () => {
+ await connect();
+
+ act(() => {
+ mockEmit('onUnitLocationUpdated', unitPush());
+ mockEmit('onPersonnelLocationUpdated', JSON.stringify({ UserId: 'ABC-def', Latitude: 40, Longitude: -120 }));
+ });
+
+ const { liveLocations } = useSignalRStore.getState();
+ expect(liveLocations.u12).toMatchObject({ pinId: 'u12', latitude: 39.5, longitude: -119.8, timestamp: Date.parse('2026-09-25T14:03:11.000Z') });
+ expect(liveLocations['pabc-def']).toMatchObject({ latitude: 40, longitude: -120, timestamp: null });
+ });
+
+ it('keeps every entity from one burst instead of the last message only', async () => {
+ await connect();
+
+ act(() => {
+ mockEmit('onUnitLocationUpdated', unitPush({ unitId: '1' }));
+ mockEmit('onUnitLocationUpdated', unitPush({ unitId: '2' }));
+ mockEmit('onUnitLocationUpdated', unitPush({ unitId: '3' }));
+ });
+
+ expect(Object.keys(useSignalRStore.getState().liveLocations).sort()).toEqual(['u1', 'u2', 'u3']);
+ });
+
+ it('ignores a fix older than the one already applied', async () => {
+ await connect();
+ act(() => mockEmit('onUnitLocationUpdated', unitPush({ latitude: 39.5, timestamp: '2026-09-25T14:03:11.000Z' })));
+ const before = useSignalRStore.getState().liveLocations;
+
+ act(() => mockEmit('onUnitLocationUpdated', unitPush({ latitude: 38, timestamp: '2026-09-25T14:00:00.000Z' })));
+
+ expect(useSignalRStore.getState().liveLocations).toBe(before);
+ expect(useSignalRStore.getState().liveLocations.u12.latitude).toBe(39.5);
+ });
+
+ it('ignores unusable pushes', async () => {
+ await connect();
+
+ act(() => {
+ mockEmit('onUnitLocationUpdated', unitPush({ latitude: 0, longitude: 0 }));
+ mockEmit('onPersonnelLocationUpdated', 'not json');
+ });
+
+ expect(useSignalRStore.getState().liveLocations).toEqual({});
+ });
+
+ it('stops listening after disconnect and forgets positions on clear', async () => {
+ await connect();
+ act(() => mockEmit('onUnitLocationUpdated', unitPush()));
+
+ await act(async () => {
+ await useSignalRStore.getState().disconnectGeolocationHub();
+ });
+ expect(mockService.disconnectFromHub).toHaveBeenCalledWith(HUB);
+ expect(useSignalRStore.getState().isGeolocationHubConnected).toBe(false);
+
+ act(() => mockEmit('onUnitLocationUpdated', unitPush({ unitId: '99' })));
+ expect(useSignalRStore.getState().liveLocations.u99).toBeUndefined();
+
+ act(() => useSignalRStore.getState().clearLiveLocations());
+ expect(useSignalRStore.getState().liveLocations).toEqual({});
+ });
+ });
+});
diff --git a/src/stores/signalr/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts
index 570c2713..a7e0af4f 100644
--- a/src/stores/signalr/__tests__/signalr-store.test.ts
+++ b/src/stores/signalr/__tests__/signalr-store.test.ts
@@ -134,6 +134,8 @@ describe('useSignalRStore', () => {
expect(result.current.lastGeolocationMessage).toBeNull();
expect(result.current.lastUpdateTimestamp).toBe(0);
expect(result.current.lastGeolocationTimestamp).toBe(0);
+ expect(result.current.liveLocations).toEqual({});
+ expect(result.current.lastGeolocationJoinTimestamp).toBe(0);
expect(result.current.error).toBeNull();
});
});
@@ -257,22 +259,26 @@ describe('useSignalRStore', () => {
await result.current.disconnectGeolocationHub();
});
- // Note: The current implementation doesn't actually call disconnectFromHub for geolocation
- // It just sets the state - this is by design as geolocation hub may have different lifecycle
+ expect(signalRService.disconnectFromHub).toHaveBeenCalledWith('geolocationHub');
expect(result.current.isGeolocationHubConnected).toBe(false);
expect(result.current.lastGeolocationMessage).toBeNull();
});
- it('should handle disconnect and set state correctly', async () => {
+ it('should clear the connected flag even when the disconnect fails', async () => {
+ const disconnectError = new Error('Disconnect failed');
+ (signalRService.disconnectFromHub as jest.Mock).mockRejectedValue(disconnectError);
+ act(() => {
+ useSignalRStore.setState({ isGeolocationHubConnected: true });
+ });
+
const { result } = renderHook(() => useSignalRStore());
await act(async () => {
await result.current.disconnectGeolocationHub();
});
- // The geolocation hub disconnect is simpler - just sets state
expect(result.current.isGeolocationHubConnected).toBe(false);
- expect(result.current.lastGeolocationMessage).toBeNull();
+ expect(result.current.error).toEqual(disconnectError);
});
});
});
diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts
index 4f774bf8..135fedb6 100644
--- a/src/stores/signalr/signalr-store.ts
+++ b/src/stores/signalr/signalr-store.ts
@@ -2,6 +2,7 @@ import { create } from 'zustand';
import { useAuthStore } from '@/lib';
import { Env } from '@/lib/env';
+import { type LiveLocation, type LiveLocations, parsePersonnelLocationUpdate, parseUnitLocationUpdate, upsertLiveLocation } from '@/lib/live-locations';
import { logger } from '@/lib/logging';
import { SignalRService, signalRService } from '@/services/signalr.service';
@@ -149,6 +150,43 @@ function armChatSession(options?: { resetAttempts?: boolean }): Promise {
return operation;
}
+/** Client-event method names raised by the geolocation SignalR hub. */
+const GEOLOCATION_HUB_METHODS = ['onUnitLocationUpdated', 'onPersonnelLocationUpdated', 'onGeolocationConnect'];
+
+// Track registered geolocation handlers (hub methods and lifecycle signals) for cleanup.
+const geolocationHubHandlers: Record void) | null> = {};
+
+// Joining the department group on the geolocation hub. The hub only adds a connection to the group in
+// response to `GeolocationConnect`, and membership belongs to the connection id — every new connection
+// (first start, automatic reconnect, or the service's rebuild after a close) has to join again or the
+// socket stays open and the maps silently stop moving.
+const GEOLOCATION_JOIN_RETRY_MS = 5000;
+const GEOLOCATION_JOIN_MAX_ATTEMPTS = 3;
+let geolocationJoinTimer: ReturnType | null = null;
+let geolocationJoinAttempts = 0;
+// Bumped by every new connection and every teardown, so a join still in flight against an old
+// connection completes as a no-op instead of scheduling retries or clearing state it no longer owns.
+let geolocationConnectionGeneration = 0;
+// The join in flight for the current generation, shared by the lifecycle handler and connectGeolocationHub.
+let geolocationJoinOperation: { generation: number; promise: Promise } | null = null;
+
+function stopGeolocationJoinRetry(): void {
+ if (geolocationJoinTimer) {
+ clearTimeout(geolocationJoinTimer);
+ geolocationJoinTimer = null;
+ }
+}
+
+function unregisterGeolocationHubHandlers(): void {
+ Object.keys(geolocationHubHandlers).forEach((event) => {
+ const handler = geolocationHubHandlers[event];
+ if (handler) {
+ signalRService.off(event, handler);
+ geolocationHubHandlers[event] = null;
+ }
+ });
+}
+
export type SignalREventType =
| 'personnelStatusUpdated'
| 'personnelStaffingUpdated'
@@ -174,9 +212,15 @@ interface SignalRState {
lastCheckInUpdateTimestamp: number;
lastWeatherAlertTimestamp: number;
lastIncidentCommandUpdateTimestamp: number;
+ /** True only once the hub acknowledged the department-group join (`onGeolocationConnect`). */
isGeolocationHubConnected: boolean;
- lastGeolocationMessage: unknown;
+ /** The most recent location push that was applied. Diagnostic only: maps read `liveLocations`. */
+ lastGeolocationMessage: LiveLocation | null;
lastGeolocationTimestamp: number;
+ /** Latest realtime position per unit/personnel pin, keyed by lower-cased pin id (`u12`, `p`). */
+ liveLocations: LiveLocations;
+ /** When the geolocation hub last acknowledged a department-group join (epoch ms); 0 before the first. */
+ lastGeolocationJoinTimestamp: number;
isChatHubConnected: boolean;
error: Error | null;
connectUpdateHub: () => Promise;
@@ -184,6 +228,8 @@ interface SignalRState {
reconnectUpdateHub: () => Promise;
connectGeolocationHub: () => Promise;
disconnectGeolocationHub: () => Promise;
+ /** Forgets every live position; used when the signed-in session ends. */
+ clearLiveLocations: () => void;
connectChatHub: () => Promise;
disconnectChatHub: () => Promise;
checkConnectionState: () => boolean;
@@ -341,6 +387,8 @@ export const useSignalRStore = create((set, get) => ({
isGeolocationHubConnected: false,
lastGeolocationMessage: null,
lastGeolocationTimestamp: 0,
+ liveLocations: {},
+ lastGeolocationJoinTimestamp: 0,
isChatHubConnected: false,
error: null,
connectUpdateHub: async () => {
@@ -799,9 +847,16 @@ export const useSignalRStore = create((set, get) => ({
}
},
connectGeolocationHub: async () => {
+ const hubName = Env.REALTIME_GEO_HUB_NAME;
try {
if (get().isGeolocationHubConnected) {
- return;
+ if (signalRService.isHubConnected(hubName)) {
+ return;
+ }
+ // The flag outlived its transport; letting it short-circuit here would block every repair.
+ logger.warn({
+ message: 'Geolocation hub was flagged connected but its connection is down; reconnecting',
+ });
}
set({ isGeolocationHubConnected: false, error: null });
@@ -868,22 +923,158 @@ export const useSignalRStore = create((set, get) => ({
});
}
- // Connect to the geolocation hub (implementation depends on your SignalR service)
- logger.info({ message: 'Geolocation hub connected' });
- set({ isGeolocationHubConnected: true, error: null });
+ // Ensure any previous handlers are cleaned up before registering new ones
+ unregisterGeolocationHubHandlers();
+
+ /**
+ * Join the department group, retrying a few times before giving up. A failed join is silent — the
+ * socket is up but nothing arrives — so the connected flag is cleared for the next repair to see.
+ */
+ const runGeolocationJoin = async (generation: number): Promise => {
+ try {
+ // The hub method takes no arguments; sending any makes the server reject the invocation.
+ await signalRService.invoke(hubName, 'GeolocationConnect');
+ // A completion from a torn-down connection must not touch state or retries.
+ if (generation !== geolocationConnectionGeneration) {
+ return;
+ }
+ stopGeolocationJoinRetry();
+ geolocationJoinAttempts = 0;
+ logger.info({ message: 'Joined geolocation hub department group' });
+ } catch (error) {
+ if (generation !== geolocationConnectionGeneration) {
+ return;
+ }
+ geolocationJoinAttempts += 1;
+ logger.warn({
+ message: 'Failed to join geolocation hub department group',
+ context: { error, attempt: geolocationJoinAttempts, maxAttempts: GEOLOCATION_JOIN_MAX_ATTEMPTS },
+ });
+ set({ isGeolocationHubConnected: false });
+
+ if (geolocationJoinAttempts < GEOLOCATION_JOIN_MAX_ATTEMPTS) {
+ stopGeolocationJoinRetry();
+ geolocationJoinTimer = setTimeout(() => {
+ geolocationJoinTimer = null;
+ void joinGeolocationGroup();
+ }, GEOLOCATION_JOIN_RETRY_MS);
+ } else {
+ logger.error({
+ message: 'Giving up joining the geolocation hub department group; the next connectGeolocationHub will rebuild the session',
+ context: { attempts: geolocationJoinAttempts },
+ });
+ }
+ }
+ };
+
+ const joinGeolocationGroup = (): Promise => {
+ const generation = geolocationConnectionGeneration;
+ // Share the join already running for this connection; one left over from an earlier
+ // generation is a dead announce that must not absorb this connection's join.
+ if (geolocationJoinOperation && geolocationJoinOperation.generation === generation) {
+ return geolocationJoinOperation.promise;
+ }
+ const promise = runGeolocationJoin(generation).finally(() => {
+ if (geolocationJoinOperation?.promise === promise) {
+ geolocationJoinOperation = null;
+ }
+ });
+ geolocationJoinOperation = { generation, promise };
+ return promise;
+ };
+
+ const recordLiveLocation = (kind: 'unit' | 'personnel', location: LiveLocation | null) => {
+ if (!location) {
+ logger.debug({ message: `Ignoring unusable ${kind} location update` });
+ return;
+ }
+ const current = get().liveLocations;
+ const next = upsertLiveLocation(current, location);
+ if (next === current) {
+ logger.debug({ message: `Ignoring out-of-order ${kind} location update`, context: { pinId: location.pinId } });
+ return;
+ }
+ set({ liveLocations: next, lastGeolocationMessage: location, lastGeolocationTimestamp: location.receivedAt });
+ };
+
+ const register = (event: string, handler: (...args: unknown[]) => void) => {
+ geolocationHubHandlers[event] = handler;
+ signalRService.on(event, handler);
+ };
+
+ register('onUnitLocationUpdated', (payload: unknown) => recordLiveLocation('unit', parseUnitLocationUpdate(payload)));
+ register('onPersonnelLocationUpdated', (payload: unknown) => recordLiveLocation('personnel', parsePersonnelLocationUpdate(payload)));
+ register('onGeolocationConnect', (connectionId: unknown) => {
+ logger.info({ message: 'Joined geolocation SignalR hub', context: { connectionId } });
+ set({ isGeolocationHubConnected: true, lastGeolocationJoinTimestamp: Date.now(), error: null });
+ });
+
+ // Any new connection id — the first start, the service's rebuild after a close or on visibility
+ // resume, or the client's automatic reconnect — is outside the group until it joins again.
+ const onNewGeolocationConnection = () => {
+ geolocationConnectionGeneration += 1;
+ stopGeolocationJoinRetry();
+ geolocationJoinAttempts = 0;
+ set({ isGeolocationHubConnected: false });
+ void joinGeolocationGroup();
+ };
+ // While the transport is down nothing is being received; clearing the flag is also what lets a
+ // later connectGeolocationHub repair the session.
+ const onGeolocationConnectionLost = () => {
+ geolocationConnectionGeneration += 1;
+ stopGeolocationJoinRetry();
+ geolocationJoinAttempts = 0;
+ set({ isGeolocationHubConnected: false });
+ };
+ register(`${SignalRService.HUB_CONNECTED_EVENT}:${hubName}`, onNewGeolocationConnection);
+ register(`${SignalRService.HUB_RECONNECTED_EVENT}:${hubName}`, onNewGeolocationConnection);
+ register(`${SignalRService.HUB_RECONNECTING_EVENT}:${hubName}`, onGeolocationConnectionLost);
+ register(`${SignalRService.HUB_DISCONNECTED_EVENT}:${hubName}`, onGeolocationConnectionLost);
+
+ const generationBeforeConnect = geolocationConnectionGeneration;
+
+ await signalRService.connectToHubWithEventingUrl({
+ name: hubName,
+ eventingUrl,
+ hubName,
+ methods: GEOLOCATION_HUB_METHODS,
+ });
+
+ if (geolocationConnectionGeneration === generationBeforeConnect) {
+ // The service already had this hub open, so no new connection announced itself; join explicitly.
+ // It gets a fresh retry budget: after an earlier join gave up, the spent one would allow no retries.
+ stopGeolocationJoinRetry();
+ geolocationJoinAttempts = 0;
+ await joinGeolocationGroup();
+ } else if (geolocationJoinOperation) {
+ // The new connection's lifecycle handler started the join; finish together with it.
+ await geolocationJoinOperation.promise;
+ }
+
+ logger.info({
+ message: 'Geolocation hub handlers registered successfully',
+ context: { listenerCount: signalRService.getTotalEventListenerCount() },
+ });
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error occurred');
logger.error({
message: 'Failed to connect to geolocation hub',
context: { error: err },
});
- set({ error: err });
+ set({ isGeolocationHubConnected: false, error: err });
}
},
disconnectGeolocationHub: async () => {
try {
+ // Invalidate any join still in flight so its completion can't schedule retries after this teardown.
+ geolocationConnectionGeneration += 1;
+ stopGeolocationJoinRetry();
+ geolocationJoinAttempts = 0;
+ unregisterGeolocationHubHandlers();
set({ isGeolocationHubConnected: false, lastGeolocationMessage: null });
- logger.info({ message: 'Geolocation hub disconnected' });
+
+ await signalRService.disconnectFromHub(Env.REALTIME_GEO_HUB_NAME);
+ logger.info({ message: 'Geolocation hub disconnected and handlers cleaned up' });
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error occurred');
logger.error({
@@ -893,6 +1084,9 @@ export const useSignalRStore = create((set, get) => ({
set({ error: err });
}
},
+ clearLiveLocations: () => {
+ set({ liveLocations: {}, lastGeolocationMessage: null, lastGeolocationTimestamp: 0, lastGeolocationJoinTimestamp: 0 });
+ },
connectChatHub: async () => {
try {
// Guard here so every call path (init, app-resume reconnect) honors the flag.
diff --git a/src/translations/ar.json b/src/translations/ar.json
index 0d4d8c92..c112eff5 100644
--- a/src/translations/ar.json
+++ b/src/translations/ar.json
@@ -163,6 +163,12 @@
"no_location_for_routing": "لا توجد بيانات موقع متاحة للتوجيه",
"no_protocols": "لا توجد بروتوكولات مرفقة بهذه المكالمة",
"no_timeline": "لا توجد أحداث زمنية متاحة",
+ "activity_link": {
+ "auto_linked": "مرتبط تلقائيًا",
+ "auto_linked_hint": "أُرسلت دون مكالمة؛ ربطها Resgrid بالمكالمة من الحالة السابقة أو من الإرسال.",
+ "inferred": "مُستنتَج",
+ "inferred_hint": "حدّدتها دون وجهة وحدةٌ أو شخصٌ مُرسَل إلى هذه المكالمة أثناء العمل عليها."
+ },
"not_available": "غير متوفر",
"not_found": "لم يتم العثور على المكالمة",
"missing_call_id": "معرّف المكالمة مفقود",
@@ -310,6 +316,7 @@
"selected": "مختار",
"title": "المكالمات",
"type": "النوع",
+ "udf_suggestions": "اقتراحات",
"units": "الوحدات",
"users": "المستخدمين",
"viewNotes": "ملاحظات",
@@ -678,7 +685,13 @@
"empty": "لم يتم إرفاق أي ملفات بهذه الجهة.",
"share_unavailable": "المشاركة غير متاحة على هذا الجهاز.",
"download_failed": "تعذر تنزيل الملف."
- }
+ },
+ "withheld": "تم إخفاء {{count}} من التفاصيل المحمية.",
+ "categoryLabel": "الفئة: {{name}}",
+ "physicalAddress": "العنوان الفعلي",
+ "mailingAddress": "العنوان البريدي",
+ "hasGeofence": "لهذا الموقع سياج جغرافي مرسوم على الخريطة.",
+ "customFields": "حقول مخصصة"
},
"dispatch": {
"active_calls": "المكالمات النشطة",
@@ -1141,7 +1154,15 @@
"disabled": "هذه الميزة معطّلة لقسمك.",
"locked": "افتح البيانات المحمية للمتابعة.",
"retry": "تعذر الوصول إلى الخادم. حاول مرة أخرى.",
- "validation": "رفض الخادم التغيير."
+ "validation": "رفض الخادم التغيير.",
+ "timereports_subject_covered": "لهذا الطاقم أو الشخص وقت مسجل بالفعل في تقرير آخر لهذا اليوم.",
+ "timereports_date_exists": "يوجد بالفعل تقرير لهذا الطاقم أو الشخص في ذلك اليوم.",
+ "timereports_scope_invalid": "هذا الطاقم أو الشخص غير نشط في عملية النشر هذه.",
+ "timereports_locked": "لم يعد بالإمكان تغيير هذا التقرير.",
+ "timereports_time_invalid": "أدخل الأوقات بصيغة HH:mm.",
+ "deployments_closed": "عملية النشر هذه مغلقة.",
+ "expenses_receipt_invalid": "تعذرت قراءة الإيصال.",
+ "deployments_attachment_too_large": "الإيصال كبير جدًا."
},
"financeMode": {
"0": "تشغيلي",
@@ -1152,7 +1173,15 @@
"end_before_start": "يجب أن يكون وقت الانتهاء بعد وقت البدء.",
"missing_subject": "اختر لمن أو لماذا هذا الإدخال.",
"missing_time": "أدخل وقت البدء والانتهاء.",
- "overlap": "يتداخل هذا الإدخال مع إدخال آخر لنفس الشخص أو الوحدة."
+ "overlap": "يتداخل هذا الإدخال مع إدخال آخر لنفس الشخص أو الوحدة.",
+ "subject_outside_report_scope": "هذا القيد يخص شخصًا خارج طاقم هذا التقرير أو الشخص المعني.",
+ "subject_on_other_report": "لهذا الشخص أو الوحدة وقت مسجل بالفعل في تقرير آخر لنفس اليوم.",
+ "subject_not_on_roster": "هذا القيد يخص شخصًا غير موجود في القائمة.",
+ "entries_overlap": "يتداخل قيدان للشخص أو الوحدة نفسها.",
+ "no_entries": "أضف قيدًا واحدًا على الأقل.",
+ "break_rule": "لا توجد استراحة مسجلة لمناوبة طويلة.",
+ "long_travel": "وقت السفر أكثر من المسموح في يوم واحد ويحتاج إلى اعتماد.",
+ "outside_report_date": "هذا القيد يقع خارج تاريخ التقرير."
},
"mars": {
"counts": "{{errors}} أخطاء · {{warnings}} تحذيرات",
@@ -1201,7 +1230,7 @@
"noReport": "لا يوجد تقرير وقت لهذا اليوم.",
"notes": "ملاحظات",
"remove": "إزالة",
- "report": "تقرير رقم {{number}}",
+ "report": "التقرير #{{number}}",
"save": "حفظ الإدخالات",
"start": "بدء تقرير الوقت",
"subject": "من أو ماذا",
@@ -1210,7 +1239,19 @@
"to": "إلى",
"total": "الإجمالي {{hours}} س",
"type": "نوع الإدخال",
- "unpaidBreak": "استراحة غير مدفوعة (دقيقة)"
+ "unpaidBreak": "استراحة غير مدفوعة (دقيقة)",
+ "crewReport": "تقرير وقت الطاقم #{{number}} · {{name}}",
+ "individualReport": "تقرير الوقت #{{number}} · {{name}}",
+ "noCrewReport": "لا يوجد تقرير وقت طاقم لـ {{name}} في هذا اليوم.",
+ "coveredBy": "هذا الوقت مسجل بالفعل في التقرير #{{number}} لهذا اليوم.",
+ "startCrew": "بدء تقرير وقت الطاقم",
+ "readOnly": "يمكنك عرض هذا التقرير لكن لا يمكنك تغييره.",
+ "nextDay": "ينتهي في اليوم التالي",
+ "applyToCrew": "تطبيق على الطاقم",
+ "approve": "اعتماد التقرير",
+ "dayReports": "تقارير هذا اليوم",
+ "awaitingApproval": "بانتظار الاعتماد ({{count}})",
+ "notOnRoster": "أنت لست ضمن قائمة أفراد أو طاقم عملية النشر هذه."
},
"title": "عمليات النشر",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "عداد المسافة الابتدائي",
"title": "الاستخدام",
"unit": "الوحدة"
+ },
+ "crewOf": "طاقم {{names}}",
+ "scope": {
+ "deployment": "عملية النشر بالكامل",
+ "mine": "وقتي",
+ "crew": "الطاقم: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "التوقيعات",
+ "crewBossSigned": "وقّع قائد الطاقم {{when}}",
+ "crewBossPending": "لم يوقّع قائد الطاقم بعد.",
+ "customerSigned": "موقّع الجهة: {{name}}",
+ "customerPending": "لم يُسجل توقيع الجهة.",
+ "asCrewBoss": "التوقيع كقائد للطاقم",
+ "customerName": "اسم موقّع الجهة",
+ "recordCustomer": "تسجيل توقيع الجهة"
+ },
+ "expenses": {
+ "title": "المصروفات",
+ "type": {
+ "0": "بدل الوجبات اليومي",
+ "1": "السكن",
+ "2": "سكن خاص",
+ "3": "العبّارة",
+ "4": "الوقود",
+ "5": "إعادة تزويد المستلزمات",
+ "6": "أخرى"
+ },
+ "typeLabel": "نوع المصروف",
+ "amount": "المبلغ ({{currency}})",
+ "description": "الوصف",
+ "city": "المدينة",
+ "takePhoto": "التقاط صورة",
+ "choosePhoto": "اختيار صورة",
+ "receiptAttached": "تم إرفاق صورة الإيصال.",
+ "save": "إضافة مصروف",
+ "add": "مصروف جديد",
+ "noneToday": "لا توجد مصروفات لهذا اليوم.",
+ "otherDays": "أيام أخرى",
+ "hasReceipt": "إيصال",
+ "remove": "إزالة",
+ "removeTitle": "إزالة المصروف؟",
+ "removeBody": "سيؤدي هذا إلى إزالة المصروف وإيصاله من عملية النشر.",
+ "cancel": "إلغاء",
+ "saved": "تم حفظ المصروف.",
+ "amountInvalid": "أدخل مبلغًا مثل 42.50.",
+ "photoDenied": "اسمح بالوصول إلى الكاميرا أو الصور لإرفاق إيصال."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "المدخل",
"alert_notes": "ملاحظات التنبيه",
"files": "ملفات الموقع"
+ },
+ "notifications": {
+ "detail_title": "إشعار",
+ "additional_info": "معلومات إضافية",
+ "view_call": "عرض المكالمة",
+ "view_chat": "عرض المحادثة",
+ "open_reference": "فتح"
}
}
diff --git a/src/translations/de.json b/src/translations/de.json
index 8e18caf0..9e60f0c6 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -164,6 +164,12 @@
"no_location_for_routing": "Keine Standortdaten für die Routenführung verfügbar",
"no_protocols": "Keine Protokolle diesem Einsatz zugeordnet",
"no_timeline": "Keine Zeitverlaufsereignisse verfügbar",
+ "activity_link": {
+ "auto_linked": "automatisch verknüpft",
+ "auto_linked_hint": "Ohne Einsatz gesendet; Resgrid hat den Status anhand des vorherigen Status oder der Disposition verknüpft.",
+ "inferred": "abgeleitet",
+ "inferred_hint": "Ohne Ziel gesetzt von einer zu diesem Einsatz disponierten Einheit oder Person während der Bearbeitung."
+ },
"not_available": "k. A.",
"not_found": "Einsatz nicht gefunden",
"missing_call_id": "Einsatz-ID fehlt",
@@ -310,6 +316,7 @@
"selected": "ausgewählt",
"title": "Einsätze",
"type": "Typ",
+ "udf_suggestions": "Vorschläge",
"units": "Einheiten",
"users": "Benutzer",
"viewNotes": "Notizen",
@@ -678,7 +685,13 @@
"empty": "An diesen Kontakt wurden keine Dateien angehängt.",
"share_unavailable": "Teilen ist auf diesem Gerät nicht verfügbar.",
"download_failed": "Die Datei konnte nicht heruntergeladen werden."
- }
+ },
+ "withheld": "{{count}} geschützte Angaben sind ausgeblendet.",
+ "categoryLabel": "Kategorie: {{name}}",
+ "physicalAddress": "Anschrift",
+ "mailingAddress": "Postanschrift",
+ "hasGeofence": "Für diesen Ort ist ein Geofence hinterlegt.",
+ "customFields": "Benutzerdefinierte Felder"
},
"dispatch": {
"active_calls": "Aktive Einsätze",
@@ -1141,7 +1154,15 @@
"disabled": "Diese Funktion ist für Ihre Abteilung deaktiviert.",
"locked": "Geschützte Daten entsperren, um fortzufahren.",
"retry": "Server nicht erreichbar. Bitte erneut versuchen.",
- "validation": "Der Server hat die Änderung abgelehnt."
+ "validation": "Der Server hat die Änderung abgelehnt.",
+ "timereports_subject_covered": "Für diese Besatzung oder Person ist an diesem Tag bereits Zeit in einem anderen Bericht erfasst.",
+ "timereports_date_exists": "Für diese Besatzung oder Person gibt es an diesem Tag bereits einen Bericht.",
+ "timereports_scope_invalid": "Diese Besatzung oder Person ist in diesem Einsatz nicht aktiv.",
+ "timereports_locked": "Dieser Bericht kann nicht mehr geändert werden.",
+ "timereports_time_invalid": "Geben Sie Zeiten als HH:mm ein.",
+ "deployments_closed": "Dieser Einsatz ist abgeschlossen.",
+ "expenses_receipt_invalid": "Der Beleg konnte nicht gelesen werden.",
+ "deployments_attachment_too_large": "Der Beleg ist zu groß."
},
"financeMode": {
"0": "Operativ",
@@ -1152,7 +1173,15 @@
"end_before_start": "Die Endzeit muss nach der Startzeit liegen.",
"missing_subject": "Wählen Sie, für wen oder was dieser Eintrag gilt.",
"missing_time": "Start- und Endzeit eingeben.",
- "overlap": "Dieser Eintrag überschneidet sich mit einem anderen für dieselbe Person oder Einheit."
+ "overlap": "Dieser Eintrag überschneidet sich mit einem anderen für dieselbe Person oder Einheit.",
+ "subject_outside_report_scope": "Dieser Eintrag betrifft jemanden außerhalb der Besatzung oder Person dieses Berichts.",
+ "subject_on_other_report": "Für diese Person oder Einheit ist am selben Tag bereits Zeit in einem anderen Bericht erfasst.",
+ "subject_not_on_roster": "Dieser Eintrag betrifft jemanden, der nicht in der Besetzung steht.",
+ "entries_overlap": "Zwei Einträge derselben Person oder Einheit überschneiden sich.",
+ "no_entries": "Fügen Sie mindestens einen Eintrag hinzu.",
+ "break_rule": "Für eine lange Schicht ist keine Pause erfasst.",
+ "long_travel": "Mehr Reisezeit als an einem Tag erlaubt; sie muss genehmigt werden.",
+ "outside_report_date": "Dieser Eintrag liegt außerhalb des Berichtsdatums."
},
"mars": {
"counts": "{{errors}} Fehler · {{warnings}} Warnungen",
@@ -1201,7 +1230,7 @@
"noReport": "Kein Zeitbericht für diesen Tag.",
"notes": "Notizen",
"remove": "Entfernen",
- "report": "Bericht Nr. {{number}}",
+ "report": "Bericht #{{number}}",
"save": "Einträge speichern",
"start": "Zeitbericht beginnen",
"subject": "Wer oder was",
@@ -1210,7 +1239,19 @@
"to": "Bis",
"total": "Gesamt {{hours}} h",
"type": "Eintragsart",
- "unpaidBreak": "Unbezahlte Pause (Min.)"
+ "unpaidBreak": "Unbezahlte Pause (Min.)",
+ "crewReport": "Besatzungszeitbericht #{{number}} · {{name}}",
+ "individualReport": "Zeitbericht #{{number}} · {{name}}",
+ "noCrewReport": "Kein Besatzungszeitbericht für {{name}} an diesem Tag.",
+ "coveredBy": "Diese Zeit steht an diesem Tag bereits in Bericht #{{number}}.",
+ "startCrew": "Besatzungszeitbericht beginnen",
+ "readOnly": "Sie können diesen Bericht ansehen, aber nicht ändern.",
+ "nextDay": "Endet am nächsten Tag",
+ "applyToCrew": "Auf Besatzung übertragen",
+ "approve": "Bericht genehmigen",
+ "dayReports": "Berichte an diesem Tag",
+ "awaitingApproval": "Wartet auf Genehmigung ({{count}})",
+ "notOnRoster": "Sie stehen nicht in der Besetzung oder Besatzung dieses Einsatzes."
},
"title": "Einsätze",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Kilometerstand Start",
"title": "Nutzung",
"unit": "Einheit"
+ },
+ "crewOf": "Besatzung von {{names}}",
+ "scope": {
+ "deployment": "Gesamter Einsatz",
+ "mine": "Meine Zeit",
+ "crew": "Besatzung: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Unterschriften",
+ "crewBossSigned": "Vom Besatzungsführer unterschrieben {{when}}",
+ "crewBossPending": "Noch nicht vom Besatzungsführer unterschrieben.",
+ "customerSigned": "Unterzeichner der Behörde: {{name}}",
+ "customerPending": "Keine Unterschrift der Behörde erfasst.",
+ "asCrewBoss": "Als Besatzungsführer unterschreiben",
+ "customerName": "Name des Unterzeichners der Behörde",
+ "recordCustomer": "Unterschrift der Behörde erfassen"
+ },
+ "expenses": {
+ "title": "Ausgaben",
+ "type": {
+ "0": "Verpflegungspauschale",
+ "1": "Unterkunft",
+ "2": "Private Unterkunft",
+ "3": "Fähre",
+ "4": "Kraftstoff",
+ "5": "Materialnachschub",
+ "6": "Sonstiges"
+ },
+ "typeLabel": "Ausgabenart",
+ "amount": "Betrag ({{currency}})",
+ "description": "Beschreibung",
+ "city": "Ort",
+ "takePhoto": "Foto aufnehmen",
+ "choosePhoto": "Foto auswählen",
+ "receiptAttached": "Belegfoto angehängt.",
+ "save": "Ausgabe hinzufügen",
+ "add": "Neue Ausgabe",
+ "noneToday": "Keine Ausgaben an diesem Tag.",
+ "otherDays": "Andere Tage",
+ "hasReceipt": "Beleg",
+ "remove": "Entfernen",
+ "removeTitle": "Ausgabe entfernen?",
+ "removeBody": "Die Ausgabe und ihr Beleg werden aus dem Einsatz entfernt.",
+ "cancel": "Abbrechen",
+ "saved": "Ausgabe gespeichert.",
+ "amountInvalid": "Geben Sie einen Betrag wie 42.50 ein.",
+ "photoDenied": "Erlauben Sie den Zugriff auf Kamera oder Fotos, um einen Beleg anzuhängen."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Zugang",
"alert_notes": "Warnhinweise",
"files": "Objektdateien"
+ },
+ "notifications": {
+ "detail_title": "Benachrichtigung",
+ "additional_info": "Zusätzliche Informationen",
+ "view_call": "Einsatz anzeigen",
+ "view_chat": "Chat anzeigen",
+ "open_reference": "Öffnen"
}
}
diff --git a/src/translations/el.json b/src/translations/el.json
index 7c22abca..55a8e32b 100644
--- a/src/translations/el.json
+++ b/src/translations/el.json
@@ -163,6 +163,12 @@
"no_location_for_routing": "Δεν υπάρχουν διαθέσιμα δεδομένα τοποθεσίας για δρομολόγηση",
"no_protocols": "Δεν έχουν επισυναφθεί πρωτόκολλα σε αυτή την κλήση",
"no_timeline": "Δεν υπάρχουν διαθέσιμα συμβάντα χρονολογίου",
+ "activity_link": {
+ "auto_linked": "αυτόματη σύνδεση",
+ "auto_linked_hint": "Στάλθηκε χωρίς κλήση· το Resgrid τη συνέδεσε από την προηγούμενη κατάσταση ή την αποστολή.",
+ "inferred": "συναγόμενο",
+ "inferred_hint": "Ορίστηκε χωρίς προορισμό από μονάδα ή άτομο που είχε σταλεί σε αυτή την κλήση, ενώ εργαζόταν σε αυτή."
+ },
"not_available": "Μ/Δ",
"not_found": "Η κλήση δεν βρέθηκε",
"missing_call_id": "Λείπει το αναγνωριστικό κλήσης",
@@ -310,6 +316,7 @@
"selected": "επιλεγμένα",
"title": "Κλήσεις",
"type": "Τύπος",
+ "udf_suggestions": "Προτάσεις",
"units": "Μονάδες",
"users": "Χρήστες",
"viewNotes": "Σημειώσεις",
@@ -678,7 +685,13 @@
"empty": "Δεν έχουν επισυναφθεί αρχεία σε αυτή την επαφή.",
"share_unavailable": "Η κοινοποίηση δεν είναι διαθέσιμη σε αυτή τη συσκευή.",
"download_failed": "Δεν ήταν δυνατή η λήψη του αρχείου."
- }
+ },
+ "withheld": "{{count}} προστατευμένα στοιχεία είναι κρυφά.",
+ "categoryLabel": "Κατηγορία: {{name}}",
+ "physicalAddress": "Φυσική διεύθυνση",
+ "mailingAddress": "Ταχυδρομική διεύθυνση",
+ "hasGeofence": "Αυτή η τοποθεσία έχει χαρτογραφημένη γεωπεριοχή.",
+ "customFields": "Προσαρμοσμένα πεδία"
},
"dispatch": {
"active_calls": "Ενεργές Κλήσεις",
@@ -1141,7 +1154,15 @@
"disabled": "Αυτή η λειτουργία είναι απενεργοποιημένη για το τμήμα σας.",
"locked": "Ξεκλειδώστε τα προστατευμένα δεδομένα για να συνεχίσετε.",
"retry": "Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Δοκιμάστε ξανά.",
- "validation": "Ο διακομιστής απέρριψε την αλλαγή."
+ "validation": "Ο διακομιστής απέρριψε την αλλαγή.",
+ "timereports_subject_covered": "Αυτό το πλήρωμα ή πρόσωπο έχει ήδη χρόνο σε άλλη αναφορά για αυτή την ημέρα.",
+ "timereports_date_exists": "Υπάρχει ήδη αναφορά για αυτό το πλήρωμα ή πρόσωπο εκείνη την ημέρα.",
+ "timereports_scope_invalid": "Αυτό το πλήρωμα ή πρόσωπο δεν είναι ενεργό σε αυτή την ανάπτυξη.",
+ "timereports_locked": "Αυτή η αναφορά δεν μπορεί πλέον να αλλάξει.",
+ "timereports_time_invalid": "Εισαγάγετε ώρες ως HH:mm.",
+ "deployments_closed": "Αυτή η ανάπτυξη έχει κλείσει.",
+ "expenses_receipt_invalid": "Η απόδειξη δεν μπόρεσε να διαβαστεί.",
+ "deployments_attachment_too_large": "Η απόδειξη είναι πολύ μεγάλη."
},
"financeMode": {
"0": "Επιχειρησιακό",
@@ -1152,7 +1173,15 @@
"end_before_start": "Η ώρα λήξης πρέπει να είναι μετά την ώρα έναρξης.",
"missing_subject": "Επιλέξτε σε ποιον ή σε τι αφορά αυτή η καταχώριση.",
"missing_time": "Εισαγάγετε ώρα έναρξης και λήξης.",
- "overlap": "Αυτή η καταχώριση επικαλύπτει άλλη για το ίδιο άτομο ή μονάδα."
+ "overlap": "Αυτή η καταχώριση επικαλύπτει άλλη για το ίδιο άτομο ή μονάδα.",
+ "subject_outside_report_scope": "Αυτή η καταχώριση αφορά κάποιον εκτός του πληρώματος ή του προσώπου αυτής της αναφοράς.",
+ "subject_on_other_report": "Αυτό το πρόσωπο ή η μονάδα έχει ήδη χρόνο σε άλλη αναφορά την ίδια ημέρα.",
+ "subject_not_on_roster": "Αυτή η καταχώριση αφορά κάποιον που δεν είναι στη σύνθεση.",
+ "entries_overlap": "Δύο καταχωρίσεις του ίδιου προσώπου ή μονάδας επικαλύπτονται.",
+ "no_entries": "Προσθέστε τουλάχιστον μία καταχώριση.",
+ "break_rule": "Μια μεγάλη βάρδια δεν έχει καταγεγραμμένο διάλειμμα.",
+ "long_travel": "Περισσότερος χρόνος μετακίνησης από όσο επιτρέπεται σε μία ημέρα· χρειάζεται έγκριση.",
+ "outside_report_date": "Αυτή η καταχώριση είναι εκτός της ημερομηνίας της αναφοράς."
},
"mars": {
"counts": "{{errors}} σφάλματα · {{warnings}} προειδοποιήσεις",
@@ -1201,7 +1230,7 @@
"noReport": "Δεν υπάρχει αναφορά χρόνου για αυτήν την ημέρα.",
"notes": "Σημειώσεις",
"remove": "Αφαίρεση",
- "report": "Αναφορά αρ. {{number}}",
+ "report": "Αναφορά #{{number}}",
"save": "Αποθήκευση καταχωρίσεων",
"start": "Έναρξη αναφοράς χρόνου",
"subject": "Ποιος ή τι",
@@ -1210,7 +1239,19 @@
"to": "Έως",
"total": "Σύνολο {{hours}} ώ",
"type": "Τύπος καταχώρισης",
- "unpaidBreak": "Μη αμειβόμενο διάλειμμα (λεπτά)"
+ "unpaidBreak": "Μη αμειβόμενο διάλειμμα (λεπτά)",
+ "crewReport": "Αναφορά χρόνου πληρώματος #{{number}} · {{name}}",
+ "individualReport": "Αναφορά χρόνου #{{number}} · {{name}}",
+ "noCrewReport": "Δεν υπάρχει αναφορά χρόνου πληρώματος για {{name}} αυτή την ημέρα.",
+ "coveredBy": "Αυτός ο χρόνος υπάρχει ήδη στην αναφορά #{{number}} για αυτή την ημέρα.",
+ "startCrew": "Έναρξη αναφοράς χρόνου πληρώματος",
+ "readOnly": "Μπορείτε να δείτε αυτή την αναφορά αλλά όχι να την αλλάξετε.",
+ "nextDay": "Λήγει την επόμενη ημέρα",
+ "applyToCrew": "Εφαρμογή στο πλήρωμα",
+ "approve": "Έγκριση αναφοράς",
+ "dayReports": "Αναφορές αυτής της ημέρας",
+ "awaitingApproval": "Αναμένει έγκριση ({{count}})",
+ "notOnRoster": "Δεν είστε στη σύνθεση ή στο πλήρωμα αυτής της ανάπτυξης."
},
"title": "Αποστολές",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Χιλιομετρητής έναρξης",
"title": "Χρήση",
"unit": "Μονάδα"
+ },
+ "crewOf": "Πλήρωμα του {{names}}",
+ "scope": {
+ "deployment": "Ολόκληρη η ανάπτυξη",
+ "mine": "Ο χρόνος μου",
+ "crew": "Πλήρωμα: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Υπογραφές",
+ "crewBossSigned": "Υπογράφηκε από τον επικεφαλής πληρώματος {{when}}",
+ "crewBossPending": "Δεν έχει υπογραφεί ακόμη από τον επικεφαλής πληρώματος.",
+ "customerSigned": "Υπογράφων φορέα: {{name}}",
+ "customerPending": "Δεν έχει καταγραφεί υπογραφή φορέα.",
+ "asCrewBoss": "Υπογραφή ως επικεφαλής πληρώματος",
+ "customerName": "Όνομα υπογράφοντος φορέα",
+ "recordCustomer": "Καταγραφή υπογραφής φορέα"
+ },
+ "expenses": {
+ "title": "Έξοδα",
+ "type": {
+ "0": "Ημερήσια αποζημίωση γευμάτων",
+ "1": "Διαμονή",
+ "2": "Ιδιωτική διαμονή",
+ "3": "Πορθμείο",
+ "4": "Καύσιμα",
+ "5": "Αναπλήρωση υλικών",
+ "6": "Άλλο"
+ },
+ "typeLabel": "Τύπος εξόδου",
+ "amount": "Ποσό ({{currency}})",
+ "description": "Περιγραφή",
+ "city": "Πόλη",
+ "takePhoto": "Λήψη φωτογραφίας",
+ "choosePhoto": "Επιλογή φωτογραφίας",
+ "receiptAttached": "Επισυνάφθηκε φωτογραφία απόδειξης.",
+ "save": "Προσθήκη εξόδου",
+ "add": "Νέο έξοδο",
+ "noneToday": "Δεν υπάρχουν έξοδα για αυτή την ημέρα.",
+ "otherDays": "Άλλες ημέρες",
+ "hasReceipt": "απόδειξη",
+ "remove": "Αφαίρεση",
+ "removeTitle": "Αφαίρεση εξόδου;",
+ "removeBody": "Αυτό αφαιρεί το έξοδο και την απόδειξή του από την ανάπτυξη.",
+ "cancel": "Άκυρο",
+ "saved": "Το έξοδο αποθηκεύτηκε.",
+ "amountInvalid": "Εισαγάγετε ένα ποσό όπως 42.50.",
+ "photoDenied": "Επιτρέψτε την πρόσβαση στην κάμερα ή στις φωτογραφίες για να επισυνάψετε απόδειξη."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Είσοδος",
"alert_notes": "Σημειώσεις ειδοποίησης",
"files": "Αρχεία χώρου"
+ },
+ "notifications": {
+ "detail_title": "Ειδοποίηση",
+ "additional_info": "Πρόσθετες Πληροφορίες",
+ "view_call": "Προβολή κλήσης",
+ "view_chat": "Προβολή συνομιλίας",
+ "open_reference": "Άνοιγμα"
}
}
diff --git a/src/translations/en.json b/src/translations/en.json
index dddabefe..32f5d965 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -163,6 +163,12 @@
"no_location_for_routing": "No location data available for routing",
"no_protocols": "No protocols attached to this call",
"no_timeline": "No timeline events available",
+ "activity_link": {
+ "auto_linked": "auto-linked",
+ "auto_linked_hint": "Sent without a call; Resgrid linked it from the previous status or the dispatch.",
+ "inferred": "inferred",
+ "inferred_hint": "Set with no destination by a unit or person dispatched to this call while working it."
+ },
"not_available": "N/A",
"not_found": "Call not found",
"missing_call_id": "Call ID is missing",
@@ -310,6 +316,7 @@
"selected": "selected",
"title": "Calls",
"type": "Type",
+ "udf_suggestions": "Suggestions",
"units": "Units",
"users": "Users",
"viewNotes": "Notes",
@@ -678,7 +685,13 @@
"empty": "No files have been attached to this contact.",
"share_unavailable": "Sharing is not available on this device.",
"download_failed": "The file could not be downloaded."
- }
+ },
+ "withheld": "{{count}} protected details are hidden.",
+ "categoryLabel": "Category: {{name}}",
+ "physicalAddress": "Physical address",
+ "mailingAddress": "Mailing address",
+ "hasGeofence": "This location has a mapped geofence.",
+ "customFields": "Custom fields"
},
"dispatch": {
"active_calls": "Active Calls",
@@ -1141,7 +1154,15 @@
"disabled": "This feature is turned off for your department.",
"locked": "Unlock protected data to continue.",
"retry": "Could not reach the server. Try again.",
- "validation": "The server rejected the change."
+ "validation": "The server rejected the change.",
+ "timereports_subject_covered": "That crew or person already has time on another report for this day.",
+ "timereports_date_exists": "A report for this crew or person already exists for that day.",
+ "timereports_scope_invalid": "That crew or person is not active on this deployment.",
+ "timereports_locked": "This report can no longer be changed.",
+ "timereports_time_invalid": "Enter times as HH:mm.",
+ "deployments_closed": "This deployment is closed.",
+ "expenses_receipt_invalid": "The receipt could not be read.",
+ "deployments_attachment_too_large": "The receipt is too large."
},
"financeMode": {
"0": "Operational",
@@ -1152,7 +1173,15 @@
"end_before_start": "End time must be after the start time.",
"missing_subject": "Pick who or what this entry is for.",
"missing_time": "Enter a start and end time.",
- "overlap": "This entry overlaps another for the same person or unit."
+ "overlap": "This entry overlaps another for the same person or unit.",
+ "subject_outside_report_scope": "This entry is for someone outside this report's crew or person.",
+ "subject_on_other_report": "This person or unit already has time on another report for the same day.",
+ "subject_not_on_roster": "This entry is for someone who is not on the roster.",
+ "entries_overlap": "Two entries overlap for the same person or unit.",
+ "no_entries": "Add at least one entry.",
+ "break_rule": "A long shift has no break recorded.",
+ "long_travel": "More travel than allowed in one day; it needs approval.",
+ "outside_report_date": "This entry falls outside the report date."
},
"mars": {
"counts": "{{errors}} errors · {{warnings}} warnings",
@@ -1210,7 +1239,19 @@
"to": "To",
"total": "Total {{hours}} h",
"type": "Entry type",
- "unpaidBreak": "Unpaid break (min)"
+ "unpaidBreak": "Unpaid break (min)",
+ "crewReport": "Crew time report #{{number}} · {{name}}",
+ "individualReport": "Time report #{{number}} · {{name}}",
+ "noCrewReport": "No crew time report for {{name}} on this day.",
+ "coveredBy": "This time is already on report #{{number}} for this day.",
+ "startCrew": "Start crew time report",
+ "readOnly": "You can view this report but not change it.",
+ "nextDay": "Ends the next day",
+ "applyToCrew": "Apply to crew",
+ "approve": "Approve report",
+ "dayReports": "Reports this day",
+ "awaitingApproval": "Awaiting approval ({{count}})",
+ "notOnRoster": "You are not on this deployment's roster or crew."
},
"title": "Deployments",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Start odometer",
"title": "Usage",
"unit": "Unit"
+ },
+ "crewOf": "Crew of {{names}}",
+ "scope": {
+ "deployment": "Whole deployment",
+ "mine": "My time",
+ "crew": "Crew: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Signatures",
+ "crewBossSigned": "Crew boss signed {{when}}",
+ "crewBossPending": "Not signed by the crew boss yet.",
+ "customerSigned": "Agency signer: {{name}}",
+ "customerPending": "No agency signature recorded.",
+ "asCrewBoss": "Sign as crew boss",
+ "customerName": "Agency signer name",
+ "recordCustomer": "Record agency signature"
+ },
+ "expenses": {
+ "title": "Expenses",
+ "type": {
+ "0": "Meal per diem",
+ "1": "Lodging",
+ "2": "Private lodging",
+ "3": "Ferry",
+ "4": "Fuel",
+ "5": "Supply restock",
+ "6": "Other"
+ },
+ "typeLabel": "Expense type",
+ "amount": "Amount ({{currency}})",
+ "description": "Description",
+ "city": "City",
+ "takePhoto": "Take photo",
+ "choosePhoto": "Choose photo",
+ "receiptAttached": "Receipt photo attached.",
+ "save": "Add expense",
+ "add": "New expense",
+ "noneToday": "No expenses for this day.",
+ "otherDays": "Other days",
+ "hasReceipt": "receipt",
+ "remove": "Remove",
+ "removeTitle": "Remove expense?",
+ "removeBody": "This removes the expense and its receipt from the deployment.",
+ "cancel": "Cancel",
+ "saved": "Expense saved.",
+ "amountInvalid": "Enter an amount such as 42.50.",
+ "photoDenied": "Allow camera or photo access to attach a receipt."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Entrance",
"alert_notes": "Alert notes",
"files": "Site files"
+ },
+ "notifications": {
+ "detail_title": "Notification",
+ "additional_info": "Additional Information",
+ "view_call": "View Call",
+ "view_chat": "View Chat",
+ "open_reference": "Open"
}
}
diff --git a/src/translations/es.json b/src/translations/es.json
index b91a390e..1ee9051a 100644
--- a/src/translations/es.json
+++ b/src/translations/es.json
@@ -163,6 +163,12 @@
"no_location_for_routing": "No hay datos de ubicación disponibles para el enrutamiento",
"no_protocols": "No hay protocolos adjuntos a esta llamada",
"no_timeline": "No hay eventos de línea de tiempo disponibles",
+ "activity_link": {
+ "auto_linked": "vinculado automáticamente",
+ "auto_linked_hint": "Enviado sin llamada; Resgrid lo vinculó a partir del estado anterior o del despacho.",
+ "inferred": "inferido",
+ "inferred_hint": "Establecido sin destino por una unidad o persona despachada a esta llamada mientras la atendía."
+ },
"not_available": "N/D",
"not_found": "Llamada no encontrada",
"missing_call_id": "Falta el ID de la llamada",
@@ -310,6 +316,7 @@
"selected": "seleccionados",
"title": "Llamadas",
"type": "Tipo",
+ "udf_suggestions": "Sugerencias",
"units": "Unidades",
"users": "Usuarios",
"viewNotes": "Notas",
@@ -678,7 +685,13 @@
"empty": "No se han adjuntado archivos a este contacto.",
"share_unavailable": "Compartir no está disponible en este dispositivo.",
"download_failed": "No se pudo descargar el archivo."
- }
+ },
+ "withheld": "Hay {{count}} datos protegidos ocultos.",
+ "categoryLabel": "Categoría: {{name}}",
+ "physicalAddress": "Dirección física",
+ "mailingAddress": "Dirección postal",
+ "hasGeofence": "Esta ubicación tiene una geocerca en el mapa.",
+ "customFields": "Campos personalizados"
},
"dispatch": {
"active_calls": "Llamadas Activas",
@@ -1141,7 +1154,15 @@
"disabled": "Esta función está desactivada para su departamento.",
"locked": "Desbloquee los datos protegidos para continuar.",
"retry": "No se pudo conectar con el servidor. Inténtelo de nuevo.",
- "validation": "El servidor rechazó el cambio."
+ "validation": "El servidor rechazó el cambio.",
+ "timereports_subject_covered": "Esa tripulación o persona ya tiene horas en otro parte de este día.",
+ "timereports_date_exists": "Ya existe un parte para esa tripulación o persona ese día.",
+ "timereports_scope_invalid": "Esa tripulación o persona no está activa en este despliegue.",
+ "timereports_locked": "Este parte ya no se puede modificar.",
+ "timereports_time_invalid": "Introduzca las horas como HH:mm.",
+ "deployments_closed": "Este despliegue está cerrado.",
+ "expenses_receipt_invalid": "No se pudo leer el recibo.",
+ "deployments_attachment_too_large": "El recibo es demasiado grande."
},
"financeMode": {
"0": "Operativo",
@@ -1152,7 +1173,15 @@
"end_before_start": "La hora de fin debe ser posterior a la de inicio.",
"missing_subject": "Elija a quién o a qué corresponde esta entrada.",
"missing_time": "Introduzca una hora de inicio y de fin.",
- "overlap": "Esta entrada se solapa con otra de la misma persona o unidad."
+ "overlap": "Esta entrada se solapa con otra de la misma persona o unidad.",
+ "subject_outside_report_scope": "Esta entrada es de alguien ajeno a la tripulación o persona de este parte.",
+ "subject_on_other_report": "Esta persona o unidad ya tiene horas en otro parte del mismo día.",
+ "subject_not_on_roster": "Esta entrada es de alguien que no figura en la dotación.",
+ "entries_overlap": "Dos entradas de la misma persona o unidad se solapan.",
+ "no_entries": "Añada al menos una entrada.",
+ "break_rule": "Un turno largo no tiene descanso registrado.",
+ "long_travel": "Más viaje del permitido en un día; necesita aprobación.",
+ "outside_report_date": "Esta entrada queda fuera de la fecha del parte."
},
"mars": {
"counts": "{{errors}} errores · {{warnings}} avisos",
@@ -1201,7 +1230,7 @@
"noReport": "No hay parte de tiempo para este día.",
"notes": "Notas",
"remove": "Quitar",
- "report": "Parte n.º {{number}}",
+ "report": "Parte #{{number}}",
"save": "Guardar entradas",
"start": "Iniciar parte de tiempo",
"subject": "Quién o qué",
@@ -1210,7 +1239,19 @@
"to": "A",
"total": "Total {{hours}} h",
"type": "Tipo de entrada",
- "unpaidBreak": "Descanso no pagado (min)"
+ "unpaidBreak": "Descanso no pagado (min)",
+ "crewReport": "Parte de horas de tripulación #{{number}} · {{name}}",
+ "individualReport": "Parte de horas #{{number}} · {{name}}",
+ "noCrewReport": "No hay parte de tripulación para {{name}} este día.",
+ "coveredBy": "Estas horas ya están en el parte #{{number}} de este día.",
+ "startCrew": "Iniciar parte de tripulación",
+ "readOnly": "Puede ver este parte pero no modificarlo.",
+ "nextDay": "Termina al día siguiente",
+ "applyToCrew": "Aplicar a la tripulación",
+ "approve": "Aprobar parte",
+ "dayReports": "Partes de este día",
+ "awaitingApproval": "Pendientes de aprobación ({{count}})",
+ "notOnRoster": "No figura en la dotación ni en una tripulación de este despliegue."
},
"title": "Despliegues",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Odómetro inicial",
"title": "Uso",
"unit": "Unidad"
+ },
+ "crewOf": "Tripulación de {{names}}",
+ "scope": {
+ "deployment": "Todo el despliegue",
+ "mine": "Mis horas",
+ "crew": "Tripulación: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Firmas",
+ "crewBossSigned": "Firmado por el jefe de tripulación {{when}}",
+ "crewBossPending": "Aún sin firma del jefe de tripulación.",
+ "customerSigned": "Firmante del organismo: {{name}}",
+ "customerPending": "No hay firma del organismo registrada.",
+ "asCrewBoss": "Firmar como jefe de tripulación",
+ "customerName": "Nombre del firmante del organismo",
+ "recordCustomer": "Registrar firma del organismo"
+ },
+ "expenses": {
+ "title": "Gastos",
+ "type": {
+ "0": "Dieta de comidas",
+ "1": "Alojamiento",
+ "2": "Alojamiento privado",
+ "3": "Ferry",
+ "4": "Combustible",
+ "5": "Reposición de material",
+ "6": "Otro"
+ },
+ "typeLabel": "Tipo de gasto",
+ "amount": "Importe ({{currency}})",
+ "description": "Descripción",
+ "city": "Ciudad",
+ "takePhoto": "Hacer foto",
+ "choosePhoto": "Elegir foto",
+ "receiptAttached": "Foto del recibo adjunta.",
+ "save": "Añadir gasto",
+ "add": "Nuevo gasto",
+ "noneToday": "No hay gastos este día.",
+ "otherDays": "Otros días",
+ "hasReceipt": "recibo",
+ "remove": "Quitar",
+ "removeTitle": "¿Quitar el gasto?",
+ "removeBody": "Esto quita el gasto y su recibo del despliegue.",
+ "cancel": "Cancelar",
+ "saved": "Gasto guardado.",
+ "amountInvalid": "Introduzca un importe como 42.50.",
+ "photoDenied": "Permita el acceso a la cámara o a las fotos para adjuntar un recibo."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Entrada",
"alert_notes": "Notas de alerta",
"files": "Archivos del sitio"
+ },
+ "notifications": {
+ "detail_title": "Notificación",
+ "additional_info": "Información adicional",
+ "view_call": "Ver llamada",
+ "view_chat": "Ver chat",
+ "open_reference": "Abrir"
}
}
diff --git a/src/translations/fr.json b/src/translations/fr.json
index a6204fae..fd168668 100644
--- a/src/translations/fr.json
+++ b/src/translations/fr.json
@@ -164,6 +164,12 @@
"no_location_for_routing": "Aucune donnée de localisation disponible pour le routage",
"no_protocols": "Aucun protocole associé à cet appel",
"no_timeline": "Aucun événement chronologique disponible",
+ "activity_link": {
+ "auto_linked": "lié automatiquement",
+ "auto_linked_hint": "Envoyé sans appel ; Resgrid l'a lié à partir du statut précédent ou de la répartition.",
+ "inferred": "déduit",
+ "inferred_hint": "Défini sans destination par une unité ou une personne dépêchée sur cet appel pendant son intervention."
+ },
"not_available": "N/D",
"not_found": "Appel introuvable",
"missing_call_id": "L'identifiant de l'appel est manquant",
@@ -310,6 +316,7 @@
"selected": "sélectionné",
"title": "Appels",
"type": "Type",
+ "udf_suggestions": "Suggestions",
"units": "Unités",
"users": "Utilisateurs",
"viewNotes": "Notes",
@@ -678,7 +685,13 @@
"empty": "Aucun fichier n'est joint à ce contact.",
"share_unavailable": "Le partage n'est pas disponible sur cet appareil.",
"download_failed": "Le fichier n'a pas pu être téléchargé."
- }
+ },
+ "withheld": "{{count}} informations protégées sont masquées.",
+ "categoryLabel": "Catégorie : {{name}}",
+ "physicalAddress": "Adresse physique",
+ "mailingAddress": "Adresse postale",
+ "hasGeofence": "Ce lieu a un géorepérage cartographié.",
+ "customFields": "Champs personnalisés"
},
"dispatch": {
"active_calls": "Appels actifs",
@@ -1141,7 +1154,15 @@
"disabled": "Cette fonction est désactivée pour votre service.",
"locked": "Déverrouillez les données protégées pour continuer.",
"retry": "Serveur injoignable. Réessayez.",
- "validation": "Le serveur a refusé la modification."
+ "validation": "Le serveur a refusé la modification.",
+ "timereports_subject_covered": "Cet équipage ou cette personne a déjà du temps sur un autre rapport ce jour-là.",
+ "timereports_date_exists": "Un rapport existe déjà pour cet équipage ou cette personne ce jour-là.",
+ "timereports_scope_invalid": "Cet équipage ou cette personne n'est pas actif sur ce déploiement.",
+ "timereports_locked": "Ce rapport ne peut plus être modifié.",
+ "timereports_time_invalid": "Saisissez les heures au format HH:mm.",
+ "deployments_closed": "Ce déploiement est clôturé.",
+ "expenses_receipt_invalid": "Le reçu n'a pas pu être lu.",
+ "deployments_attachment_too_large": "Le reçu est trop volumineux."
},
"financeMode": {
"0": "Opérationnel",
@@ -1152,7 +1173,15 @@
"end_before_start": "L'heure de fin doit être après l'heure de début.",
"missing_subject": "Choisissez à qui ou à quoi correspond cette entrée.",
"missing_time": "Saisissez une heure de début et de fin.",
- "overlap": "Cette entrée chevauche une autre pour la même personne ou unité."
+ "overlap": "Cette entrée chevauche une autre pour la même personne ou unité.",
+ "subject_outside_report_scope": "Cette entrée concerne quelqu'un hors de l'équipage ou de la personne de ce rapport.",
+ "subject_on_other_report": "Cette personne ou unité a déjà du temps sur un autre rapport le même jour.",
+ "subject_not_on_roster": "Cette entrée concerne quelqu'un qui n'est pas dans l'effectif.",
+ "entries_overlap": "Deux entrées de la même personne ou unité se chevauchent.",
+ "no_entries": "Ajoutez au moins une entrée.",
+ "break_rule": "Une longue vacation n'a aucune pause enregistrée.",
+ "long_travel": "Plus de déplacement qu'autorisé en une journée ; une approbation est nécessaire.",
+ "outside_report_date": "Cette entrée tombe en dehors de la date du rapport."
},
"mars": {
"counts": "{{errors}} erreurs · {{warnings}} avertissements",
@@ -1201,7 +1230,7 @@
"noReport": "Aucun rapport de temps pour ce jour.",
"notes": "Notes",
"remove": "Supprimer",
- "report": "Rapport n° {{number}}",
+ "report": "Rapport #{{number}}",
"save": "Enregistrer les entrées",
"start": "Commencer le rapport de temps",
"subject": "Qui ou quoi",
@@ -1210,7 +1239,19 @@
"to": "À",
"total": "Total {{hours}} h",
"type": "Type d'entrée",
- "unpaidBreak": "Pause non payée (min)"
+ "unpaidBreak": "Pause non payée (min)",
+ "crewReport": "Rapport de temps d'équipage #{{number}} · {{name}}",
+ "individualReport": "Rapport de temps #{{number}} · {{name}}",
+ "noCrewReport": "Aucun rapport d'équipage pour {{name}} ce jour-là.",
+ "coveredBy": "Ce temps figure déjà sur le rapport #{{number}} de ce jour.",
+ "startCrew": "Commencer le rapport d'équipage",
+ "readOnly": "Vous pouvez consulter ce rapport mais pas le modifier.",
+ "nextDay": "Se termine le lendemain",
+ "applyToCrew": "Appliquer à l'équipage",
+ "approve": "Approuver le rapport",
+ "dayReports": "Rapports de ce jour",
+ "awaitingApproval": "En attente d'approbation ({{count}})",
+ "notOnRoster": "Vous ne faites pas partie de l'effectif ni d'un équipage de ce déploiement."
},
"title": "Déploiements",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Compteur début",
"title": "Utilisation",
"unit": "Unité"
+ },
+ "crewOf": "Équipage de {{names}}",
+ "scope": {
+ "deployment": "Tout le déploiement",
+ "mine": "Mon temps",
+ "crew": "Équipage : {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Signatures",
+ "crewBossSigned": "Signé par le chef d'équipage {{when}}",
+ "crewBossPending": "Pas encore signé par le chef d'équipage.",
+ "customerSigned": "Signataire de l'organisme : {{name}}",
+ "customerPending": "Aucune signature de l'organisme enregistrée.",
+ "asCrewBoss": "Signer en tant que chef d'équipage",
+ "customerName": "Nom du signataire de l'organisme",
+ "recordCustomer": "Enregistrer la signature de l'organisme"
+ },
+ "expenses": {
+ "title": "Dépenses",
+ "type": {
+ "0": "Indemnité de repas",
+ "1": "Hébergement",
+ "2": "Hébergement privé",
+ "3": "Traversier",
+ "4": "Carburant",
+ "5": "Réapprovisionnement",
+ "6": "Autre"
+ },
+ "typeLabel": "Type de dépense",
+ "amount": "Montant ({{currency}})",
+ "description": "Description",
+ "city": "Ville",
+ "takePhoto": "Prendre une photo",
+ "choosePhoto": "Choisir une photo",
+ "receiptAttached": "Photo du reçu jointe.",
+ "save": "Ajouter la dépense",
+ "add": "Nouvelle dépense",
+ "noneToday": "Aucune dépense ce jour-là.",
+ "otherDays": "Autres jours",
+ "hasReceipt": "reçu",
+ "remove": "Supprimer",
+ "removeTitle": "Supprimer la dépense ?",
+ "removeBody": "Cela supprime la dépense et son reçu du déploiement.",
+ "cancel": "Annuler",
+ "saved": "Dépense enregistrée.",
+ "amountInvalid": "Saisissez un montant comme 42.50.",
+ "photoDenied": "Autorisez l'accès à l'appareil photo ou aux photos pour joindre un reçu."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Entrée",
"alert_notes": "Notes d'alerte",
"files": "Fichiers du site"
+ },
+ "notifications": {
+ "detail_title": "Notification",
+ "additional_info": "Informations supplémentaires",
+ "view_call": "Voir l'appel",
+ "view_chat": "Voir la discussion",
+ "open_reference": "Ouvrir"
}
}
diff --git a/src/translations/it.json b/src/translations/it.json
index bbccda0e..b38e2e34 100644
--- a/src/translations/it.json
+++ b/src/translations/it.json
@@ -164,6 +164,12 @@
"no_location_for_routing": "Nessun dato di posizione disponibile per il percorso",
"no_protocols": "Nessun protocollo associato a questo intervento",
"no_timeline": "Nessun evento nella cronologia disponibile",
+ "activity_link": {
+ "auto_linked": "collegato automaticamente",
+ "auto_linked_hint": "Inviato senza intervento; Resgrid lo ha collegato dallo stato precedente o dall'invio.",
+ "inferred": "dedotto",
+ "inferred_hint": "Impostato senza destinazione da un'unità o persona inviata a questo intervento mentre vi operava."
+ },
"not_available": "N/D",
"not_found": "Intervento non trovato",
"missing_call_id": "ID intervento mancante",
@@ -310,6 +316,7 @@
"selected": "selezionato",
"title": "Interventi",
"type": "Tipo",
+ "udf_suggestions": "Suggerimenti",
"units": "Unità",
"users": "Utenti",
"viewNotes": "Note",
@@ -678,7 +685,13 @@
"empty": "Nessun file allegato a questo contatto.",
"share_unavailable": "La condivisione non è disponibile su questo dispositivo.",
"download_failed": "Impossibile scaricare il file."
- }
+ },
+ "withheld": "{{count}} dettagli protetti sono nascosti.",
+ "categoryLabel": "Categoria: {{name}}",
+ "physicalAddress": "Indirizzo fisico",
+ "mailingAddress": "Indirizzo postale",
+ "hasGeofence": "Questo luogo ha un geofence mappato.",
+ "customFields": "Campi personalizzati"
},
"dispatch": {
"active_calls": "Interventi attivi",
@@ -1141,7 +1154,15 @@
"disabled": "Questa funzione è disattivata per il tuo dipartimento.",
"locked": "Sblocca i dati protetti per continuare.",
"retry": "Impossibile raggiungere il server. Riprova.",
- "validation": "Il server ha rifiutato la modifica."
+ "validation": "Il server ha rifiutato la modifica.",
+ "timereports_subject_covered": "Questo equipaggio o questa persona ha già ore su un altro rapporto di questo giorno.",
+ "timereports_date_exists": "Esiste già un rapporto per questo equipaggio o persona in quel giorno.",
+ "timereports_scope_invalid": "Questo equipaggio o questa persona non è attivo in questo dispiegamento.",
+ "timereports_locked": "Questo rapporto non può più essere modificato.",
+ "timereports_time_invalid": "Inserisci gli orari come HH:mm.",
+ "deployments_closed": "Questo dispiegamento è chiuso.",
+ "expenses_receipt_invalid": "Impossibile leggere la ricevuta.",
+ "deployments_attachment_too_large": "La ricevuta è troppo grande."
},
"financeMode": {
"0": "Operativo",
@@ -1152,7 +1173,15 @@
"end_before_start": "L'ora di fine deve essere successiva a quella di inizio.",
"missing_subject": "Scegli a chi o a cosa si riferisce questa voce.",
"missing_time": "Inserisci un'ora di inizio e di fine.",
- "overlap": "Questa voce si sovrappone a un'altra per la stessa persona o unità."
+ "overlap": "Questa voce si sovrappone a un'altra per la stessa persona o unità.",
+ "subject_outside_report_scope": "Questa voce riguarda qualcuno al di fuori dell'equipaggio o della persona di questo rapporto.",
+ "subject_on_other_report": "Questa persona o unità ha già ore su un altro rapporto dello stesso giorno.",
+ "subject_not_on_roster": "Questa voce riguarda qualcuno che non è nell'organico.",
+ "entries_overlap": "Due voci della stessa persona o unità si sovrappongono.",
+ "no_entries": "Aggiungi almeno una voce.",
+ "break_rule": "Un turno lungo non ha pause registrate.",
+ "long_travel": "Più viaggio di quanto consentito in un giorno; serve approvazione.",
+ "outside_report_date": "Questa voce è fuori dalla data del rapporto."
},
"mars": {
"counts": "{{errors}} errori · {{warnings}} avvisi",
@@ -1201,7 +1230,7 @@
"noReport": "Nessun rapporto orario per questo giorno.",
"notes": "Note",
"remove": "Rimuovi",
- "report": "Rapporto n. {{number}}",
+ "report": "Rapporto #{{number}}",
"save": "Salva voci",
"start": "Avvia rapporto orario",
"subject": "Chi o cosa",
@@ -1210,7 +1239,19 @@
"to": "A",
"total": "Totale {{hours}} h",
"type": "Tipo di voce",
- "unpaidBreak": "Pausa non retribuita (min)"
+ "unpaidBreak": "Pausa non retribuita (min)",
+ "crewReport": "Rapporto ore dell'equipaggio #{{number}} · {{name}}",
+ "individualReport": "Rapporto ore #{{number}} · {{name}}",
+ "noCrewReport": "Nessun rapporto dell'equipaggio per {{name}} in questo giorno.",
+ "coveredBy": "Queste ore sono già nel rapporto #{{number}} di questo giorno.",
+ "startCrew": "Avvia rapporto dell'equipaggio",
+ "readOnly": "Puoi vedere questo rapporto ma non modificarlo.",
+ "nextDay": "Termina il giorno dopo",
+ "applyToCrew": "Applica all'equipaggio",
+ "approve": "Approva rapporto",
+ "dayReports": "Rapporti di questo giorno",
+ "awaitingApproval": "In attesa di approvazione ({{count}})",
+ "notOnRoster": "Non sei nell'organico né in un equipaggio di questo dispiegamento."
},
"title": "Dispiegamenti",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Contachilometri iniziale",
"title": "Utilizzo",
"unit": "Unità"
+ },
+ "crewOf": "Equipaggio di {{names}}",
+ "scope": {
+ "deployment": "Intero dispiegamento",
+ "mine": "Il mio tempo",
+ "crew": "Equipaggio: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Firme",
+ "crewBossSigned": "Firmato dal capo equipaggio {{when}}",
+ "crewBossPending": "Non ancora firmato dal capo equipaggio.",
+ "customerSigned": "Firmatario dell'ente: {{name}}",
+ "customerPending": "Nessuna firma dell'ente registrata.",
+ "asCrewBoss": "Firma come capo equipaggio",
+ "customerName": "Nome del firmatario dell'ente",
+ "recordCustomer": "Registra la firma dell'ente"
+ },
+ "expenses": {
+ "title": "Spese",
+ "type": {
+ "0": "Diaria pasti",
+ "1": "Alloggio",
+ "2": "Alloggio privato",
+ "3": "Traghetto",
+ "4": "Carburante",
+ "5": "Reintegro materiali",
+ "6": "Altro"
+ },
+ "typeLabel": "Tipo di spesa",
+ "amount": "Importo ({{currency}})",
+ "description": "Descrizione",
+ "city": "Città",
+ "takePhoto": "Scatta foto",
+ "choosePhoto": "Scegli foto",
+ "receiptAttached": "Foto della ricevuta allegata.",
+ "save": "Aggiungi spesa",
+ "add": "Nuova spesa",
+ "noneToday": "Nessuna spesa in questo giorno.",
+ "otherDays": "Altri giorni",
+ "hasReceipt": "ricevuta",
+ "remove": "Rimuovi",
+ "removeTitle": "Rimuovere la spesa?",
+ "removeBody": "La spesa e la sua ricevuta vengono rimosse dal dispiegamento.",
+ "cancel": "Annulla",
+ "saved": "Spesa salvata.",
+ "amountInvalid": "Inserisci un importo come 42.50.",
+ "photoDenied": "Consenti l'accesso alla fotocamera o alle foto per allegare una ricevuta."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Ingresso",
"alert_notes": "Note di allerta",
"files": "File del sito"
+ },
+ "notifications": {
+ "detail_title": "Notifica",
+ "additional_info": "Informazioni aggiuntive",
+ "view_call": "Visualizza intervento",
+ "view_chat": "Visualizza chat",
+ "open_reference": "Apri"
}
}
diff --git a/src/translations/pl.json b/src/translations/pl.json
index b1ecf78c..22bd989b 100644
--- a/src/translations/pl.json
+++ b/src/translations/pl.json
@@ -164,6 +164,12 @@
"no_location_for_routing": "Brak danych lokalizacyjnych do wyznaczenia trasy",
"no_protocols": "Brak protokołów dołączonych do tego zgłoszenia",
"no_timeline": "Brak zdarzeń na osi czasu",
+ "activity_link": {
+ "auto_linked": "powiązano automatycznie",
+ "auto_linked_hint": "Wysłano bez zgłoszenia; Resgrid powiązał status na podstawie poprzedniego statusu lub zadysponowania.",
+ "inferred": "wywnioskowano",
+ "inferred_hint": "Ustawiony bez celu przez jednostkę lub osobę zadysponowaną do tego zgłoszenia podczas działań przy nim."
+ },
"not_available": "Nd.",
"not_found": "Nie znaleziono zgłoszenia",
"missing_call_id": "Brak identyfikatora zgłoszenia",
@@ -310,6 +316,7 @@
"selected": "wybrano",
"title": "Zgłoszenia",
"type": "Typ",
+ "udf_suggestions": "Sugestie",
"units": "Jednostki",
"users": "Użytkownicy",
"viewNotes": "Notatki",
@@ -678,7 +685,13 @@
"empty": "Do tego kontaktu nie dołączono żadnych plików.",
"share_unavailable": "Udostępnianie nie jest dostępne na tym urządzeniu.",
"download_failed": "Nie udało się pobrać pliku."
- }
+ },
+ "withheld": "Ukryto {{count}} chronionych danych.",
+ "categoryLabel": "Kategoria: {{name}}",
+ "physicalAddress": "Adres fizyczny",
+ "mailingAddress": "Adres korespondencyjny",
+ "hasGeofence": "Ta lokalizacja ma wyznaczoną na mapie strefę geofence.",
+ "customFields": "Pola niestandardowe"
},
"dispatch": {
"active_calls": "Aktywne zgłoszenia",
@@ -1141,7 +1154,15 @@
"disabled": "Ta funkcja jest wyłączona dla Twojego wydziału.",
"locked": "Odblokuj dane chronione, aby kontynuować.",
"retry": "Brak połączenia z serwerem. Spróbuj ponownie.",
- "validation": "Serwer odrzucił zmianę."
+ "validation": "Serwer odrzucił zmianę.",
+ "timereports_subject_covered": "Ta załoga lub osoba ma już czas w innym raporcie z tego dnia.",
+ "timereports_date_exists": "Raport dla tej załogi lub osoby już istnieje w tym dniu.",
+ "timereports_scope_invalid": "Ta załoga lub osoba nie jest aktywna w tym rozmieszczeniu.",
+ "timereports_locked": "Tego raportu nie można już zmienić.",
+ "timereports_time_invalid": "Wpisz godziny w formacie HH:mm.",
+ "deployments_closed": "To rozmieszczenie jest zamknięte.",
+ "expenses_receipt_invalid": "Nie udało się odczytać paragonu.",
+ "deployments_attachment_too_large": "Paragon jest za duży."
},
"financeMode": {
"0": "Operacyjne",
@@ -1152,7 +1173,15 @@
"end_before_start": "Godzina zakończenia musi być późniejsza niż rozpoczęcia.",
"missing_subject": "Wybierz, kogo lub czego dotyczy ten wpis.",
"missing_time": "Podaj godzinę rozpoczęcia i zakończenia.",
- "overlap": "Ten wpis nakłada się na inny dla tej samej osoby lub jednostki."
+ "overlap": "Ten wpis nakłada się na inny dla tej samej osoby lub jednostki.",
+ "subject_outside_report_scope": "Ten wpis dotyczy kogoś spoza załogi lub osoby tego raportu.",
+ "subject_on_other_report": "Ta osoba lub jednostka ma już czas w innym raporcie z tego samego dnia.",
+ "subject_not_on_roster": "Ten wpis dotyczy kogoś, kogo nie ma w składzie.",
+ "entries_overlap": "Dwa wpisy tej samej osoby lub jednostki nakładają się.",
+ "no_entries": "Dodaj co najmniej jeden wpis.",
+ "break_rule": "Długa zmiana nie ma zapisanej przerwy.",
+ "long_travel": "Więcej podróży niż dozwolone w ciągu dnia; wymaga zatwierdzenia.",
+ "outside_report_date": "Ten wpis wykracza poza datę raportu."
},
"mars": {
"counts": "{{errors}} błędów · {{warnings}} ostrzeżeń",
@@ -1201,7 +1230,7 @@
"noReport": "Brak raportu czasu na ten dzień.",
"notes": "Notatki",
"remove": "Usuń",
- "report": "Raport nr {{number}}",
+ "report": "Raport #{{number}}",
"save": "Zapisz wpisy",
"start": "Rozpocznij raport czasu",
"subject": "Kto lub co",
@@ -1210,7 +1239,19 @@
"to": "Do",
"total": "Razem {{hours}} h",
"type": "Rodzaj wpisu",
- "unpaidBreak": "Przerwa bezpłatna (min)"
+ "unpaidBreak": "Przerwa bezpłatna (min)",
+ "crewReport": "Raport czasu załogi #{{number}} · {{name}}",
+ "individualReport": "Raport czasu #{{number}} · {{name}}",
+ "noCrewReport": "Brak raportu czasu załogi {{name}} w tym dniu.",
+ "coveredBy": "Ten czas jest już w raporcie #{{number}} z tego dnia.",
+ "startCrew": "Rozpocznij raport czasu załogi",
+ "readOnly": "Możesz przeglądać ten raport, ale nie możesz go zmienić.",
+ "nextDay": "Kończy się następnego dnia",
+ "applyToCrew": "Zastosuj do załogi",
+ "approve": "Zatwierdź raport",
+ "dayReports": "Raporty z tego dnia",
+ "awaitingApproval": "Oczekuje na zatwierdzenie ({{count}})",
+ "notOnRoster": "Nie jesteś w składzie ani w załodze tego rozmieszczenia."
},
"title": "Rozmieszczenia",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Licznik początkowy",
"title": "Użycie",
"unit": "Jednostka"
+ },
+ "crewOf": "Załoga {{names}}",
+ "scope": {
+ "deployment": "Całe rozmieszczenie",
+ "mine": "Mój czas",
+ "crew": "Załoga: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Podpisy",
+ "crewBossSigned": "Podpisane przez dowódcę załogi {{when}}",
+ "crewBossPending": "Jeszcze nie podpisane przez dowódcę załogi.",
+ "customerSigned": "Podpisujący z ramienia instytucji: {{name}}",
+ "customerPending": "Brak podpisu instytucji.",
+ "asCrewBoss": "Podpisz jako dowódca załogi",
+ "customerName": "Imię i nazwisko podpisującego z instytucji",
+ "recordCustomer": "Zapisz podpis instytucji"
+ },
+ "expenses": {
+ "title": "Wydatki",
+ "type": {
+ "0": "Dieta posiłkowa",
+ "1": "Zakwaterowanie",
+ "2": "Zakwaterowanie prywatne",
+ "3": "Prom",
+ "4": "Paliwo",
+ "5": "Uzupełnienie zapasów",
+ "6": "Inne"
+ },
+ "typeLabel": "Rodzaj wydatku",
+ "amount": "Kwota ({{currency}})",
+ "description": "Opis",
+ "city": "Miasto",
+ "takePhoto": "Zrób zdjęcie",
+ "choosePhoto": "Wybierz zdjęcie",
+ "receiptAttached": "Dołączono zdjęcie paragonu.",
+ "save": "Dodaj wydatek",
+ "add": "Nowy wydatek",
+ "noneToday": "Brak wydatków w tym dniu.",
+ "otherDays": "Inne dni",
+ "hasReceipt": "paragon",
+ "remove": "Usuń",
+ "removeTitle": "Usunąć wydatek?",
+ "removeBody": "Spowoduje to usunięcie wydatku i jego paragonu z rozmieszczenia.",
+ "cancel": "Anuluj",
+ "saved": "Wydatek zapisany.",
+ "amountInvalid": "Wpisz kwotę, np. 42.50.",
+ "photoDenied": "Zezwól na dostęp do aparatu lub zdjęć, aby dołączyć paragon."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Wejście",
"alert_notes": "Notatki alarmowe",
"files": "Pliki obiektu"
+ },
+ "notifications": {
+ "detail_title": "Powiadomienie",
+ "additional_info": "Dodatkowe informacje",
+ "view_call": "Zobacz zgłoszenie",
+ "view_chat": "Zobacz czat",
+ "open_reference": "Otwórz"
}
}
diff --git a/src/translations/sv.json b/src/translations/sv.json
index 8a152c9d..2677ec7c 100644
--- a/src/translations/sv.json
+++ b/src/translations/sv.json
@@ -164,6 +164,12 @@
"no_location_for_routing": "Ingen platsdata tillgänglig för vägbeskrivning",
"no_protocols": "Inga protokoll kopplade till detta ärende",
"no_timeline": "Inga tidslinjehändelser tillgängliga",
+ "activity_link": {
+ "auto_linked": "autolänkad",
+ "auto_linked_hint": "Skickad utan ärende; Resgrid länkade den från föregående status eller utlarmningen.",
+ "inferred": "härledd",
+ "inferred_hint": "Satt utan destination av en enhet eller person som larmats ut till detta ärende under arbetet med det."
+ },
"not_available": "Ej tillgänglig",
"not_found": "Ärende hittades inte",
"missing_call_id": "Ärende-ID saknas",
@@ -310,6 +316,7 @@
"selected": "vald",
"title": "Ärenden",
"type": "Typ",
+ "udf_suggestions": "Förslag",
"units": "Enheter",
"users": "Användare",
"viewNotes": "Anteckningar",
@@ -678,7 +685,13 @@
"empty": "Inga filer har bifogats denna kontakt.",
"share_unavailable": "Delning är inte tillgänglig på den här enheten.",
"download_failed": "Filen kunde inte laddas ner."
- }
+ },
+ "withheld": "{{count}} skyddade uppgifter är dolda.",
+ "categoryLabel": "Kategori: {{name}}",
+ "physicalAddress": "Besöksadress",
+ "mailingAddress": "Postadress",
+ "hasGeofence": "Platsen har ett inritat geostaket.",
+ "customFields": "Anpassade fält"
},
"dispatch": {
"active_calls": "Aktiva ärenden",
@@ -1141,7 +1154,15 @@
"disabled": "Funktionen är avstängd för din avdelning.",
"locked": "Lås upp skyddade data för att fortsätta.",
"retry": "Kunde inte nå servern. Försök igen.",
- "validation": "Servern avvisade ändringen."
+ "validation": "Servern avvisade ändringen.",
+ "timereports_subject_covered": "Den besättningen eller personen har redan tid i en annan rapport denna dag.",
+ "timereports_date_exists": "Det finns redan en rapport för den besättningen eller personen den dagen.",
+ "timereports_scope_invalid": "Den besättningen eller personen är inte aktiv i denna insats.",
+ "timereports_locked": "Rapporten kan inte längre ändras.",
+ "timereports_time_invalid": "Ange tider som HH:mm.",
+ "deployments_closed": "Insatsen är avslutad.",
+ "expenses_receipt_invalid": "Kvittot kunde inte läsas.",
+ "deployments_attachment_too_large": "Kvittot är för stort."
},
"financeMode": {
"0": "Operativ",
@@ -1152,7 +1173,15 @@
"end_before_start": "Sluttiden måste vara efter starttiden.",
"missing_subject": "Välj vem eller vad posten gäller.",
"missing_time": "Ange start- och sluttid.",
- "overlap": "Posten överlappar en annan för samma person eller enhet."
+ "overlap": "Posten överlappar en annan för samma person eller enhet.",
+ "subject_outside_report_scope": "Posten gäller någon utanför rapportens besättning eller person.",
+ "subject_on_other_report": "Den här personen eller enheten har redan tid i en annan rapport samma dag.",
+ "subject_not_on_roster": "Posten gäller någon som inte finns i bemanningen.",
+ "entries_overlap": "Två poster för samma person eller enhet överlappar.",
+ "no_entries": "Lägg till minst en post.",
+ "break_rule": "Ett långt pass saknar registrerad rast.",
+ "long_travel": "Mer resa än tillåtet på en dag; kräver godkännande.",
+ "outside_report_date": "Posten ligger utanför rapportens datum."
},
"mars": {
"counts": "{{errors}} fel · {{warnings}} varningar",
@@ -1201,7 +1230,7 @@
"noReport": "Ingen tidrapport för den här dagen.",
"notes": "Anteckningar",
"remove": "Ta bort",
- "report": "Rapport nr {{number}}",
+ "report": "Rapport #{{number}}",
"save": "Spara poster",
"start": "Starta tidrapport",
"subject": "Vem eller vad",
@@ -1210,7 +1239,19 @@
"to": "Till",
"total": "Totalt {{hours}} h",
"type": "Posttyp",
- "unpaidBreak": "Obetald rast (min)"
+ "unpaidBreak": "Obetald rast (min)",
+ "crewReport": "Besättningens tidrapport #{{number}} · {{name}}",
+ "individualReport": "Tidrapport #{{number}} · {{name}}",
+ "noCrewReport": "Ingen tidrapport för besättningen {{name}} denna dag.",
+ "coveredBy": "Den här tiden finns redan i rapport #{{number}} för denna dag.",
+ "startCrew": "Starta besättningens tidrapport",
+ "readOnly": "Du kan se rapporten men inte ändra den.",
+ "nextDay": "Slutar nästa dag",
+ "applyToCrew": "Använd för besättningen",
+ "approve": "Godkänn rapport",
+ "dayReports": "Rapporter denna dag",
+ "awaitingApproval": "Väntar på godkännande ({{count}})",
+ "notOnRoster": "Du finns inte i bemanningen eller någon besättning i denna insats."
},
"title": "Insatser",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Mätarställning start",
"title": "Användning",
"unit": "Enhet"
+ },
+ "crewOf": "Besättning på {{names}}",
+ "scope": {
+ "deployment": "Hela insatsen",
+ "mine": "Min tid",
+ "crew": "Besättning: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Underskrifter",
+ "crewBossSigned": "Signerad av besättningschefen {{when}}",
+ "crewBossPending": "Ännu inte signerad av besättningschefen.",
+ "customerSigned": "Myndighetens undertecknare: {{name}}",
+ "customerPending": "Ingen underskrift från myndigheten registrerad.",
+ "asCrewBoss": "Signera som besättningschef",
+ "customerName": "Myndighetens undertecknares namn",
+ "recordCustomer": "Registrera myndighetens underskrift"
+ },
+ "expenses": {
+ "title": "Utlägg",
+ "type": {
+ "0": "Traktamente måltider",
+ "1": "Logi",
+ "2": "Privat logi",
+ "3": "Färja",
+ "4": "Bränsle",
+ "5": "Materialpåfyllning",
+ "6": "Övrigt"
+ },
+ "typeLabel": "Typ av utlägg",
+ "amount": "Belopp ({{currency}})",
+ "description": "Beskrivning",
+ "city": "Ort",
+ "takePhoto": "Ta foto",
+ "choosePhoto": "Välj foto",
+ "receiptAttached": "Kvittofoto bifogat.",
+ "save": "Lägg till utlägg",
+ "add": "Nytt utlägg",
+ "noneToday": "Inga utlägg denna dag.",
+ "otherDays": "Andra dagar",
+ "hasReceipt": "kvitto",
+ "remove": "Ta bort",
+ "removeTitle": "Ta bort utlägget?",
+ "removeBody": "Utlägget och dess kvitto tas bort från insatsen.",
+ "cancel": "Avbryt",
+ "saved": "Utlägget sparades.",
+ "amountInvalid": "Ange ett belopp som 42.50.",
+ "photoDenied": "Tillåt åtkomst till kamera eller bilder för att bifoga ett kvitto."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Entré",
"alert_notes": "Varningsanteckningar",
"files": "Objektfiler"
+ },
+ "notifications": {
+ "detail_title": "Avisering",
+ "additional_info": "Ytterligare information",
+ "view_call": "Visa ärende",
+ "view_chat": "Visa chatt",
+ "open_reference": "Öppna"
}
}
diff --git a/src/translations/uk.json b/src/translations/uk.json
index f9bb1019..53b78995 100644
--- a/src/translations/uk.json
+++ b/src/translations/uk.json
@@ -164,6 +164,12 @@
"no_location_for_routing": "Дані про місцезнаходження для маршрутизації відсутні",
"no_protocols": "До цього виклику не прикріплено протоколів",
"no_timeline": "Події хронології відсутні",
+ "activity_link": {
+ "auto_linked": "пов'язано автоматично",
+ "auto_linked_hint": "Надіслано без виклику; Resgrid пов'язав статус на основі попереднього статусу або диспетчеризації.",
+ "inferred": "виведено",
+ "inferred_hint": "Встановлено без пункту призначення підрозділом або особою, диспетчеризованими на цей виклик, під час роботи над ним."
+ },
"not_available": "Н/Д",
"not_found": "Виклик не знайдено",
"missing_call_id": "Відсутній ID виклику",
@@ -310,6 +316,7 @@
"selected": "вибрано",
"title": "Виклики",
"type": "Тип",
+ "udf_suggestions": "Пропозиції",
"units": "Підрозділи",
"users": "Користувачі",
"viewNotes": "Нотатки",
@@ -678,7 +685,13 @@
"empty": "До цього контакту не додано файлів.",
"share_unavailable": "Поширення недоступне на цьому пристрої.",
"download_failed": "Не вдалося завантажити файл."
- }
+ },
+ "withheld": "Приховано {{count}} захищених даних.",
+ "categoryLabel": "Категорія: {{name}}",
+ "physicalAddress": "Фактична адреса",
+ "mailingAddress": "Поштова адреса",
+ "hasGeofence": "Для цього місця на карті позначено геозону.",
+ "customFields": "Власні поля"
},
"dispatch": {
"active_calls": "Активні виклики",
@@ -1141,7 +1154,15 @@
"disabled": "Цю функцію вимкнено для вашого підрозділу.",
"locked": "Розблокуйте захищені дані, щоб продовжити.",
"retry": "Не вдалося з’єднатися з сервером. Спробуйте ще раз.",
- "validation": "Сервер відхилив зміну."
+ "validation": "Сервер відхилив зміну.",
+ "timereports_subject_covered": "Цей екіпаж або особа вже мають час в іншому звіті за цей день.",
+ "timereports_date_exists": "Звіт для цього екіпажу або особи за той день уже існує.",
+ "timereports_scope_invalid": "Цей екіпаж або особа не активні в цьому розгортанні.",
+ "timereports_locked": "Цей звіт більше не можна змінити.",
+ "timereports_time_invalid": "Вводьте час у форматі HH:mm.",
+ "deployments_closed": "Це розгортання закрите.",
+ "expenses_receipt_invalid": "Не вдалося прочитати чек.",
+ "deployments_attachment_too_large": "Чек завеликий."
},
"financeMode": {
"0": "Оперативне",
@@ -1152,7 +1173,15 @@
"end_before_start": "Час завершення має бути пізніше за час початку.",
"missing_subject": "Виберіть, кого або чого стосується цей запис.",
"missing_time": "Введіть час початку та завершення.",
- "overlap": "Цей запис перетинається з іншим для тієї самої особи або підрозділу."
+ "overlap": "Цей запис перетинається з іншим для тієї самої особи або підрозділу.",
+ "subject_outside_report_scope": "Цей запис стосується когось поза екіпажем або особою цього звіту.",
+ "subject_on_other_report": "Ця особа або підрозділ уже має час в іншому звіті за той самий день.",
+ "subject_not_on_roster": "Цей запис стосується когось, кого немає у складі.",
+ "entries_overlap": "Два записи тієї самої особи або підрозділу перекриваються.",
+ "no_entries": "Додайте принаймні один запис.",
+ "break_rule": "Для довгої зміни не записано перерви.",
+ "long_travel": "Більше переїздів, ніж дозволено за день; потрібне затвердження.",
+ "outside_report_date": "Цей запис виходить за межі дати звіту."
},
"mars": {
"counts": "{{errors}} помилок · {{warnings}} попереджень",
@@ -1201,7 +1230,7 @@
"noReport": "Немає звіту про час за цей день.",
"notes": "Нотатки",
"remove": "Видалити",
- "report": "Звіт № {{number}}",
+ "report": "Звіт #{{number}}",
"save": "Зберегти записи",
"start": "Почати звіт про час",
"subject": "Хто або що",
@@ -1210,7 +1239,19 @@
"to": "До",
"total": "Разом {{hours}} год",
"type": "Тип запису",
- "unpaidBreak": "Неоплачувана перерва (хв)"
+ "unpaidBreak": "Неоплачувана перерва (хв)",
+ "crewReport": "Звіт про час екіпажу #{{number}} · {{name}}",
+ "individualReport": "Звіт про час #{{number}} · {{name}}",
+ "noCrewReport": "Немає звіту про час екіпажу {{name}} за цей день.",
+ "coveredBy": "Цей час уже є у звіті #{{number}} за цей день.",
+ "startCrew": "Почати звіт про час екіпажу",
+ "readOnly": "Ви можете переглядати цей звіт, але не змінювати його.",
+ "nextDay": "Закінчується наступного дня",
+ "applyToCrew": "Застосувати до екіпажу",
+ "approve": "Затвердити звіт",
+ "dayReports": "Звіти за цей день",
+ "awaitingApproval": "Очікують затвердження ({{count}})",
+ "notOnRoster": "Ви не входите до складу чи екіпажу цього розгортання."
},
"title": "Розгортання",
"usage": {
@@ -1224,6 +1265,54 @@
"startOdometer": "Одометр на початку",
"title": "Використання",
"unit": "Підрозділ"
+ },
+ "crewOf": "Екіпаж {{names}}",
+ "scope": {
+ "deployment": "Усе розгортання",
+ "mine": "Мій час",
+ "crew": "Екіпаж: {{name}}",
+ "individual": "{{name}}"
+ },
+ "sign": {
+ "title": "Підписи",
+ "crewBossSigned": "Підписано командиром екіпажу {{when}}",
+ "crewBossPending": "Командир екіпажу ще не підписав.",
+ "customerSigned": "Підписант установи: {{name}}",
+ "customerPending": "Підпис установи не записано.",
+ "asCrewBoss": "Підписати як командир екіпажу",
+ "customerName": "Ім'я підписанта установи",
+ "recordCustomer": "Записати підпис установи"
+ },
+ "expenses": {
+ "title": "Витрати",
+ "type": {
+ "0": "Добові на харчування",
+ "1": "Проживання",
+ "2": "Приватне проживання",
+ "3": "Пором",
+ "4": "Пальне",
+ "5": "Поповнення запасів",
+ "6": "Інше"
+ },
+ "typeLabel": "Тип витрати",
+ "amount": "Сума ({{currency}})",
+ "description": "Опис",
+ "city": "Місто",
+ "takePhoto": "Зробити фото",
+ "choosePhoto": "Вибрати фото",
+ "receiptAttached": "Фото чека додано.",
+ "save": "Додати витрату",
+ "add": "Нова витрата",
+ "noneToday": "Немає витрат за цей день.",
+ "otherDays": "Інші дні",
+ "hasReceipt": "чек",
+ "remove": "Видалити",
+ "removeTitle": "Видалити витрату?",
+ "removeBody": "Це видалить витрату та її чек із розгортання.",
+ "cancel": "Скасувати",
+ "saved": "Витрату збережено.",
+ "amountInvalid": "Введіть суму, наприклад 42.50.",
+ "photoDenied": "Дозвольте доступ до камери або фото, щоб додати чек."
}
},
"personnel": {
@@ -1940,5 +2029,12 @@
"entrance": "Вхід",
"alert_notes": "Тривожні нотатки",
"files": "Файли об'єкта"
+ },
+ "notifications": {
+ "detail_title": "Сповіщення",
+ "additional_info": "Додаткова інформація",
+ "view_call": "Переглянути виклик",
+ "view_chat": "Переглянути чат",
+ "open_reference": "Відкрити"
}
}
diff --git a/src/types/notification.ts b/src/types/notification.ts
index 0559b9ed..cecec533 100644
--- a/src/types/notification.ts
+++ b/src/types/notification.ts
@@ -6,7 +6,7 @@ export interface NotificationPayload {
read?: boolean;
type?: string;
referenceId?: string;
- referenceType?: 'call' | 'message' | 'status' | 'note' | 'other';
+ referenceType?: 'call' | 'message' | 'status' | 'note' | 'chat' | 'other';
metadata?: Record;
}
diff --git a/src/utils/__tests__/html-entities.test.ts b/src/utils/__tests__/html-entities.test.ts
new file mode 100644
index 00000000..ac8025c1
--- /dev/null
+++ b/src/utils/__tests__/html-entities.test.ts
@@ -0,0 +1,11 @@
+import { escapeHtml } from '../html-entities';
+
+describe('escapeHtml', () => {
+ it('escapes the characters that can start markup or leave an attribute', () => {
+ expect(escapeHtml(` & more`)).toBe('<img src=x onerror="alert('x')"> & more');
+ });
+
+ it('leaves plain text untouched', () => {
+ expect(escapeHtml('Engine 41 — Station 3')).toBe('Engine 41 — Station 3');
+ });
+});
diff --git a/src/utils/html-entities.ts b/src/utils/html-entities.ts
index ded2d5ef..64d10430 100644
--- a/src/utils/html-entities.ts
+++ b/src/utils/html-entities.ts
@@ -28,3 +28,8 @@ export const decodeHtmlEntities = (value: string): string =>
export const looksHtmlEncoded = (value: string): boolean => !/<[a-z!/]/i.test(value) && /&(lt|#0*60|#x0*3c);/i.test(value);
export const decodeHtmlEntitiesIfEncoded = (value: string): string => (looksHtmlEncoded(value) ? decodeHtmlEntities(value) : value);
+
+const ESCAPED_CHARACTERS: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+
+/** Plain text made safe to place in HTML markup, as text content or a quoted attribute value. */
+export const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (character) => ESCAPED_CHARACTERS[character]);
diff --git a/tsconfig.json b/tsconfig.json
index 9a88bfa2..8b2b99a6 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,7 +12,6 @@
"lib": ["esnext", "dom"],
"skipLibCheck": true,
"resolveJsonModule": true,
- "baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@env": ["./src/lib/env.js"],
diff --git a/yarn.lock b/yarn.lock
index 7d4b366f..9f489f39 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7,11 +7,6 @@
resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.2.0.tgz#7a03314684dd6572b7dfa89e68ce31d60286854d"
integrity sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==
-"@0no-co/graphql.web@^1.0.13", "@0no-co/graphql.web@^1.0.8":
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz#296d00581bfaaabfda1e976849d927824aaea81b"
- integrity sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==
-
"@adobe/css-tools@^4.4.0":
version "4.4.4"
resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.4.tgz#2856c55443d3d461693f32d2b96fb6ea92e1ffa9"
@@ -50,22 +45,7 @@
resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30"
integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==
-"@ampproject/remapping@^2.2.0":
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4"
- integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==
- dependencies:
- "@jridgewell/gen-mapping" "^0.3.5"
- "@jridgewell/trace-mapping" "^0.3.24"
-
-"@babel/code-frame@7.10.4", "@babel/code-frame@~7.10.4":
- version "7.10.4"
- resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a"
- integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==
- dependencies:
- "@babel/highlight" "^7.10.4"
-
-"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1":
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.26.2", "@babel/code-frame@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be"
integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==
@@ -83,15 +63,24 @@
js-tokens "^4.0.0"
picocolors "^1.1.1"
+"@babel/code-frame@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7"
+ integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==
+ dependencies:
+ "@babel/helper-validator-identifier" "^7.29.7"
+ js-tokens "^4.0.0"
+ picocolors "^1.1.1"
+
"@babel/compat-data@^7.27.2", "@babel/compat-data@^7.27.7":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.4.tgz#96fdf1af1b8859c8474ab39c295312bfb7c24b04"
integrity sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==
-"@babel/compat-data@^7.28.6":
- version "7.29.0"
- resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d"
- integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==
+"@babel/compat-data@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629"
+ integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.21.3", "@babel/core@^7.23.9", "@babel/core@^7.24.4", "@babel/core@^7.25.2", "@babel/core@^7.27.4":
version "7.28.4"
@@ -114,28 +103,28 @@
json5 "^2.2.3"
semver "^6.3.1"
-"@babel/core@~7.26.0":
- version "7.26.10"
- resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.10.tgz#5c876f83c8c4dcb233ee4b670c0606f2ac3000f9"
- integrity sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==
- dependencies:
- "@ampproject/remapping" "^2.2.0"
- "@babel/code-frame" "^7.26.2"
- "@babel/generator" "^7.26.10"
- "@babel/helper-compilation-targets" "^7.26.5"
- "@babel/helper-module-transforms" "^7.26.0"
- "@babel/helpers" "^7.26.10"
- "@babel/parser" "^7.26.10"
- "@babel/template" "^7.26.9"
- "@babel/traverse" "^7.26.10"
- "@babel/types" "^7.26.10"
+"@babel/core@^7.29.0":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7"
+ integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
+ dependencies:
+ "@babel/code-frame" "^7.29.7"
+ "@babel/generator" "^7.29.7"
+ "@babel/helper-compilation-targets" "^7.29.7"
+ "@babel/helper-module-transforms" "^7.29.7"
+ "@babel/helpers" "^7.29.7"
+ "@babel/parser" "^7.29.7"
+ "@babel/template" "^7.29.7"
+ "@babel/traverse" "^7.29.7"
+ "@babel/types" "^7.29.7"
+ "@jridgewell/remapping" "^2.3.5"
convert-source-map "^2.0.0"
debug "^4.1.0"
gensync "^1.0.0-beta.2"
json5 "^2.2.3"
semver "^6.3.1"
-"@babel/generator@^7.20.5", "@babel/generator@^7.25.0", "@babel/generator@^7.26.10", "@babel/generator@^7.28.3", "@babel/generator@^7.7.2":
+"@babel/generator@^7.20.5", "@babel/generator@^7.28.3", "@babel/generator@^7.7.2":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e"
integrity sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==
@@ -157,6 +146,17 @@
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
+"@babel/generator@^7.29.7", "@babel/generator@^7.29.8":
+ version "7.29.8"
+ resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.8.tgz#4b0b887885422643339e09022148a4c4ebaa4979"
+ integrity sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==
+ dependencies:
+ "@babel/parser" "^7.29.8"
+ "@babel/types" "^7.29.8"
+ "@jridgewell/gen-mapping" "^0.3.12"
+ "@jridgewell/trace-mapping" "^0.3.28"
+ jsesc "^3.0.2"
+
"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3":
version "7.27.3"
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5"
@@ -164,7 +164,14 @@
dependencies:
"@babel/types" "^7.27.3"
-"@babel/helper-compilation-targets@^7.26.5", "@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2":
+"@babel/helper-annotate-as-pure@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz#c70fe3c6ecbdc3fd2dd1b0f498428b88b82ce47f"
+ integrity sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==
+ dependencies:
+ "@babel/types" "^7.29.7"
+
+"@babel/helper-compilation-targets@^7.27.2":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d"
integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==
@@ -175,13 +182,13 @@
lru-cache "^5.1.1"
semver "^6.3.1"
-"@babel/helper-compilation-targets@^7.28.6":
- version "7.28.6"
- resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25"
- integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==
+"@babel/helper-compilation-targets@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042"
+ integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==
dependencies:
- "@babel/compat-data" "^7.28.6"
- "@babel/helper-validator-option" "^7.27.1"
+ "@babel/compat-data" "^7.29.7"
+ "@babel/helper-validator-option" "^7.29.7"
browserslist "^4.24.0"
lru-cache "^5.1.1"
semver "^6.3.1"
@@ -212,6 +219,19 @@
"@babel/traverse" "^7.28.6"
semver "^6.3.1"
+"@babel/helper-create-class-features-plugin@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz#6eddf286f2ec418f740c91d60a83347c55838ddd"
+ integrity sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==
+ dependencies:
+ "@babel/helper-annotate-as-pure" "^7.29.7"
+ "@babel/helper-member-expression-to-functions" "^7.29.7"
+ "@babel/helper-optimise-call-expression" "^7.29.7"
+ "@babel/helper-replace-supers" "^7.29.7"
+ "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7"
+ "@babel/traverse" "^7.29.7"
+ semver "^6.3.1"
+
"@babel/helper-create-regexp-features-plugin@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz#05b0882d97ba1d4d03519e4bce615d70afa18c53"
@@ -221,6 +241,15 @@
regexpu-core "^6.2.0"
semver "^6.3.1"
+"@babel/helper-create-regexp-features-plugin@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz#5d4c3f928f315cf6c4184ea2fc3b5b38745b2430"
+ integrity sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==
+ dependencies:
+ "@babel/helper-annotate-as-pure" "^7.29.7"
+ regexpu-core "^6.3.1"
+ semver "^6.3.1"
+
"@babel/helper-define-polyfill-provider@^0.6.5":
version "0.6.5"
resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz#742ccf1cb003c07b48859fc9fa2c1bbe40e5f753"
@@ -237,6 +266,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674"
integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
+"@babel/helper-globals@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b"
+ integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==
+
"@babel/helper-member-expression-to-functions@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz#ea1211276be93e798ce19037da6f06fbb994fa44"
@@ -253,6 +287,14 @@
"@babel/traverse" "^7.28.5"
"@babel/types" "^7.28.5"
+"@babel/helper-member-expression-to-functions@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz#8dbdb3ce0b5c487e1aec10e13c9a43a500814df8"
+ integrity sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==
+ dependencies:
+ "@babel/traverse" "^7.29.7"
+ "@babel/types" "^7.29.7"
+
"@babel/helper-module-imports@^7.25.9", "@babel/helper-module-imports@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204"
@@ -261,7 +303,15 @@
"@babel/traverse" "^7.27.1"
"@babel/types" "^7.27.1"
-"@babel/helper-module-transforms@^7.26.0", "@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.3":
+"@babel/helper-module-imports@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396"
+ integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==
+ dependencies:
+ "@babel/traverse" "^7.29.7"
+ "@babel/types" "^7.29.7"
+
+"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.3":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz#a2b37d3da3b2344fe085dab234426f2b9a2fa5f6"
integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==
@@ -270,6 +320,15 @@
"@babel/helper-validator-identifier" "^7.27.1"
"@babel/traverse" "^7.28.3"
+"@babel/helper-module-transforms@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae"
+ integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==
+ dependencies:
+ "@babel/helper-module-imports" "^7.29.7"
+ "@babel/helper-validator-identifier" "^7.29.7"
+ "@babel/traverse" "^7.29.7"
+
"@babel/helper-optimise-call-expression@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz#c65221b61a643f3e62705e5dd2b5f115e35f9200"
@@ -277,6 +336,13 @@
dependencies:
"@babel/types" "^7.27.1"
+"@babel/helper-optimise-call-expression@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz#77b0b5b94f1997fa9d6e3125f445227b1faf9d85"
+ integrity sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==
+ dependencies:
+ "@babel/types" "^7.29.7"
+
"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.8.0":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c"
@@ -287,6 +353,11 @@
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8"
integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==
+"@babel/helper-plugin-utils@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz#c0a0766f1a13617d8a17407d7ab8f9d486225ea4"
+ integrity sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==
+
"@babel/helper-remap-async-to-generator@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz#4601d5c7ce2eb2aea58328d43725523fcd362ce6"
@@ -314,6 +385,15 @@
"@babel/helper-optimise-call-expression" "^7.27.1"
"@babel/traverse" "^7.28.6"
+"@babel/helper-replace-supers@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz#bc3c3964329043c79112e513c1b198f16589ac21"
+ integrity sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==
+ dependencies:
+ "@babel/helper-member-expression-to-functions" "^7.29.7"
+ "@babel/helper-optimise-call-expression" "^7.29.7"
+ "@babel/traverse" "^7.29.7"
+
"@babel/helper-skip-transparent-expression-wrappers@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz#62bb91b3abba8c7f1fec0252d9dbea11b3ee7a56"
@@ -322,12 +402,25 @@
"@babel/traverse" "^7.27.1"
"@babel/types" "^7.27.1"
+"@babel/helper-skip-transparent-expression-wrappers@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz#50c95c7e4c4f54936cfa0116428edc559862d551"
+ integrity sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==
+ dependencies:
+ "@babel/traverse" "^7.29.7"
+ "@babel/types" "^7.29.7"
+
"@babel/helper-string-parser@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
-"@babel/helper-validator-identifier@^7.19.1", "@babel/helper-validator-identifier@^7.25.9", "@babel/helper-validator-identifier@^7.27.1":
+"@babel/helper-string-parser@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f"
+ integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==
+
+"@babel/helper-validator-identifier@^7.19.1", "@babel/helper-validator-identifier@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8"
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
@@ -337,11 +430,21 @@
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
+"@babel/helper-validator-identifier@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2"
+ integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==
+
"@babel/helper-validator-option@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f"
integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
+"@babel/helper-validator-option@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a"
+ integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==
+
"@babel/helper-wrap-function@^7.27.1":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz#fe4872092bc1438ffd0ce579e6f699609f9d0a7a"
@@ -351,7 +454,7 @@
"@babel/traverse" "^7.28.3"
"@babel/types" "^7.28.2"
-"@babel/helpers@^7.26.10", "@babel/helpers@^7.28.4":
+"@babel/helpers@^7.28.4":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.4.tgz#fe07274742e95bdf7cf1443593eeb8926ab63827"
integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==
@@ -359,17 +462,15 @@
"@babel/template" "^7.27.2"
"@babel/types" "^7.28.4"
-"@babel/highlight@^7.10.4":
- version "7.25.9"
- resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.25.9.tgz#8141ce68fc73757946f983b343f1231f4691acc6"
- integrity sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==
+"@babel/helpers@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607"
+ integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==
dependencies:
- "@babel/helper-validator-identifier" "^7.25.9"
- chalk "^2.4.2"
- js-tokens "^4.0.0"
- picocolors "^1.0.0"
+ "@babel/template" "^7.29.7"
+ "@babel/types" "^7.29.7"
-"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.24.4", "@babel/parser@^7.25.3", "@babel/parser@^7.26.10", "@babel/parser@^7.27.2", "@babel/parser@^7.28.3", "@babel/parser@^7.28.4":
+"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.24.4", "@babel/parser@^7.27.2", "@babel/parser@^7.28.3", "@babel/parser@^7.28.4":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8"
integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==
@@ -383,6 +484,13 @@
dependencies:
"@babel/types" "^7.29.0"
+"@babel/parser@^7.29.7", "@babel/parser@^7.29.8":
+ version "7.29.9"
+ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.9.tgz#e7ee0a24f752c5296c6455855ccb72662e67ea96"
+ integrity sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==
+ dependencies:
+ "@babel/types" "^7.29.8"
+
"@babel/plugin-proposal-decorators@^7.12.9":
version "7.28.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.28.0.tgz#419c8acc31088e05a774344c021800f7ddc39bf0"
@@ -491,6 +599,13 @@
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
+"@babel/plugin-syntax-jsx@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz#622c16f9ad63782fe6e83dadc7e40330744b7f1e"
+ integrity sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.29.7"
+
"@babel/plugin-syntax-logical-assignment-operators@^7.10.4":
version "7.10.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699"
@@ -554,19 +669,19 @@
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/plugin-syntax-typescript@^7.28.6":
- version "7.28.6"
- resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz#c7b2ddf1d0a811145b1de800d1abd146af92e3a2"
- integrity sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==
+"@babel/plugin-syntax-typescript@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz#7c29388932313ed58413a0343048d75d92fb5b24"
+ integrity sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==
dependencies:
- "@babel/helper-plugin-utils" "^7.28.6"
+ "@babel/helper-plugin-utils" "^7.29.7"
-"@babel/plugin-transform-arrow-functions@^7.0.0-0", "@babel/plugin-transform-arrow-functions@^7.24.7":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a"
- integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==
+"@babel/plugin-transform-arrow-functions@^7.27.1":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz#d651343f562c03f47951bd1802195d0e10605f27"
+ integrity sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==
dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/helper-plugin-utils" "^7.29.7"
"@babel/plugin-transform-async-generator-functions@^7.25.4":
version "7.28.0"
@@ -593,14 +708,6 @@
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/plugin-transform-class-properties@^7.0.0-0":
- version "7.28.6"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz#d274a4478b6e782d9ea987fda09bdb6d28d66b72"
- integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==
- dependencies:
- "@babel/helper-create-class-features-plugin" "^7.28.6"
- "@babel/helper-plugin-utils" "^7.28.6"
-
"@babel/plugin-transform-class-properties@^7.25.4":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz#dd40a6a370dfd49d32362ae206ddaf2bb082a925"
@@ -609,6 +716,14 @@
"@babel/helper-create-class-features-plugin" "^7.27.1"
"@babel/helper-plugin-utils" "^7.27.1"
+"@babel/plugin-transform-class-properties@^7.28.6":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz#034897b8a21beec163332fac2de235b14409abdf"
+ integrity sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==
+ dependencies:
+ "@babel/helper-create-class-features-plugin" "^7.29.7"
+ "@babel/helper-plugin-utils" "^7.29.7"
+
"@babel/plugin-transform-class-static-block@^7.27.1":
version "7.28.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz#1257491e8259c6d125ac4d9a6f39f9d2bf3dba70"
@@ -617,18 +732,6 @@
"@babel/helper-create-class-features-plugin" "^7.28.6"
"@babel/helper-plugin-utils" "^7.28.6"
-"@babel/plugin-transform-classes@^7.0.0-0":
- version "7.28.6"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz#8f6fb79ba3703978e701ce2a97e373aae7dda4b7"
- integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==
- dependencies:
- "@babel/helper-annotate-as-pure" "^7.27.3"
- "@babel/helper-compilation-targets" "^7.28.6"
- "@babel/helper-globals" "^7.28.0"
- "@babel/helper-plugin-utils" "^7.28.6"
- "@babel/helper-replace-supers" "^7.28.6"
- "@babel/traverse" "^7.28.6"
-
"@babel/plugin-transform-classes@^7.25.4":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.4.tgz#75d66175486788c56728a73424d67cbc7473495c"
@@ -641,13 +744,17 @@
"@babel/helper-replace-supers" "^7.27.1"
"@babel/traverse" "^7.28.4"
-"@babel/plugin-transform-computed-properties@^7.24.7":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz#81662e78bf5e734a97982c2b7f0a793288ef3caa"
- integrity sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==
+"@babel/plugin-transform-classes@^7.28.6":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz#61d3e5aaae0c838acc3204d9db7c8dc05c25815b"
+ integrity sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==
dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
- "@babel/template" "^7.27.1"
+ "@babel/helper-annotate-as-pure" "^7.29.7"
+ "@babel/helper-compilation-targets" "^7.29.7"
+ "@babel/helper-globals" "^7.29.7"
+ "@babel/helper-plugin-utils" "^7.29.7"
+ "@babel/helper-replace-supers" "^7.29.7"
+ "@babel/traverse" "^7.29.7"
"@babel/plugin-transform-destructuring@^7.24.8", "@babel/plugin-transform-destructuring@^7.28.0":
version "7.28.0"
@@ -680,22 +787,6 @@
"@babel/helper-plugin-utils" "^7.27.1"
"@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
-"@babel/plugin-transform-function-name@^7.25.1":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz#4d0bf307720e4dce6d7c30fcb1fd6ca77bdeb3a7"
- integrity sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==
- dependencies:
- "@babel/helper-compilation-targets" "^7.27.1"
- "@babel/helper-plugin-utils" "^7.27.1"
- "@babel/traverse" "^7.27.1"
-
-"@babel/plugin-transform-literals@^7.25.2":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz#baaefa4d10a1d4206f9dcdda50d7d5827bb70b24"
- integrity sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==
- dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
-
"@babel/plugin-transform-logical-assignment-operators@^7.24.7":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz#890cb20e0270e0e5bebe3f025b434841c32d5baa"
@@ -711,6 +802,14 @@
"@babel/helper-module-transforms" "^7.27.1"
"@babel/helper-plugin-utils" "^7.27.1"
+"@babel/plugin-transform-modules-commonjs@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz#70e6835abf2663dafbe94b8ef1f51de7351ef135"
+ integrity sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==
+ dependencies:
+ "@babel/helper-module-transforms" "^7.29.7"
+ "@babel/helper-plugin-utils" "^7.29.7"
+
"@babel/plugin-transform-named-capturing-groups-regex@^7.24.7":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz#f32b8f7818d8fc0cc46ee20a8ef75f071af976e1"
@@ -719,13 +818,6 @@
"@babel/helper-create-regexp-features-plugin" "^7.27.1"
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/plugin-transform-nullish-coalescing-operator@^7.0.0-0":
- version "7.28.6"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz#9bc62096e90ab7a887f3ca9c469f6adec5679757"
- integrity sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==
- dependencies:
- "@babel/helper-plugin-utils" "^7.28.6"
-
"@babel/plugin-transform-nullish-coalescing-operator@^7.24.7":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz#4f9d3153bf6782d73dd42785a9d22d03197bc91d"
@@ -733,12 +825,12 @@
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/plugin-transform-numeric-separator@^7.24.7":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz#614e0b15cc800e5997dadd9bd6ea524ed6c819c6"
- integrity sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==
+"@babel/plugin-transform-nullish-coalescing-operator@^7.28.6":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz#8a54cdf88c3f50433a6173117a286195b67714cc"
+ integrity sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==
dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/helper-plugin-utils" "^7.29.7"
"@babel/plugin-transform-object-rest-spread@^7.24.7":
version "7.28.4"
@@ -758,14 +850,6 @@
dependencies:
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/plugin-transform-optional-chaining@^7.0.0-0":
- version "7.28.6"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz#926cf150bd421fc8362753e911b4a1b1ce4356cd"
- integrity sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==
- dependencies:
- "@babel/helper-plugin-utils" "^7.28.6"
- "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
-
"@babel/plugin-transform-optional-chaining@^7.24.8":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz#874ce3c4f06b7780592e946026eb76a32830454f"
@@ -774,6 +858,14 @@
"@babel/helper-plugin-utils" "^7.27.1"
"@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
+"@babel/plugin-transform-optional-chaining@^7.28.6":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz#b84a1b574b3c73001023092567e16c492b720e51"
+ integrity sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.29.7"
+ "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7"
+
"@babel/plugin-transform-parameters@^7.24.7", "@babel/plugin-transform-parameters@^7.27.7":
version "7.27.7"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz#1fd2febb7c74e7d21cf3b05f7aebc907940af53a"
@@ -798,7 +890,7 @@
"@babel/helper-create-class-features-plugin" "^7.27.1"
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/plugin-transform-react-display-name@^7.24.7", "@babel/plugin-transform-react-display-name@^7.27.1":
+"@babel/plugin-transform-react-display-name@^7.24.7":
version "7.28.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz#6f20a7295fea7df42eb42fed8f896813f5b934de"
integrity sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==
@@ -812,21 +904,7 @@
dependencies:
"@babel/plugin-transform-react-jsx" "^7.27.1"
-"@babel/plugin-transform-react-jsx-self@^7.24.7":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz#af678d8506acf52c577cac73ff7fe6615c85fc92"
- integrity sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==
- dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
-
-"@babel/plugin-transform-react-jsx-source@^7.24.7":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz#dcfe2c24094bb757bf73960374e7c55e434f19f0"
- integrity sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==
- dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
-
-"@babel/plugin-transform-react-jsx@^7.25.2", "@babel/plugin-transform-react-jsx@^7.27.1":
+"@babel/plugin-transform-react-jsx@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz#1023bc94b78b0a2d68c82b5e96aed573bcfb9db0"
integrity sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==
@@ -837,6 +915,17 @@
"@babel/plugin-syntax-jsx" "^7.27.1"
"@babel/types" "^7.27.1"
+"@babel/plugin-transform-react-jsx@^7.28.6":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz#3d16a0e5773f079400a8c82a190709cdf92ee204"
+ integrity sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==
+ dependencies:
+ "@babel/helper-annotate-as-pure" "^7.29.7"
+ "@babel/helper-module-imports" "^7.29.7"
+ "@babel/helper-plugin-utils" "^7.29.7"
+ "@babel/plugin-syntax-jsx" "^7.29.7"
+ "@babel/types" "^7.29.7"
+
"@babel/plugin-transform-react-pure-annotations@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz#339f1ce355eae242e0649f232b1c68907c02e879"
@@ -845,13 +934,6 @@
"@babel/helper-annotate-as-pure" "^7.27.1"
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/plugin-transform-regenerator@^7.24.7":
- version "7.28.4"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.4.tgz#9d3fa3bebb48ddd0091ce5729139cd99c67cea51"
- integrity sha512-+ZEdQlBoRg9m2NnzvEeLgtvBMO4tkFBw5SQIUgLICgTrumLoU7lr+Oghi6km2PFj+dbUt2u1oby2w3BDO9YQnA==
- dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
-
"@babel/plugin-transform-runtime@^7.24.7":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.3.tgz#f5990a1b2d2bde950ed493915e0719841c8d0eaa"
@@ -864,34 +946,19 @@
babel-plugin-polyfill-regenerator "^0.6.5"
semver "^6.3.1"
-"@babel/plugin-transform-shorthand-properties@^7.0.0-0", "@babel/plugin-transform-shorthand-properties@^7.24.7":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz#532abdacdec87bfee1e0ef8e2fcdee543fe32b90"
- integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==
- dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
-
-"@babel/plugin-transform-spread@^7.24.7":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz#1a264d5fc12750918f50e3fe3e24e437178abb08"
- integrity sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==
- dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
- "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
-
-"@babel/plugin-transform-sticky-regex@^7.24.7":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz#18984935d9d2296843a491d78a014939f7dcd280"
- integrity sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==
+"@babel/plugin-transform-shorthand-properties@^7.27.1":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz#25c0436b98f4bd9ca4b98e1fbd662743bbaab9bf"
+ integrity sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==
dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/helper-plugin-utils" "^7.29.7"
-"@babel/plugin-transform-template-literals@^7.0.0-0":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8"
- integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==
+"@babel/plugin-transform-template-literals@^7.27.1":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz#ada97d8e0832bca8edb315888aa654b1570f3835"
+ integrity sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==
dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
+ "@babel/helper-plugin-utils" "^7.29.7"
"@babel/plugin-transform-typescript@^7.25.2", "@babel/plugin-transform-typescript@^7.27.1":
version "7.28.0"
@@ -904,18 +971,18 @@
"@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
"@babel/plugin-syntax-typescript" "^7.27.1"
-"@babel/plugin-transform-typescript@^7.28.5":
- version "7.28.6"
- resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz#1e93d96da8adbefdfdade1d4956f73afa201a158"
- integrity sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==
+"@babel/plugin-transform-typescript@^7.29.7":
+ version "7.29.9"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz#963dc446aea3fafcab8ffe217329263f5613feb6"
+ integrity sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==
dependencies:
- "@babel/helper-annotate-as-pure" "^7.27.3"
- "@babel/helper-create-class-features-plugin" "^7.28.6"
- "@babel/helper-plugin-utils" "^7.28.6"
- "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1"
- "@babel/plugin-syntax-typescript" "^7.28.6"
+ "@babel/helper-annotate-as-pure" "^7.29.7"
+ "@babel/helper-create-class-features-plugin" "^7.29.7"
+ "@babel/helper-plugin-utils" "^7.29.7"
+ "@babel/helper-skip-transparent-expression-wrappers" "^7.29.7"
+ "@babel/plugin-syntax-typescript" "^7.29.7"
-"@babel/plugin-transform-unicode-regex@^7.0.0-0", "@babel/plugin-transform-unicode-regex@^7.24.7":
+"@babel/plugin-transform-unicode-regex@^7.24.7":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97"
integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==
@@ -923,28 +990,13 @@
"@babel/helper-create-regexp-features-plugin" "^7.27.1"
"@babel/helper-plugin-utils" "^7.27.1"
-"@babel/preset-react@^7.22.15":
- version "7.27.1"
- resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.27.1.tgz#86ea0a5ca3984663f744be2fd26cb6747c3fd0ec"
- integrity sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA==
- dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
- "@babel/helper-validator-option" "^7.27.1"
- "@babel/plugin-transform-react-display-name" "^7.27.1"
- "@babel/plugin-transform-react-jsx" "^7.27.1"
- "@babel/plugin-transform-react-jsx-development" "^7.27.1"
- "@babel/plugin-transform-react-pure-annotations" "^7.27.1"
-
-"@babel/preset-typescript@^7.16.7":
- version "7.28.5"
- resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz#540359efa3028236958466342967522fd8f2a60c"
- integrity sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==
+"@babel/plugin-transform-unicode-regex@^7.27.1":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz#c3064b293ff7f1794b71f7650eec8db9896d3e59"
+ integrity sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==
dependencies:
- "@babel/helper-plugin-utils" "^7.27.1"
- "@babel/helper-validator-option" "^7.27.1"
- "@babel/plugin-syntax-jsx" "^7.27.1"
- "@babel/plugin-transform-modules-commonjs" "^7.27.1"
- "@babel/plugin-transform-typescript" "^7.28.5"
+ "@babel/helper-create-regexp-features-plugin" "^7.29.7"
+ "@babel/helper-plugin-utils" "^7.29.7"
"@babel/preset-typescript@^7.23.0":
version "7.27.1"
@@ -957,6 +1009,17 @@
"@babel/plugin-transform-modules-commonjs" "^7.27.1"
"@babel/plugin-transform-typescript" "^7.27.1"
+"@babel/preset-typescript@^7.28.5":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz#de9be1f47b785c979ec7b3a71f4cd8bae5267b62"
+ integrity sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==
+ dependencies:
+ "@babel/helper-plugin-utils" "^7.29.7"
+ "@babel/helper-validator-option" "^7.29.7"
+ "@babel/plugin-syntax-jsx" "^7.29.7"
+ "@babel/plugin-transform-modules-commonjs" "^7.29.7"
+ "@babel/plugin-transform-typescript" "^7.29.7"
+
"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.23.2", "@babel/runtime@^7.25.0", "@babel/runtime@^7.8.7":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
@@ -967,7 +1030,7 @@
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768"
integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==
-"@babel/template@^7.25.0", "@babel/template@^7.26.9", "@babel/template@^7.27.1", "@babel/template@^7.27.2", "@babel/template@^7.3.3":
+"@babel/template@^7.27.2", "@babel/template@^7.3.3":
version "7.27.2"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==
@@ -985,20 +1048,16 @@
"@babel/parser" "^7.28.6"
"@babel/types" "^7.28.6"
-"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3":
- version "7.28.4"
- resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.4.tgz#8d456101b96ab175d487249f60680221692b958b"
- integrity sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==
+"@babel/template@^7.29.7":
+ version "7.29.7"
+ resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
+ integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==
dependencies:
- "@babel/code-frame" "^7.27.1"
- "@babel/generator" "^7.28.3"
- "@babel/helper-globals" "^7.28.0"
- "@babel/parser" "^7.28.4"
- "@babel/template" "^7.27.2"
- "@babel/types" "^7.28.4"
- debug "^4.3.1"
+ "@babel/code-frame" "^7.29.7"
+ "@babel/parser" "^7.29.7"
+ "@babel/types" "^7.29.7"
-"@babel/traverse@^7.25.3", "@babel/traverse@^7.26.10", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.4":
+"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.4":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.4.tgz#8d456101b96ab175d487249f60680221692b958b"
integrity sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==
@@ -1024,7 +1083,20 @@
"@babel/types" "^7.29.0"
debug "^4.3.1"
-"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.25.2", "@babel/types@^7.26.10", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.3.3":
+"@babel/traverse@^7.29.7":
+ version "7.29.8"
+ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.8.tgz#4111014cdc71a0f95d9471907590baa0b8a6b28a"
+ integrity sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==
+ dependencies:
+ "@babel/code-frame" "^7.29.7"
+ "@babel/generator" "^7.29.8"
+ "@babel/helper-globals" "^7.29.7"
+ "@babel/parser" "^7.29.8"
+ "@babel/template" "^7.29.7"
+ "@babel/types" "^7.29.8"
+ debug "^4.3.1"
+
+"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.3.3":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a"
integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==
@@ -1040,6 +1112,14 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
+"@babel/types@^7.29.7", "@babel/types@^7.29.8":
+ version "7.29.8"
+ resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863"
+ integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==
+ dependencies:
+ "@babel/helper-string-parser" "^7.29.7"
+ "@babel/helper-validator-identifier" "^7.29.7"
+
"@bcoe/v8-coverage@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -1205,15 +1285,15 @@
"@types/conventional-commits-parser" "^5.0.0"
chalk "^5.3.0"
-"@config-plugins/react-native-callkeep@^11.0.0":
- version "11.0.0"
- resolved "https://registry.yarnpkg.com/@config-plugins/react-native-callkeep/-/react-native-callkeep-11.0.0.tgz#d97526bb7cebc3082701fd6b3c285a56479a0a66"
- integrity sha512-DsfpNU+VCgF9/fNU/BHxlJa1+RiMz0UpFDp4Yj86hk0uahA5sPHmMIThhMg4DJjdYL70U6wELSzsGYaAcsI4vQ==
+"@config-plugins/react-native-callkeep@14.0.1":
+ version "14.0.1"
+ resolved "https://registry.yarnpkg.com/@config-plugins/react-native-callkeep/-/react-native-callkeep-14.0.1.tgz#8ab589d3fb7acd461ead7246d82fd9c3883d063e"
+ integrity sha512-8OwcjQL6Jpz8quYKHgbgxBVW1DLJPeFWoob0JMyCicqscV864LFO2IM5XhY5QGGzhryzQ04kaSJBQOFqNDmuoQ==
-"@config-plugins/react-native-webrtc@~12.0.0":
- version "12.0.0"
- resolved "https://registry.yarnpkg.com/@config-plugins/react-native-webrtc/-/react-native-webrtc-12.0.0.tgz#2bad8d59fa8aeb1b311c82c8704259154b00805f"
- integrity sha512-EIYR+ArIOFBz8cEHSdPjxqFPhaN+nNeoPnI6iVStctYKZdl6AEgmXq6Qfpds6qyin1W4JP7wq2jJh32OsRfwxg==
+"@config-plugins/react-native-webrtc@15.0.1":
+ version "15.0.1"
+ resolved "https://registry.yarnpkg.com/@config-plugins/react-native-webrtc/-/react-native-webrtc-15.0.1.tgz#a8a31bce0e4677bda79fe85f4a7744252f409b04"
+ integrity sha512-1/RSRnMOWPqHmEJCGEhElzFvkXUiNkGYsbAIxJl/3+t9OW2l26Qsjo8Ei7v/OMJ7g0Ek6qnKkjCxYrrLSfJi2A==
"@cspotcode/source-map-support@^0.8.0":
version "0.8.1"
@@ -1222,10 +1302,10 @@
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
-"@dev-plugins/react-query@~0.2.0":
- version "0.2.0"
- resolved "https://registry.yarnpkg.com/@dev-plugins/react-query/-/react-query-0.2.0.tgz#623f53f082c550dca0601879dbe1423136f25bb2"
- integrity sha512-tbGfXiR4/Pd9V6oJGDqx/YFtNpHJ0jrfLhDnY6k9yZu2e5niuIStDVKDimZ3m+HYXQDw62Ydk5NFa6fcRq4soA==
+"@dev-plugins/react-query@0.4.0":
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/@dev-plugins/react-query/-/react-query-0.4.0.tgz#ac64fe1cbdac9bc8fd5efb0f69fa467c74e99557"
+ integrity sha512-/8M2a7DlVTjcdhb4D/E+aHUCOqSS6s53y3fFrOoCyLPxhhhkMujS91pARH6XbF/GRr6p6RBNBJQpWasI6uj7GA==
dependencies:
flatted "^3.3.1"
@@ -1433,36 +1513,42 @@
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2"
integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==
-"@expo/cli@54.0.27":
- version "54.0.27"
- resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-54.0.27.tgz#312988d267b7deff05ea722c6c258413f6966f97"
- integrity sha512-BxKOr6e8wmUwhGSyEVKY+CHWgS3Cre9ibgx1QWcdCenKTVKAz62qGmYL51q0lCcKg6vQY+4Usw9RK5Il4ZFaIA==
+"@expo-google-fonts/material-symbols@^0.4.1":
+ version "0.4.48"
+ resolved "https://registry.yarnpkg.com/@expo-google-fonts/material-symbols/-/material-symbols-0.4.48.tgz#f8a00bbcf23a0996286a5558432283663643eac1"
+ integrity sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==
+
+"@expo/cli@^57.0.27":
+ version "57.0.27"
+ resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-57.0.27.tgz#68573b46121c03e326f196c4c9fcad3336f5eb2f"
+ integrity sha512-Jauk4chxmpVG5ElrMLTCwjFP20jbYU7PB4l3LYV8j+4e/3HU5jdl7qTQ4eVy1AaDUE9Ia2NCQ5A9kar2V1gaLg==
dependencies:
- "@0no-co/graphql.web" "^1.0.8"
"@expo/code-signing-certificates" "^0.0.6"
- "@expo/config" "~12.0.14"
- "@expo/config-plugins" "~54.0.5"
+ "@expo/config" "~57.0.9"
+ "@expo/config-plugins" "~57.0.9"
"@expo/devcert" "^1.2.1"
- "@expo/env" "~2.0.12"
- "@expo/image-utils" "^0.8.8"
- "@expo/json-file" "^10.0.16"
- "@expo/metro" "~54.2.0"
- "@expo/metro-config" "~54.0.17"
- "@expo/osascript" "^2.3.8"
- "@expo/package-manager" "^1.9.11"
- "@expo/plist" "^0.4.9"
- "@expo/prebuild-config" "^54.0.9"
- "@expo/schema-utils" "^0.1.9"
- "@expo/spawn-async" "^1.7.2"
- "@expo/ws-tunnel" "^1.0.1"
- "@expo/xcpretty" "^4.3.0"
- "@react-native/dev-middleware" "0.81.5"
- "@urql/core" "^5.0.6"
- "@urql/exchange-retry" "^1.3.0"
+ "@expo/env" "~2.4.3"
+ "@expo/image-utils" "^0.11.5"
+ "@expo/inline-modules" "^0.1.7"
+ "@expo/json-file" "^11.0.1"
+ "@expo/log-box" "^57.0.4"
+ "@expo/metro" "~56.0.2"
+ "@expo/metro-config" "~57.0.12"
+ "@expo/metro-file-map" "^57.0.3"
+ "@expo/osascript" "^2.7.1"
+ "@expo/package-manager" "^1.13.1"
+ "@expo/plist" "^0.8.1"
+ "@expo/prebuild-config" "^57.0.16"
+ "@expo/require-utils" "^57.0.5"
+ "@expo/router-server" "^57.0.11"
+ "@expo/schema-utils" "^57.0.2"
+ "@expo/spawn-async" "^1.8.0"
+ "@expo/ws-tunnel" "^2.0.0"
+ "@expo/xcpretty" "^4.4.4"
+ "@react-native/dev-middleware" "0.86.3"
accepts "^1.3.8"
- agent-cli-detector "^0.1.2"
+ agent-cli-detector "0.1.7"
arg "^5.0.2"
- better-opn "~3.0.2"
bplist-creator "0.1.0"
bplist-parser "^0.3.1"
chalk "^4.0.0"
@@ -1470,38 +1556,32 @@
compression "^1.7.4"
connect "^3.7.0"
debug "^4.3.4"
- env-editor "^0.4.1"
- expo-server "^1.0.7"
- freeport-async "^2.0.0"
+ dnssd-advertise "^1.1.4"
+ expo-server "^57.0.3"
+ fetch-nodeshim "^0.4.10"
getenv "^2.0.0"
glob "^13.0.0"
lan-network "^0.2.1"
- minimatch "^9.0.0"
+ multitars "^1.0.2"
node-forge "^1.3.3"
npm-package-arg "^11.0.0"
ora "^3.4.0"
- picomatch "^4.0.3"
- pretty-bytes "^5.6.0"
+ picomatch "^4.0.4"
pretty-format "^29.7.0"
progress "^2.0.3"
prompts "^2.3.2"
- qrcode-terminal "0.11.0"
- require-from-string "^2.0.2"
- requireg "^0.2.2"
- resolve "^1.22.2"
resolve-from "^5.0.0"
- resolve.exports "^2.0.3"
+ sandbox-cli-detector "^0.2.0"
semver "^7.6.0"
send "^0.19.0"
slugify "^1.3.4"
- source-map-support "~0.5.21"
stacktrace-parser "^0.1.10"
structured-headers "^0.4.1"
- tar "^7.5.2"
terminal-link "^2.1.1"
- undici "^6.18.2"
+ toqr "^0.1.1"
wrap-ansi "^7.0.0"
ws "^8.12.1"
+ zod "^3.25.76"
"@expo/code-signing-certificates@^0.0.6":
version "0.0.6"
@@ -1510,88 +1590,45 @@
dependencies:
node-forge "^1.3.3"
-"@expo/config-plugins@~54.0.4":
- version "54.0.4"
- resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-54.0.4.tgz#b31cb16f6651342abcdafba600118245ecd9fb00"
- integrity sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==
+"@expo/config-plugins@^57.0.9", "@expo/config-plugins@~57.0.9":
+ version "57.0.9"
+ resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-57.0.9.tgz#3fa42fb565f3662e518f0accc1653a9ac25f73f3"
+ integrity sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==
dependencies:
- "@expo/config-types" "^54.0.10"
- "@expo/json-file" "~10.0.8"
- "@expo/plist" "^0.4.8"
+ "@expo/config-types" "^57.0.2"
+ "@expo/json-file" "~11.0.1"
+ "@expo/plist" "^0.8.1"
+ "@expo/require-utils" "^57.0.5"
"@expo/sdk-runtime-versions" "^1.0.0"
chalk "^4.1.2"
debug "^4.3.5"
getenv "^2.0.0"
glob "^13.0.0"
- resolve-from "^5.0.0"
semver "^7.5.4"
- slash "^3.0.0"
slugify "^1.6.6"
xcode "^3.0.1"
xml2js "0.6.0"
-"@expo/config-plugins@~54.0.5":
- version "54.0.5"
- resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-54.0.5.tgz#e8b2b127c2a8e85d82b6f36cdf7cc9cde9bfd085"
- integrity sha512-aWQ3sViNRoQWw6So4A2qhWCt24CuGBK6MrRHI1AG+V6/NQAjIZCHaSvcXK2gXKpmisRhTSUWaKPIgLJQFB+AeQ==
- dependencies:
- "@expo/config-types" "^54.0.10"
- "@expo/json-file" "~10.0.16"
- "@expo/plist" "^0.4.9"
- "@expo/sdk-runtime-versions" "^1.0.0"
- chalk "^4.1.2"
- debug "^4.3.5"
- getenv "^2.0.0"
- glob "^13.0.0"
- resolve-from "^5.0.0"
- semver "^7.5.4"
- slash "^3.0.0"
- slugify "^1.6.6"
- xcode "^3.0.1"
- xml2js "0.6.0"
-
-"@expo/config-types@^54.0.10":
- version "54.0.10"
- resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-54.0.10.tgz#688f4338255d2fdea970f44e2dfd8e8d37dec292"
- integrity sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==
-
-"@expo/config@~12.0.12", "@expo/config@~12.0.13":
- version "12.0.13"
- resolved "https://registry.yarnpkg.com/@expo/config/-/config-12.0.13.tgz#8e696e6121c3c364e1dd527f595cf0a1d9386828"
- integrity sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==
- dependencies:
- "@babel/code-frame" "~7.10.4"
- "@expo/config-plugins" "~54.0.4"
- "@expo/config-types" "^54.0.10"
- "@expo/json-file" "^10.0.8"
- deepmerge "^4.3.1"
- getenv "^2.0.0"
- glob "^13.0.0"
- require-from-string "^2.0.2"
- resolve-from "^5.0.0"
- resolve-workspace-root "^2.0.0"
- semver "^7.6.0"
- slugify "^1.3.4"
- sucrase "~3.35.1"
+"@expo/config-types@^57.0.2":
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-57.0.2.tgz#127da99326399e014b63cf23d71220719f1336a1"
+ integrity sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==
-"@expo/config@~12.0.14":
- version "12.0.14"
- resolved "https://registry.yarnpkg.com/@expo/config/-/config-12.0.14.tgz#cf23ec3886dc4f2b0fa7bfd6e6f2d3303138f926"
- integrity sha512-3dfbBd9LnPDgyylhCgkOsaG8Adg52uOVOTQYH5lf23a/t8M5eQpXKCvzUarrf62B78057n2NnkiofK9TfPgvzw==
+"@expo/config@^57.0.0", "@expo/config@~57.0.9":
+ version "57.0.9"
+ resolved "https://registry.yarnpkg.com/@expo/config/-/config-57.0.9.tgz#21528ed96a49e6bad7a2988628b9026c256ee4fe"
+ integrity sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ==
dependencies:
- "@babel/code-frame" "~7.10.4"
- "@expo/config-plugins" "~54.0.5"
- "@expo/config-types" "^54.0.10"
- "@expo/json-file" "^10.0.16"
+ "@expo/config-plugins" "~57.0.9"
+ "@expo/config-types" "^57.0.2"
+ "@expo/json-file" "^11.0.1"
+ "@expo/require-utils" "^57.0.5"
deepmerge "^4.3.1"
getenv "^2.0.0"
glob "^13.0.0"
- require-from-string "^2.0.2"
- resolve-from "^5.0.0"
resolve-workspace-root "^2.0.0"
semver "^7.6.0"
slugify "^1.3.4"
- sucrase "~3.35.1"
"@expo/devcert@^1.2.1":
version "1.2.1"
@@ -1601,41 +1638,39 @@
"@expo/sudo-prompt" "^9.3.1"
debug "^3.1.0"
-"@expo/devtools@0.1.8":
- version "0.1.8"
- resolved "https://registry.yarnpkg.com/@expo/devtools/-/devtools-0.1.8.tgz#bc5b297698f78b3b67037f04593a31e688330a7a"
- integrity sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==
+"@expo/devtools@~57.0.1":
+ version "57.0.1"
+ resolved "https://registry.yarnpkg.com/@expo/devtools/-/devtools-57.0.1.tgz#81a0e976f38c30774bfeec2f306f17bcdb99998a"
+ integrity sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==
dependencies:
chalk "^4.1.2"
-"@expo/env@~2.0.12":
- version "2.0.12"
- resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.0.12.tgz#7738baa640459c261d3d2219f4e1598d1cccb919"
- integrity sha512-wVfzeBGlUohZG5kS8QCqXurpuWZFJEkBB1wXCifai3EZ/Llcg/VMTiUCpAgHImD3lI7GIU3V1uI64c04XIo98Q==
- dependencies:
- chalk "^4.0.0"
- debug "^4.3.4"
- dotenv "~16.4.5"
- dotenv-expand "~11.0.6"
- getenv "^2.0.0"
+"@expo/dom-webview@^57.0.1", "@expo/dom-webview@~57.0.1":
+ version "57.0.1"
+ resolved "https://registry.yarnpkg.com/@expo/dom-webview/-/dom-webview-57.0.1.tgz#e41e9ab9b691046c944ef2d0cd874cf316894097"
+ integrity sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==
-"@expo/env@~2.0.8":
- version "2.0.11"
- resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.0.11.tgz#3a10d9142b1833566bdfb39de1c062f7a8b8ac38"
- integrity sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==
+"@expo/env@^2.4.3", "@expo/env@~2.4.3":
+ version "2.4.3"
+ resolved "https://registry.yarnpkg.com/@expo/env/-/env-2.4.3.tgz#cba412d1cf19374bedd83106b32166a12862667c"
+ integrity sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng==
dependencies:
chalk "^4.0.0"
debug "^4.3.4"
- dotenv "~16.4.5"
- dotenv-expand "~11.0.6"
getenv "^2.0.0"
-"@expo/fingerprint@0.15.5":
- version "0.15.5"
- resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.15.5.tgz#76968191026346657d22cea10d957aa8be97777e"
- integrity sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==
+"@expo/expo-modules-macros-plugin@0.6.1":
+ version "0.6.1"
+ resolved "https://registry.yarnpkg.com/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.6.1.tgz#5581bbe09e0aedb39ea787a9960eb0a9ee07d2ff"
+ integrity sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==
+
+"@expo/fingerprint@^0.20.13":
+ version "0.20.13"
+ resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.20.13.tgz#244496407df37a41bdb030261ff7db61aed2b48b"
+ integrity sha512-h1x+w+JpNRu88a/5SMZRzJs9YDGR/f7R8ApCr7OdenhqmJ00hFxQMzvxajDLL+1P2Y2g/ND0xksusqvBqWSW5Q==
dependencies:
- "@expo/spawn-async" "^1.7.2"
+ "@expo/env" "^2.4.3"
+ "@expo/spawn-async" "^1.8.0"
arg "^5.0.2"
chalk "^4.1.2"
debug "^4.3.4"
@@ -1643,7 +1678,6 @@
glob "^13.0.0"
ignore "^5.3.1"
minimatch "^10.2.2"
- p-limit "^3.1.0"
resolve-from "^5.0.0"
semver "^7.6.0"
@@ -1652,36 +1686,27 @@
resolved "https://registry.yarnpkg.com/@expo/html-elements/-/html-elements-0.10.1.tgz#ec2625370cf1d4cb78efa954df45d422532d5ab6"
integrity sha512-3PTmtkV15D7+lykXVtvkH1jQ5Y6JE+e3zCaoMMux7z2cSLGQUNwDEUwG37gew3OEB1/E4/SEWgjvg8m7E6/e2Q==
-"@expo/image-utils@^0.8.8":
- version "0.8.13"
- resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.8.13.tgz#c7476352af9f576440e5ec8201c2f75f090a4804"
- integrity sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==
+"@expo/image-utils@^0.11.5":
+ version "0.11.5"
+ resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.11.5.tgz#47cd85c9d09ab2fb155823da62b71b9ae0f7ffa7"
+ integrity sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ==
dependencies:
- "@expo/require-utils" "^55.0.4"
- "@expo/spawn-async" "^1.7.2"
+ "@expo/require-utils" "^57.0.5"
+ "@expo/spawn-async" "^1.8.0"
chalk "^4.0.0"
getenv "^2.0.0"
jimp-compact "0.16.1"
parse-png "^2.1.0"
semver "^7.6.0"
-"@expo/json-file@^10.0.16":
- version "10.2.0"
- resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-10.2.0.tgz#a63fca4445f2457a60407a3d76da8bfd0221e9a4"
- integrity sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==
- dependencies:
- "@babel/code-frame" "^7.20.0"
- json5 "^2.2.3"
-
-"@expo/json-file@^10.0.8", "@expo/json-file@~10.0.8":
- version "10.0.13"
- resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-10.0.13.tgz#1a9ac56333786e8672181b0b95aab08f8255a548"
- integrity sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==
+"@expo/inline-modules@^0.1.7":
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/@expo/inline-modules/-/inline-modules-0.1.7.tgz#43bc725ce4993496693040d30c36c38fe542cdd8"
+ integrity sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g==
dependencies:
- "@babel/code-frame" "^7.20.0"
- json5 "^2.2.3"
+ "@expo/config-plugins" "~57.0.9"
-"@expo/json-file@^11.0.1":
+"@expo/json-file@^11.0.1", "@expo/json-file@~11.0.1":
version "11.0.1"
resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-11.0.1.tgz#dab8f6e5ff78e7c33a8e49a48722ce972306036c"
integrity sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==
@@ -1689,79 +1714,103 @@
"@babel/code-frame" "^7.20.0"
json5 "^2.2.3"
-"@expo/json-file@~10.0.16":
- version "10.0.16"
- resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-10.0.16.tgz#5c715dd360178f64ed3a23a0d7ea2d95ac9c09cd"
- integrity sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==
+"@expo/local-build-cache-provider@^57.0.8":
+ version "57.0.8"
+ resolved "https://registry.yarnpkg.com/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.8.tgz#8d64ce4045734dbe735aa20b1cd0209347725a92"
+ integrity sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg==
dependencies:
- "@babel/code-frame" "~7.10.4"
- json5 "^2.2.3"
+ "@expo/config" "~57.0.9"
+ chalk "^4.1.2"
-"@expo/metro-config@54.0.17", "@expo/metro-config@~54.0.17":
- version "54.0.17"
- resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-54.0.17.tgz#a0d7eb8dba21e60694f37ed56c624fd172ef2bcd"
- integrity sha512-PQFgQCZGY0DffZUvBzJttDPreZfHrQakaBlKjnvOUMNXbDna+TYmg1IFZuIDUYJezLcdp+TvVFTLjNi1+mqaVw==
+"@expo/log-box@^57.0.4":
+ version "57.0.4"
+ resolved "https://registry.yarnpkg.com/@expo/log-box/-/log-box-57.0.4.tgz#373148b27267f70fa093516a46c9abbbd0f0df0a"
+ integrity sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg==
+ dependencies:
+ "@expo/dom-webview" "^57.0.1"
+ anser "^1.4.9"
+ stacktrace-parser "^0.1.10"
+
+"@expo/metro-config@~57.0.12":
+ version "57.0.12"
+ resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-57.0.12.tgz#8955eb416ac07c86c5b9f0bd1de7bc2cf1486bab"
+ integrity sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA==
dependencies:
"@babel/code-frame" "^7.20.0"
"@babel/core" "^7.20.0"
"@babel/generator" "^7.20.5"
- "@expo/config" "~12.0.14"
- "@expo/env" "~2.0.12"
- "@expo/json-file" "~10.0.16"
- "@expo/metro" "~54.2.0"
- "@expo/spawn-async" "^1.7.2"
+ "@expo/config" "~57.0.9"
+ "@expo/env" "~2.4.3"
+ "@expo/json-file" "~11.0.1"
+ "@expo/metro" "~56.0.2"
+ "@expo/require-utils" "^57.0.5"
+ "@expo/spawn-async" "^1.8.0"
+ "@jridgewell/gen-mapping" "^0.3.13"
+ "@jridgewell/remapping" "^2.3.5"
+ "@jridgewell/sourcemap-codec" "^1.5.5"
browserslist "^4.25.0"
chalk "^4.1.0"
debug "^4.3.2"
- dotenv "~16.4.5"
- dotenv-expand "~11.0.6"
getenv "^2.0.0"
glob "^13.0.0"
- hermes-parser "^0.29.1"
+ hermes-parser "^0.36.0"
jsc-safe-url "^0.2.4"
lightningcss "^1.30.1"
- picomatch "^4.0.3"
- postcss "~8.4.32"
+ picomatch "^4.0.4"
+ postcss "^8.5.14"
resolve-from "^5.0.0"
-"@expo/metro-runtime@^6.1.2", "@expo/metro-runtime@~6.1.2":
- version "6.1.2"
- resolved "https://registry.yarnpkg.com/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz#5a4ff117df6115f9c9d4dcc561065e16d69c078b"
- integrity sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==
+"@expo/metro-file-map@^57.0.3":
+ version "57.0.3"
+ resolved "https://registry.yarnpkg.com/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz#1ed09151ac17f594fc08051de15894cdfef07503"
+ integrity sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==
+ dependencies:
+ debug "^4.3.4"
+ fb-watchman "^2.0.2"
+ invariant "^2.2.4"
+ jest-worker "^29.7.0"
+ micromatch "^4.0.4"
+ walker "^1.0.8"
+
+"@expo/metro-runtime@^57.0.16", "@expo/metro-runtime@~57.0.16":
+ version "57.0.16"
+ resolved "https://registry.yarnpkg.com/@expo/metro-runtime/-/metro-runtime-57.0.16.tgz#01570c6564c53e2529f4d4c81ea6c3dd862b8e88"
+ integrity sha512-x43WgZoICpp37BhDV7vixe0bNxbVPPGaBLHYc98ZJ8MU+UC2dk39Y2XvkfYMRRbAUSIkCIJiwdERnptlPg0Ckw==
dependencies:
+ "@expo/log-box" "^57.0.4"
anser "^1.4.9"
pretty-format "^29.7.0"
stacktrace-parser "^0.1.10"
whatwg-fetch "^3.0.0"
-"@expo/metro@~54.2.0":
- version "54.2.0"
- resolved "https://registry.yarnpkg.com/@expo/metro/-/metro-54.2.0.tgz#6ecf4a77ae7553b73daca4206854728de76c854d"
- integrity sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==
- dependencies:
- metro "0.83.3"
- metro-babel-transformer "0.83.3"
- metro-cache "0.83.3"
- metro-cache-key "0.83.3"
- metro-config "0.83.3"
- metro-core "0.83.3"
- metro-file-map "0.83.3"
- metro-minify-terser "0.83.3"
- metro-resolver "0.83.3"
- metro-runtime "0.83.3"
- metro-source-map "0.83.3"
- metro-symbolicate "0.83.3"
- metro-transform-plugins "0.83.3"
- metro-transform-worker "0.83.3"
-
-"@expo/osascript@^2.3.8":
- version "2.4.2"
- resolved "https://registry.yarnpkg.com/@expo/osascript/-/osascript-2.4.2.tgz#fe341cff1eb2c939da43cf58ade5504c8a5d77ca"
- integrity sha512-/XP7PSYF2hzOZzqfjgkoWtllyeTN8dW3aM4P6YgKcmmPikKL5FdoyQhti4eh6RK5a5VrUXJTOlTNIpIHsfB5Iw==
+"@expo/metro@~56.0.2":
+ version "56.0.2"
+ resolved "https://registry.yarnpkg.com/@expo/metro/-/metro-56.0.2.tgz#f5371f203c00788c7037897616d5efc9efeeb74a"
+ integrity sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==
+ dependencies:
+ metro "0.84.5"
+ metro-babel-transformer "0.84.5"
+ metro-cache "0.84.5"
+ metro-cache-key "0.84.5"
+ metro-config "0.84.5"
+ metro-core "0.84.5"
+ metro-file-map "0.84.5"
+ metro-minify-terser "0.84.5"
+ metro-resolver "0.84.5"
+ metro-runtime "0.84.5"
+ metro-source-map "0.84.5"
+ metro-symbolicate "0.84.5"
+ metro-transform-plugins "0.84.5"
+ metro-transform-worker "0.84.5"
+
+"@expo/osascript@^2.7.1":
+ version "2.7.1"
+ resolved "https://registry.yarnpkg.com/@expo/osascript/-/osascript-2.7.1.tgz#1880dcbaf79cde9631e0e68f461f05c8f0542b88"
+ integrity sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==
dependencies:
- "@expo/spawn-async" "^1.7.2"
+ "@expo/spawn-async" "^1.8.0"
-"@expo/package-manager@^1.9.11":
+"@expo/package-manager@^1.13.1":
version "1.13.1"
resolved "https://registry.yarnpkg.com/@expo/package-manager/-/package-manager-1.13.1.tgz#a6ce56c602a70e33ed376525dd100945e19006f9"
integrity sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==
@@ -1773,87 +1822,57 @@
ora "^3.4.0"
resolve-workspace-root "^2.0.0"
-"@expo/plist@^0.4.8":
- version "0.4.8"
- resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.4.8.tgz#e014511a4a5008cf2b832b91caa8e9f2704127cc"
- integrity sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==
+"@expo/plist@^0.8.1":
+ version "0.8.1"
+ resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.8.1.tgz#8f024f23d7b9cf45cfff4a6f8871d1c107a8833c"
+ integrity sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==
dependencies:
"@xmldom/xmldom" "^0.8.8"
- base64-js "^1.2.3"
+ base64-js "^1.5.1"
xmlbuilder "^15.1.1"
-"@expo/plist@^0.4.9":
- version "0.4.9"
- resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.4.9.tgz#acf90bac48827e6467897bb8e6c45587ede7d715"
- integrity sha512-MPVpmKGfnQEnrCzgxuXcmPP/y/t6AVm+DcSb2Myp21LKWv1N3l8uFxMggesfF4ixAxkRlGmMMx9GyDC9M+XklQ==
+"@expo/prebuild-config@^57.0.16":
+ version "57.0.16"
+ resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-57.0.16.tgz#64e7697d93ed332e60c85730cd8d863619c177cf"
+ integrity sha512-QA8oMZ7y70kjOzw+abhRl9uYC8Nu/abeUTHr0YVxb3pgIC14gc7w543bYtI+5gEDI3q55jKUViIauC76QvZN1w==
dependencies:
- "@xmldom/xmldom" "^0.8.8"
- base64-js "^1.2.3"
- xmlbuilder "^15.1.1"
-
-"@expo/prebuild-config@^54.0.8":
- version "54.0.8"
- resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz#509410345489cc52d1e6ece52742384efe7ad7c6"
- integrity sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==
- dependencies:
- "@expo/config" "~12.0.13"
- "@expo/config-plugins" "~54.0.4"
- "@expo/config-types" "^54.0.10"
- "@expo/image-utils" "^0.8.8"
- "@expo/json-file" "^10.0.8"
- "@react-native/normalize-colors" "0.81.5"
- debug "^4.3.1"
- resolve-from "^5.0.0"
- semver "^7.6.0"
- xml2js "0.6.0"
-
-"@expo/prebuild-config@^54.0.9":
- version "54.0.9"
- resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-54.0.9.tgz#1021686511749fed2f5be3ff489793d7434367ec"
- integrity sha512-3/Rmyzt8vduPjnSVHbnc0wYFrlhwLWn2g596rDyKcLGeqN2WTLJbzVeznsrUwyzhBNXgnTomZWO5HDzbZ/4E7g==
- dependencies:
- "@expo/config" "~12.0.14"
- "@expo/config-plugins" "~54.0.5"
- "@expo/config-types" "^54.0.10"
- "@expo/image-utils" "^0.8.8"
- "@expo/json-file" "^10.0.16"
- "@react-native/normalize-colors" "0.81.5"
+ "@expo/config" "~57.0.9"
+ "@expo/config-plugins" "~57.0.9"
+ "@expo/config-types" "^57.0.2"
+ "@expo/image-utils" "^0.11.5"
+ "@expo/json-file" "^11.0.1"
+ "@react-native/normalize-colors" "0.86.3"
debug "^4.3.1"
+ expo-modules-autolinking "~57.0.13"
resolve-from "^5.0.0"
semver "^7.6.0"
- xml2js "0.6.0"
-"@expo/require-utils@^55.0.4":
- version "55.0.4"
- resolved "https://registry.yarnpkg.com/@expo/require-utils/-/require-utils-55.0.4.tgz#cd474a8997ba6ecfa43d084a7f17bde0cb854179"
- integrity sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==
+"@expo/require-utils@^57.0.5":
+ version "57.0.5"
+ resolved "https://registry.yarnpkg.com/@expo/require-utils/-/require-utils-57.0.5.tgz#bde8d170b0b53fa1cd77aa73a305e5a16f4229a3"
+ integrity sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==
dependencies:
"@babel/code-frame" "^7.20.0"
"@babel/core" "^7.25.2"
"@babel/plugin-transform-modules-commonjs" "^7.24.8"
-"@expo/schema-utils@^0.1.8":
- version "0.1.8"
- resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-0.1.8.tgz#8b9543d77fc4ac4954196e3fa00f8fcedd71426a"
- integrity sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==
+"@expo/router-server@^57.0.11":
+ version "57.0.11"
+ resolved "https://registry.yarnpkg.com/@expo/router-server/-/router-server-57.0.11.tgz#54456fefa3085413db15da66f1e5e258286bc9e0"
+ integrity sha512-0QxbqEkceb1WDeu5VqNdnTPVHlm3U183F7Y1t5LMohmGRAkHVL0PB4tCHBvEqKpLzOzHVObw3reGkJ1tX48h8Q==
+ dependencies:
+ debug "^4.3.4"
-"@expo/schema-utils@^0.1.9":
- version "0.1.9"
- resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-0.1.9.tgz#48fa4971f760de7a6427a56f02aaf89889929316"
- integrity sha512-t9bYwG4Z0yCVzHYJoDMci1OFq2FkBkhStlfUGSkspKYTwB/84+x6sY+CXCgdhkQNQtvWaugW5KUs9YZfAXq9Sg==
+"@expo/schema-utils@^57.0.2":
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/@expo/schema-utils/-/schema-utils-57.0.2.tgz#abbce2902bd23a338691d600815a6093cc6f4403"
+ integrity sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==
"@expo/sdk-runtime-versions@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz#d7ebd21b19f1c6b0395e50d78da4416941c57f7c"
integrity sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==
-"@expo/spawn-async@^1.7.2":
- version "1.7.2"
- resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58"
- integrity sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==
- dependencies:
- cross-spawn "^7.0.3"
-
"@expo/spawn-async@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.8.0.tgz#915e2ab38554fca7d788e16032078554dee633cf"
@@ -1866,40 +1885,49 @@
resolved "https://registry.yarnpkg.com/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz#0fd2813402a42988e49145cab220e25bea74b308"
integrity sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==
-"@expo/vector-icons@^15.0.3":
- version "15.1.1"
- resolved "https://registry.yarnpkg.com/@expo/vector-icons/-/vector-icons-15.1.1.tgz#4b1d2c60493c0b0536972f0a5babd5f5c85b48f4"
- integrity sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==
+"@expo/ui@^57.0.20":
+ version "57.0.20"
+ resolved "https://registry.yarnpkg.com/@expo/ui/-/ui-57.0.20.tgz#86d9f1404888a62d387a97f2205e75220b8f6ed8"
+ integrity sha512-0ChmuyWBwEy3JHMR5wDkJGyvkTR5zH3BTwjJrv3f2ymtamhpU9hD5uH4GsLoWuB6MJFOPTuchq4zByZbtgPyNw==
+ dependencies:
+ sf-symbols-typescript "^2.1.0"
+ vaul "^1.1.2"
-"@expo/ws-tunnel@^1.0.1":
- version "1.0.6"
- resolved "https://registry.yarnpkg.com/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz#92b70e7264ad42ea07f28a20f2f540b91d07bdd9"
- integrity sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==
+"@expo/ws-tunnel@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz#32d5fae46c7deb34cdf6b319a53b8a19b7915c93"
+ integrity sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==
-"@expo/xcpretty@^4.3.0":
- version "4.3.2"
- resolved "https://registry.yarnpkg.com/@expo/xcpretty/-/xcpretty-4.3.2.tgz#12dba1295167a9c8dde4be783d74f7e81648ca5d"
- integrity sha512-ReZxZ8pdnoI3tP/dNnJdnmAk7uLT4FjsKDGW7YeDdvdOMz2XCQSmSCM9IWlrXuWtMF9zeSB6WJtEhCQ41gQOfw==
+"@expo/xcpretty@^4.4.4":
+ version "4.4.5"
+ resolved "https://registry.yarnpkg.com/@expo/xcpretty/-/xcpretty-4.4.5.tgz#82535301f1d76e10ca4fb0df23191db4066db0d2"
+ integrity sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==
dependencies:
- "@babel/code-frame" "7.10.4"
+ "@babel/code-frame" "^7.20.0"
chalk "^4.1.0"
- find-up "^5.0.0"
js-yaml "^4.1.0"
-"@floating-ui/core@^1.6.0", "@floating-ui/core@^1.7.3":
+"@floating-ui/core@^1.7.3":
version "1.7.3"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.3.tgz#462d722f001e23e46d86fd2bd0d21b7693ccb8b7"
integrity sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==
dependencies:
"@floating-ui/utils" "^0.2.10"
-"@floating-ui/dom@1.6.13":
- version "1.6.13"
- resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34"
- integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==
+"@floating-ui/core@^1.7.5":
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.8.0.tgz#d01c0bbea02e4a57f6fd7d5de6fc2c5c7dca40e1"
+ integrity sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==
+ dependencies:
+ "@floating-ui/utils" "^0.2.12"
+
+"@floating-ui/dom@1.7.6":
+ version "1.7.6"
+ resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.6.tgz#f915bba5abbb177e1f227cacee1b4d0634b187bf"
+ integrity sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==
dependencies:
- "@floating-ui/core" "^1.6.0"
- "@floating-ui/utils" "^0.2.9"
+ "@floating-ui/core" "^1.7.5"
+ "@floating-ui/utils" "^0.2.11"
"@floating-ui/dom@^1.6.13":
version "1.7.4"
@@ -1909,11 +1937,16 @@
"@floating-ui/core" "^1.7.3"
"@floating-ui/utils" "^0.2.10"
-"@floating-ui/utils@^0.2.10", "@floating-ui/utils@^0.2.9":
+"@floating-ui/utils@^0.2.10":
version "0.2.10"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.10.tgz#a2a1e3812d14525f725d011a73eceb41fef5bc1c"
integrity sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==
+"@floating-ui/utils@^0.2.11", "@floating-ui/utils@^0.2.12":
+ version "0.2.12"
+ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.12.tgz#afefe785949f16ac4cdd1e695935a321572dd56a"
+ integrity sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==
+
"@formatjs/ecma402-abstract@2.3.4":
version "2.3.4"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz#e90c5a846ba2b33d92bc400fdd709da588280fbc"
@@ -2083,11 +2116,6 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3"
integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==
-"@ide/backoff@^1.0.0":
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/@ide/backoff/-/backoff-1.0.0.tgz#466842c25bd4a4833e0642fab41ccff064010176"
- integrity sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==
-
"@inquirer/external-editor@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@inquirer/external-editor/-/external-editor-1.0.2.tgz#dc16e7064c46c53be09918db639ff780718c071a"
@@ -2679,7 +2707,7 @@
dependencies:
regenerator-runtime "^0.13.3"
-"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5":
+"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.13", "@jridgewell/gen-mapping@^0.3.5":
version "0.3.13"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
@@ -2741,65 +2769,66 @@
resolved "https://registry.yarnpkg.com/@legendapp/tools/-/tools-2.0.1.tgz#995fe6cb3e2398b939f645505aa8e1abc84bd07f"
integrity sha512-Kxt0HWvWElRK6oybHRzcYxdgaKGwuaiRNreS7usW7QuHXRIHaH4yMcW2YNRG4DHE5fpefv+Bno/BohQcCE4FaA==
-"@livekit/components-core@0.12.9":
- version "0.12.9"
- resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.12.9.tgz#87502ed28a61c65db8306b684073e0dd26c86da2"
- integrity sha512-bwrZsHf6GaHIO+lLyA6Yps1STTX9YIeL3ixwt+Ufi88OgkNYdp41Ug8oeVDlf7tzdxa+r3Xkfaj/qvIG84Yo6A==
+"@livekit/components-core@0.12.15":
+ version "0.12.15"
+ resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.12.15.tgz#03187c5460bf3f2f7a3094b96d53006fcb0adc57"
+ integrity sha512-bDkK+jkPMgyT4a5lZmdcuc/hZ8fZUrXW+qQPNHVeSpXCBMzk5q9QWzx+HPtTsPJ8hnncWGfoT81RnXHBsy+Z8g==
dependencies:
- "@floating-ui/dom" "1.6.13"
+ "@floating-ui/dom" "1.7.6"
loglevel "1.9.1"
rxjs "7.8.2"
-"@livekit/components-react@^2.8.1":
- version "2.9.14"
- resolved "https://registry.yarnpkg.com/@livekit/components-react/-/components-react-2.9.14.tgz#d0b26ccbfe419c7e9191d41a5e5151e10f057cdb"
- integrity sha512-fQ3t4PdcM+AORo62FWmJcfqWe7ODwVaU4nsqxse+fp6L5a+0K2uMD7yQ2jrutXIaUQigU/opzTUxPcpdk9+0ow==
+"@livekit/components-react@^2.9.17":
+ version "2.9.24"
+ resolved "https://registry.yarnpkg.com/@livekit/components-react/-/components-react-2.9.24.tgz#a63bc6cd409e6faa8bfe6139ef901e5c1eb800d3"
+ integrity sha512-qw5Oy1EfPg1f/xrKYrFielSrmtxY8BZ9mDt6299G5aikotQIztmldBfTNosrVS7VSNit7qeYfOFSyKaML20W6Q==
dependencies:
- "@livekit/components-core" "0.12.9"
+ "@livekit/components-core" "0.12.15"
clsx "2.1.1"
+ events "^3.3.0"
+ jose "^6.0.12"
usehooks-ts "3.1.1"
-"@livekit/mutex@1.1.1":
+"@livekit/mutex@1.1.1", "@livekit/mutex@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@livekit/mutex/-/mutex-1.1.1.tgz#72492b611d55be8130ba2271b7a436d94b1bc6d4"
integrity sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==
-"@livekit/protocol@1.39.3":
- version "1.39.3"
- resolved "https://registry.yarnpkg.com/@livekit/protocol/-/protocol-1.39.3.tgz#dfbb801f6de232d64d918e0ad796268ff709cc35"
- integrity sha512-hfOnbwPCeZBEvMRdRhU2sr46mjGXavQcrb3BFRfG+Gm0Z7WUSeFdy5WLstXJzEepz17Iwp/lkGwJ4ZgOOYfPuA==
+"@livekit/protocol@1.50.4":
+ version "1.50.4"
+ resolved "https://registry.yarnpkg.com/@livekit/protocol/-/protocol-1.50.4.tgz#31495741dd786924b8ba6e190dd2a6e5d3711c51"
+ integrity sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==
dependencies:
"@bufbuild/protobuf" "^1.10.0"
-"@livekit/react-native-expo-plugin@^1.0.1":
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/@livekit/react-native-expo-plugin/-/react-native-expo-plugin-1.0.1.tgz#8472299b1b399209463347ed27de7b0f743d700d"
- integrity sha512-CSPjjzgDDlBH1ZyFyaw7/FW2Ql1S51eUkIxv/vjGwVshn+lUD6eQ9VgfUh7ha84itvjXi9X87FvP0XWKn9CiFQ==
+"@livekit/react-native-expo-plugin@1.0.2":
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/@livekit/react-native-expo-plugin/-/react-native-expo-plugin-1.0.2.tgz#d6df2cf441c98755e53d79e68b782af8155375bd"
+ integrity sha512-Crnht6xAlvbSo677oXFBMzS6I5lCMfCNNyLIs6bQZOwlwempz6tWj4CddRknqCCd/dvAyHCa1HwslqXpvGoSJA==
-"@livekit/react-native-webrtc@^137.0.2":
- version "137.0.2"
- resolved "https://registry.yarnpkg.com/@livekit/react-native-webrtc/-/react-native-webrtc-137.0.2.tgz#5fbd7876e6768a2247d04ef5653c373d92d74783"
- integrity sha512-0aXYATcBraOMDTteKzmfH5ICNHw8xFyMPHmhKg14+94fAGZ2hGjdHZUSkzL14+e508W486aIAmbXipuSQCCJgA==
+"@livekit/react-native-webrtc@144.1.2":
+ version "144.1.2"
+ resolved "https://registry.yarnpkg.com/@livekit/react-native-webrtc/-/react-native-webrtc-144.1.2.tgz#1a4b05f33cebcc4522e806f2d93ecad636b5e91b"
+ integrity sha512-Ls8w3FGlf67SMNu6r8SUpbFHXMIkyEEGb25j6apZ7HrBSGqOFSRWAx7ms9z5d0D3e9Zs1AMNiziG1YWBLOtqiQ==
dependencies:
base64-js "1.5.1"
debug "4.3.4"
- event-target-shim "6.0.2"
-"@livekit/react-native@^2.9.1":
- version "2.9.1"
- resolved "https://registry.yarnpkg.com/@livekit/react-native/-/react-native-2.9.1.tgz#1d0d9a40ebecd49e6c088cbe0d62b6380c1ff7ca"
- integrity sha512-oN1aWQeAuo3NhhrQRCI/V9upi8PhRQRFyZHBqnU/AlxYFyTKWKGmtVJtJWkGW+zNUF3lEF+nZMnpIHft9aJdqQ==
+"@livekit/react-native@2.12.0":
+ version "2.12.0"
+ resolved "https://registry.yarnpkg.com/@livekit/react-native/-/react-native-2.12.0.tgz#aaf484ad26a1480d840f713865ee43c2981207ab"
+ integrity sha512-6n4PQr22vtbl6WL6QbLVCLzM5/bCdTt+hHUyMecL8AheEuypVwbhMe5Vj7UuxOeyNojqXdgi2yRWGhB9WH4Gxg==
dependencies:
- "@livekit/components-react" "^2.8.1"
+ "@livekit/components-react" "^2.9.17"
+ "@livekit/mutex" "^1.1.1"
array.prototype.at "^1.1.1"
- event-target-shim "6.0.2"
+ base64-js "1.5.1"
events "^3.3.0"
loglevel "^1.8.0"
promise.allsettled "^1.0.5"
- react-native-quick-base64 "2.1.1"
- react-native-url-polyfill "^1.3.0"
+ react-native-url-polyfill "^3.0.0"
typed-emitter "^2.1.0"
- web-streams-polyfill "^4.1.0"
+ web-streams-polyfill "^4.3.0"
well-known-symbols "^4.1.0"
"@malept/cross-spawn-promise@^2.0.0":
@@ -3079,6 +3108,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
+"@radix-ui/react-compose-refs@1.1.5":
+ version "1.1.5"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz#883e642c5ec0ba393dd1bf586e7e772985770d87"
+ integrity sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==
+
"@radix-ui/react-context@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
@@ -3179,13 +3213,6 @@
"@radix-ui/react-use-callback-ref" "1.1.1"
"@radix-ui/react-use-controllable-state" "1.2.2"
-"@radix-ui/react-slot@1.2.0":
- version "1.2.0"
- resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.0.tgz#57727fc186ddb40724ccfbe294e1a351d92462ba"
- integrity sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==
- dependencies:
- "@radix-ui/react-compose-refs" "1.1.2"
-
"@radix-ui/react-slot@1.2.3":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1"
@@ -3193,6 +3220,13 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
+"@radix-ui/react-slot@^1.2.0":
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.3.3.tgz#ccc8c8936fff12f7e324d6917fe5167357097676"
+ integrity sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==
+ dependencies:
+ "@radix-ui/react-compose-refs" "1.1.5"
+
"@radix-ui/react-tabs@^1.1.12":
version "1.1.13"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz#3537ce379d7e7ff4eeb6b67a0973e139c2ac1f15"
@@ -3405,211 +3439,126 @@
resolved "https://registry.yarnpkg.com/@react-buoy/shared-ui/-/shared-ui-1.5.23.tgz#de216fb14e06d07cf13672d076eace00f4eefdf2"
integrity sha512-C2UAdUkCTXQrfjiv6vAxIRU8GMKWGviuttlLVbhAQmr0ZcfoY8bKrrpONJ2lgK6sAcSidaDZsidP2mamboelPQ==
-"@react-native-community/netinfo@^11.4.1":
- version "11.4.1"
- resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.4.1.tgz#a3c247aceab35f75dd0aa4bfa85d2be5a4508688"
- integrity sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==
+"@react-native-community/netinfo@12.0.1":
+ version "12.0.1"
+ resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-12.0.1.tgz#fec75f4093c27778479d8927b4f51f133b8a13a4"
+ integrity sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==
-"@react-native/assets-registry@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.81.5.tgz#d22c924fa6f6d4a463c5af34ce91f38756c0fa7d"
- integrity sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==
+"@react-native-masked-view/masked-view@^0.3.2":
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz#7064533a573e3539ec912f59c1f457371bf49dd9"
+ integrity sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==
-"@react-native/babel-plugin-codegen@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz#328d03f42c32b5a8cc2dee1aa84a7c48dddc5f18"
- integrity sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==
- dependencies:
- "@babel/traverse" "^7.25.3"
- "@react-native/codegen" "0.81.5"
+"@react-native/assets-registry@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/assets-registry/-/assets-registry-0.86.3.tgz#c9fd9c0d709133a70738697c0033af83ffa1c21e"
+ integrity sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==
-"@react-native/babel-preset@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/babel-preset/-/babel-preset-0.81.5.tgz#e8b7969d21f87ef4e41e603248e8a70c44b4a5bb"
- integrity sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==
+"@react-native/babel-plugin-codegen@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.3.tgz#e5af8fffc8bdeedcc0019afc988e53c8cb18c207"
+ integrity sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ==
dependencies:
- "@babel/core" "^7.25.2"
- "@babel/plugin-proposal-export-default-from" "^7.24.7"
- "@babel/plugin-syntax-dynamic-import" "^7.8.3"
- "@babel/plugin-syntax-export-default-from" "^7.24.7"
- "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
- "@babel/plugin-syntax-optional-chaining" "^7.8.3"
- "@babel/plugin-transform-arrow-functions" "^7.24.7"
- "@babel/plugin-transform-async-generator-functions" "^7.25.4"
- "@babel/plugin-transform-async-to-generator" "^7.24.7"
- "@babel/plugin-transform-block-scoping" "^7.25.0"
- "@babel/plugin-transform-class-properties" "^7.25.4"
- "@babel/plugin-transform-classes" "^7.25.4"
- "@babel/plugin-transform-computed-properties" "^7.24.7"
- "@babel/plugin-transform-destructuring" "^7.24.8"
- "@babel/plugin-transform-flow-strip-types" "^7.25.2"
- "@babel/plugin-transform-for-of" "^7.24.7"
- "@babel/plugin-transform-function-name" "^7.25.1"
- "@babel/plugin-transform-literals" "^7.25.2"
- "@babel/plugin-transform-logical-assignment-operators" "^7.24.7"
- "@babel/plugin-transform-modules-commonjs" "^7.24.8"
- "@babel/plugin-transform-named-capturing-groups-regex" "^7.24.7"
- "@babel/plugin-transform-nullish-coalescing-operator" "^7.24.7"
- "@babel/plugin-transform-numeric-separator" "^7.24.7"
- "@babel/plugin-transform-object-rest-spread" "^7.24.7"
- "@babel/plugin-transform-optional-catch-binding" "^7.24.7"
- "@babel/plugin-transform-optional-chaining" "^7.24.8"
- "@babel/plugin-transform-parameters" "^7.24.7"
- "@babel/plugin-transform-private-methods" "^7.24.7"
- "@babel/plugin-transform-private-property-in-object" "^7.24.7"
- "@babel/plugin-transform-react-display-name" "^7.24.7"
- "@babel/plugin-transform-react-jsx" "^7.25.2"
- "@babel/plugin-transform-react-jsx-self" "^7.24.7"
- "@babel/plugin-transform-react-jsx-source" "^7.24.7"
- "@babel/plugin-transform-regenerator" "^7.24.7"
- "@babel/plugin-transform-runtime" "^7.24.7"
- "@babel/plugin-transform-shorthand-properties" "^7.24.7"
- "@babel/plugin-transform-spread" "^7.24.7"
- "@babel/plugin-transform-sticky-regex" "^7.24.7"
- "@babel/plugin-transform-typescript" "^7.25.2"
- "@babel/plugin-transform-unicode-regex" "^7.24.7"
- "@babel/template" "^7.25.0"
- "@react-native/babel-plugin-codegen" "0.81.5"
- babel-plugin-syntax-hermes-parser "0.29.1"
- babel-plugin-transform-flow-enums "^0.0.2"
- react-refresh "^0.14.0"
+ "@babel/traverse" "^7.29.0"
+ "@react-native/codegen" "0.86.3"
-"@react-native/codegen@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.81.5.tgz#d4dec668c94b9d58a5c2dbdbf026db331e1b6b27"
- integrity sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==
+"@react-native/codegen@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/codegen/-/codegen-0.86.3.tgz#7ef0a10bb98ba5004463cf8f66adb000dba1a598"
+ integrity sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA==
dependencies:
"@babel/core" "^7.25.2"
- "@babel/parser" "^7.25.3"
- glob "^7.1.1"
- hermes-parser "0.29.1"
+ "@babel/parser" "^7.29.0"
+ hermes-parser "0.36.0"
invariant "^2.2.4"
nullthrows "^1.1.1"
+ tinyglobby "^0.2.15"
yargs "^17.6.2"
-"@react-native/community-cli-plugin@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz#617789cda4da419d03dda00e2a78c36188b4391e"
- integrity sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==
+"@react-native/community-cli-plugin@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.3.tgz#4e3dee7a018ac1e5cc91c6d00ca0b89f82281784"
+ integrity sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g==
dependencies:
- "@react-native/dev-middleware" "0.81.5"
+ "@react-native/dev-middleware" "0.86.3"
debug "^4.4.0"
invariant "^2.2.4"
- metro "^0.83.1"
- metro-config "^0.83.1"
- metro-core "^0.83.1"
+ metro "^0.84.3"
+ metro-config "^0.84.3"
+ metro-core "^0.84.3"
semver "^7.1.3"
-"@react-native/debugger-frontend@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz#82ece0181e9a7a3dcbdfa86cf9decd654e13f81f"
- integrity sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==
+"@react-native/debugger-frontend@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/debugger-frontend/-/debugger-frontend-0.86.3.tgz#93fb484ed4bffb48d20a1f25f02323e1b9f3cabe"
+ integrity sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw==
+
+"@react-native/debugger-shell@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/debugger-shell/-/debugger-shell-0.86.3.tgz#a2348ba5bae41fa2573a008383098d1cf0ef1cab"
+ integrity sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ==
+ dependencies:
+ cross-spawn "^7.0.6"
+ debug "^4.4.0"
+ fb-dotslash "0.5.8"
-"@react-native/dev-middleware@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz#81e8ac545d7736ef6ebb2e59fdbaebc5cf9aedec"
- integrity sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==
+"@react-native/dev-middleware@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/dev-middleware/-/dev-middleware-0.86.3.tgz#6eeeabeb28cf4a49dd53888f6d307135d257e6b1"
+ integrity sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA==
dependencies:
"@isaacs/ttlcache" "^1.4.1"
- "@react-native/debugger-frontend" "0.81.5"
+ "@react-native/debugger-frontend" "0.86.3"
+ "@react-native/debugger-shell" "0.86.3"
chrome-launcher "^0.15.2"
- chromium-edge-launcher "^0.2.0"
+ chromium-edge-launcher "^0.3.0"
connect "^3.6.5"
debug "^4.4.0"
invariant "^2.2.4"
nullthrows "^1.1.1"
open "^7.0.3"
serve-static "^1.16.2"
- ws "^6.2.3"
+ ws "^7.5.10"
+
+"@react-native/gradle-plugin@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.86.3.tgz#3a79ae4216800c5628e61798236ea44f98891a1b"
+ integrity sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ==
-"@react-native/gradle-plugin@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz#a58830f38789f6254b64449a17fe57455b589d00"
- integrity sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==
+"@react-native/jest-preset@~0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/jest-preset/-/jest-preset-0.86.3.tgz#164222da2070f7056a12c8e8f402fb06ef6f7f6b"
+ integrity sha512-oMmBwXUpTgJvGlcXQQPuiQLykoM8jsWYp6RwdJ2NWyIWzJcGp1rmSr3XV7istoIcUt0wdOVLtQs0A0nDm9ND+A==
+ dependencies:
+ "@jest/create-cache-key-function" "^29.7.0"
+ "@react-native/js-polyfills" "0.86.3"
+ babel-jest "^29.7.0"
+ jest-environment-node "^29.7.0"
+ regenerator-runtime "^0.13.2"
-"@react-native/js-polyfills@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz#2ca68188c8fff9b951f507b1dec7efe928848274"
- integrity sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==
+"@react-native/js-polyfills@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/js-polyfills/-/js-polyfills-0.86.3.tgz#811f1d0bcb44325ed5969425420825a0fbfc3a27"
+ integrity sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==
-"@react-native/normalize-colors@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz#1ca6cb6772bb7324df2b11aab35227eacd6bdfe7"
- integrity sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==
+"@react-native/normalize-colors@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.86.3.tgz#50176b5ff6d5533418c5a71c889b37e123d0b3cb"
+ integrity sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==
"@react-native/normalize-colors@^0.74.1":
version "0.74.89"
resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz#b8ac17d1bbccd3ef9a1f921665d04d42cff85976"
integrity sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==
-"@react-native/virtualized-lists@0.81.5":
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz#24123fded16992d7e46ecc4ccd473be939ea8c1b"
- integrity sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==
+"@react-native/virtualized-lists@0.86.3":
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/@react-native/virtualized-lists/-/virtualized-lists-0.86.3.tgz#e4a6d9219b58441e54a71007f32cfaacf4a0c0c1"
+ integrity sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==
dependencies:
invariant "^2.2.4"
nullthrows "^1.1.1"
-"@react-navigation/bottom-tabs@^7.4.0":
- version "7.15.9"
- resolved "https://registry.yarnpkg.com/@react-navigation/bottom-tabs/-/bottom-tabs-7.15.9.tgz#f9789b73d23f4e79f0a4a0cb5b61c38054d3c71d"
- integrity sha512-Ou28A1aZLj5wiFQ3F93aIsrI4NCwn3IJzkkjNo9KLFXsc0Yks+UqrVaFlffHFLsrbajuGRG/OQpnMA1ljayY5Q==
- dependencies:
- "@react-navigation/elements" "^2.9.14"
- color "^4.2.3"
- sf-symbols-typescript "^2.1.0"
-
-"@react-navigation/core@^7.17.2":
- version "7.17.2"
- resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-7.17.2.tgz#8a17b73faf7c0688a4749dcac8c7350d8f93e943"
- integrity sha512-Rt2OZwcgOmjv401uLGAKaRM6xo0fiBce/A7LfRHI1oe5FV+KooWcgAoZ2XOtgKj6UzVMuQWt3b2e6rxo/mDJRA==
- dependencies:
- "@react-navigation/routers" "^7.5.3"
- escape-string-regexp "^4.0.0"
- fast-deep-equal "^3.1.3"
- nanoid "^3.3.11"
- query-string "^7.1.3"
- react-is "^19.1.0"
- use-latest-callback "^0.2.4"
- use-sync-external-store "^1.5.0"
-
-"@react-navigation/elements@^2.9.14":
- version "2.9.14"
- resolved "https://registry.yarnpkg.com/@react-navigation/elements/-/elements-2.9.14.tgz#48b9e3cf16e38818df1e633a13a17c0fa96e9c43"
- integrity sha512-lKqzu+su2pI/YIZmR7L7xdOs4UL+rVXKJAMpRMBrwInEy96SjIFst6QDGpE89Dunnu3VjVpjWfByo9f2GWBHDQ==
- dependencies:
- color "^4.2.3"
- use-latest-callback "^0.2.4"
- use-sync-external-store "^1.5.0"
-
-"@react-navigation/native-stack@^7.3.16":
- version "7.14.10"
- resolved "https://registry.yarnpkg.com/@react-navigation/native-stack/-/native-stack-7.14.10.tgz#f83ff598bbadcaec57412301f2258183af244085"
- integrity sha512-mCbYbYhi7Em2R2nEgwYGdLU38smy+KK+HMMVcwuzllWsF3Qb+jOUEYbB6Or7LvE7SS77BZ6sHdx4HptCEv50hQ==
- dependencies:
- "@react-navigation/elements" "^2.9.14"
- color "^4.2.3"
- sf-symbols-typescript "^2.1.0"
- warn-once "^0.1.1"
-
-"@react-navigation/native@^7.1.8":
- version "7.2.2"
- resolved "https://registry.yarnpkg.com/@react-navigation/native/-/native-7.2.2.tgz#c9438fe8393454d74fdb7f959ac9abede52b1f8e"
- integrity sha512-kem1Ko2BcbAjmbQIv66dNmr6EtfDut3QU0qjsVhMnLLhktwyXb6FzZYp8gTrUb6AvkAbaJoi+BF5Pl55pAUa5w==
- dependencies:
- "@react-navigation/core" "^7.17.2"
- escape-string-regexp "^4.0.0"
- fast-deep-equal "^3.1.3"
- nanoid "^3.3.11"
- use-latest-callback "^0.2.4"
-
-"@react-navigation/routers@^7.5.3":
- version "7.5.3"
- resolved "https://registry.yarnpkg.com/@react-navigation/routers/-/routers-7.5.3.tgz#8002930ef5f62351be2475d0dffde3ffaee174d7"
- integrity sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==
- dependencies:
- nanoid "^3.3.11"
-
"@react-spectrum/button@^3.18.0":
version "3.18.1"
resolved "https://registry.yarnpkg.com/@react-spectrum/button/-/button-3.18.1.tgz#67f63c59fa7bdddd58816ffee4dd1293733e57e7"
@@ -3824,10 +3773,10 @@
"@react-aria/switch" "^3.8.0"
"@react-spectrum/switch" "^3.7.0"
-"@rnmapbox/maps@10.1.42-rc.0":
- version "10.1.42-rc.0"
- resolved "https://registry.yarnpkg.com/@rnmapbox/maps/-/maps-10.1.42-rc.0.tgz#760a3261a5c386ff6ba640406b4b0c8872d3c702"
- integrity sha512-oFv7K3kFhcSqvR+G8vExZC1T8iqHQMjsid6gBmCJKIBnBYD2dXxQlr6g/x0VH0polIVWirmrINaJzKkqrIjPig==
+"@rnmapbox/maps@10.3.5":
+ version "10.3.5"
+ resolved "https://registry.yarnpkg.com/@rnmapbox/maps/-/maps-10.3.5.tgz#bbc57d910654d79d31f37122d4cb23e48c94bc52"
+ integrity sha512-ShQppktl1H4HHPp/aninsEVK7+tsHmN48ULxpB1rfmx7W3WogB21Q1NAEw7CzxERnLZveijyutszJG2y3EBn2Q==
dependencies:
"@turf/along" "6.5.0"
"@turf/distance" "6.5.0"
@@ -3835,7 +3784,7 @@
"@turf/length" "6.5.0"
"@turf/nearest-point-on-line" "6.5.0"
"@types/geojson" "^7946.0.7"
- debounce "^1.2.0"
+ debounce "^2.2.0"
"@rtsao/scc@^1.1.0":
version "1.1.0"
@@ -3868,144 +3817,152 @@
micromatch "^4.0.0"
p-reduce "^2.0.0"
-"@sentry-internal/browser-utils@10.12.0":
- version "10.12.0"
- resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-10.12.0.tgz#aa3a05653e530d2693e307c0131571ee8a97b60d"
- integrity sha512-dozbx389jhKynj0d657FsgbBVOar7pX3mb6GjqCxslXF0VKpZH2Xks0U32RgDY/nK27O+o095IWz7YvjVmPkDw==
- dependencies:
- "@sentry/core" "10.12.0"
+"@sentry/babel-plugin-component-annotate@5.3.0":
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz#356218f747969f9af970987dcf0f17ec81d6e50c"
+ integrity sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==
+
+"@sentry/browser-utils@10.67.0":
+ version "10.67.0"
+ resolved "https://registry.yarnpkg.com/@sentry/browser-utils/-/browser-utils-10.67.0.tgz#407395e17efe5e643dacc8170cfcd20b05ef61b6"
+ integrity sha512-HUzaf0xAnPAB+OHBkD7N1Py+CTbD5InHulQ/pdhX4JctWtxuwD8odMD1LzdPnW8J6gVHlDVvcVBR8mXMZYSLSw==
+ dependencies:
+ "@sentry/conventions" "^0.16.0"
+ "@sentry/core" "10.67.0"
+
+"@sentry/browser@10.67.0":
+ version "10.67.0"
+ resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.67.0.tgz#b536a13f1d78405c9e1dbf97864952a0b58701c8"
+ integrity sha512-/ZhsAvte4rYhg0A0RtSFFgAgXhyMOfQIeOAfMfptN+X6IVSYOfkA9jtrP+Ej4+6vlaUFWRir1HweF56y63dEEA==
+ dependencies:
+ "@sentry/browser-utils" "10.67.0"
+ "@sentry/conventions" "^0.16.0"
+ "@sentry/core" "10.67.0"
+ "@sentry/feedback" "10.67.0"
+ "@sentry/replay" "10.67.0"
+ "@sentry/replay-canvas" "10.67.0"
+
+"@sentry/cli-darwin@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-3.6.1.tgz#1de4902d87569b815726b6b13999dd8f67c00a5d"
+ integrity sha512-hit8SHXYTSZnKvT87/n/5RLwJnl0Nm24dewqEXwMzw7YkDFg7Ptq0bIhvGWINgyuBsmEfXGMnrbBZw5sueBtnQ==
-"@sentry-internal/feedback@10.12.0":
- version "10.12.0"
- resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-10.12.0.tgz#a48039507f37fe62e19566128a894661a724ef0d"
- integrity sha512-0+7ceO6yQPPqfxRc9ue/xoPHKcnB917ezPaehGQNfAFNQB9PNTG1y55+8mRu0Fw+ANbZeCt/HyoCmXuRdxmkpg==
- dependencies:
- "@sentry/core" "10.12.0"
+"@sentry/cli-linux-arm64@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.6.1.tgz#16dbd4bcb610cb748e9467af74fb1e9a905958b8"
+ integrity sha512-+6UpbQo56wW5pf1TTBfNoYzf+WuG7c1fj9DAKchQwUzDN7886OwSYC326rDyh/PaafszYj5gAOElFhymNVA1WA==
-"@sentry-internal/replay-canvas@10.12.0":
- version "10.12.0"
- resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-10.12.0.tgz#f79dde92bcba67b4f706db6c217467e14d6348c5"
- integrity sha512-W/z1/+69i3INNfPjD1KuinSNaRQaApjzwb37IFmiyF440F93hxmEYgXHk3poOlYYaigl2JMYbysGPWOiXnqUXA==
- dependencies:
- "@sentry-internal/replay" "10.12.0"
- "@sentry/core" "10.12.0"
+"@sentry/cli-linux-arm@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-3.6.1.tgz#487b32a4bd312384e374d5347a23dbea8c034c25"
+ integrity sha512-FgfKPa4sXianCQxpujNE1JrJYbF6Ug+UPjwdEGAnCZtDeRRQdXbSIih7ikaocg4BcpHxwoRm0BHG1wYeizkCbQ==
-"@sentry-internal/replay@10.12.0":
- version "10.12.0"
- resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-10.12.0.tgz#47ca89acdc621217991c7ed1d133fd37915a512d"
- integrity sha512-/1093gSNGN5KlOBsuyAl33JkzGiG38kCnxswQLZWpPpR6LBbR1Ddb18HjhDpoQNNEZybJBgJC3a5NKl43C2TSQ==
- dependencies:
- "@sentry-internal/browser-utils" "10.12.0"
- "@sentry/core" "10.12.0"
+"@sentry/cli-linux-i686@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-3.6.1.tgz#4b75063fb6eff8976e754635c50a68b3a7fefd7c"
+ integrity sha512-xDzb2knLayJKv6FGggjrfvQiIpA4wvYvxeNV6B3plTlu1Qunkz5889A+Ayrp4wv19bgMVI6wLdKF9U2TpHPTbQ==
-"@sentry/babel-plugin-component-annotate@4.3.0":
- version "4.3.0"
- resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.3.0.tgz#c5b6cbb986952596d3ad233540a90a1fd18bad80"
- integrity sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==
+"@sentry/cli-linux-x64@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-3.6.1.tgz#ea0d1e2bdc57e293293ecdd48b8863b7bbfa7223"
+ integrity sha512-OJChq61ZoKFLAVC+LPqensvBcoToxnAfAZF19emTLNQNjBbGZBL6G/4P0uK4tlwlhB5wAeobu58xX2kfJEgkjA==
-"@sentry/browser@10.12.0":
- version "10.12.0"
- resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.12.0.tgz#79dffc88e1f9241b9fdb5def5a7c6809f41230b3"
- integrity sha512-lKyaB2NFmr7SxPjmMTLLhQ7xfxaY3kdkMhpzuRI5qwOngtKt4+FtvNYHRuz+PTtEFv4OaHhNNbRn6r91gWguQg==
- dependencies:
- "@sentry-internal/browser-utils" "10.12.0"
- "@sentry-internal/feedback" "10.12.0"
- "@sentry-internal/replay" "10.12.0"
- "@sentry-internal/replay-canvas" "10.12.0"
- "@sentry/core" "10.12.0"
-
-"@sentry/cli-darwin@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.55.0.tgz#79513547d15223d51905e94d8c1e2bc7377cfdf1"
- integrity sha512-jGHE7SHHzqXUmnsmRLgorVH6nmMmTjQQXdPZbSL5tRtH8d3OIYrVNr5D72DSgD26XAPBDMV0ibqOQ9NKoiSpfA==
-
-"@sentry/cli-linux-arm64@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.55.0.tgz#5a2e8ea6e088f4884cbe272525f0781f8c748ff3"
- integrity sha512-jNB/0/gFcOuDCaY/TqeuEpsy/k52dwyk1SOV3s1ku4DUsln6govTppeAGRewY3T1Rj9B2vgIWTrnB8KVh9+Rgg==
-
-"@sentry/cli-linux-arm@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.55.0.tgz#54c330471c4b23ff6769bfd886f092afba3380ce"
- integrity sha512-ATjU0PsiWADSPLF/kZroLZ7FPKd5W9TDWHVkKNwIUNTei702LFgTjNeRwOIzTgSvG3yTmVEqtwFQfFN/7hnVXQ==
-
-"@sentry/cli-linux-i686@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.55.0.tgz#12c64453ef7014ba89c885497e3c9764a5f3e0a0"
- integrity sha512-8LZjo6PncTM6bWdaggscNOi5r7F/fqRREsCwvd51dcjGj7Kp1plqo9feEzYQ+jq+KUzVCiWfHrUjddFmYyZJrg==
-
-"@sentry/cli-linux-x64@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.55.0.tgz#c640464fe533fe87e57d0ca5585e0c504e0ef5b3"
- integrity sha512-5LUVvq74Yj2cZZy5g5o/54dcWEaX4rf3myTHy73AKhRj1PABtOkfexOLbF9xSrZy95WXWaXyeH+k5n5z/vtHfA==
-
-"@sentry/cli-win32-arm64@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.55.0.tgz#da1e8ba13083c281fcdf72d1dd35255df7fdb5c5"
- integrity sha512-cWIQdzm1pfLwPARsV6dUb8TVd6Y3V1A2VWxjTons3Ift6GvtVmiAe0OWL8t2Yt95i8v61kTD/6Tq21OAaogqzA==
-
-"@sentry/cli-win32-i686@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.55.0.tgz#c2eae0a75fc55f101c31de0999214d7e613d65c4"
- integrity sha512-ldepCn2t9r4I0wvgk7NRaA7coJyy4rTQAzM66u9j5nTEsUldf66xym6esd5ZZRAaJUjffqvHqUIr/lrieTIrVg==
-
-"@sentry/cli-win32-x64@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.55.0.tgz#499e6697663a3e1453ff2e921bf3876f774520b5"
- integrity sha512-4hPc/I/9tXx+HLTdTGwlagtAfDSIa2AoTUP30tl32NAYQhx9a6niUbPAemK2qfxesiufJ7D2djX83rCw6WnJVA==
-
-"@sentry/cli@2.55.0":
- version "2.55.0"
- resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.55.0.tgz#c22d2edbd320242e7881c15ecf89649378c05387"
- integrity sha512-cynvcIM2xL8ddwELyFRSpZQw4UtFZzoM2rId2l9vg7+wDREPDocMJB9lEQpBIo3eqhp9JswqUT037yjO6iJ5Sw==
- dependencies:
- https-proxy-agent "^5.0.0"
- node-fetch "^2.6.7"
- progress "^2.0.3"
- proxy-from-env "^1.1.0"
- which "^2.0.2"
- optionalDependencies:
- "@sentry/cli-darwin" "2.55.0"
- "@sentry/cli-linux-arm" "2.55.0"
- "@sentry/cli-linux-arm64" "2.55.0"
- "@sentry/cli-linux-i686" "2.55.0"
- "@sentry/cli-linux-x64" "2.55.0"
- "@sentry/cli-win32-arm64" "2.55.0"
- "@sentry/cli-win32-i686" "2.55.0"
- "@sentry/cli-win32-x64" "2.55.0"
-
-"@sentry/core@10.12.0":
- version "10.12.0"
- resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.12.0.tgz#3f6a0f5c2f63f2c1761e3cf442a986d74adf6403"
- integrity sha512-Jrf0Yo7DvmI/ZQcvBnA0xKNAFkJlVC/fMlvcin+5IrFNRcqOToZ2vtF+XqTgjRZymXQNE8s1QTD7IomPHk0TAw==
+"@sentry/cli-win32-arm64@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.6.1.tgz#5995593c7b72fb81df6a3e6f995c9c1abf86b1c2"
+ integrity sha512-SR4JESQdr1+XmK3kAVa9BAUdOA9QYkreJkWzUlj1oXbTlD5OdRmzc1yMHlkrkbbKSu6vrlqYiEaox3Pgsc08sg==
-"@sentry/react-native@~7.2.0":
- version "7.2.0"
- resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-7.2.0.tgz#d29deacc36fd55fc52119ffcf53f61c21cab3600"
- integrity sha512-rjqYgEjntPz1sPysud78wi4B9ui7LBVPsG6qr8s/htLMYho9GPGFA5dF+eqsQWqMX8NDReAxNkLTC4+gCNklLQ==
- dependencies:
- "@sentry/babel-plugin-component-annotate" "4.3.0"
- "@sentry/browser" "10.12.0"
- "@sentry/cli" "2.55.0"
- "@sentry/core" "10.12.0"
- "@sentry/react" "10.12.0"
- "@sentry/types" "10.12.0"
+"@sentry/cli-win32-i686@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-3.6.1.tgz#11d8ad1f00cc56b0d01f09a3960fc73408dd9c2f"
+ integrity sha512-EBOR2Yx5nYUcwbGXp5AHuQPjNZ9QLcoKlTKAt9ygQX4SF7S0pwnrAfXktkA4QOZqSXtzlKhrpwwnWNq0zPksNw==
-"@sentry/react@10.12.0":
- version "10.12.0"
- resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.12.0.tgz#c121f37bf582f4851108f67ef492de6a4c8f7a8d"
- integrity sha512-TpqgdoYbkf5JynmmW2oQhHQ/h5w+XPYk0cEb/UrsGlvJvnBSR+5tgh0AqxCSi3gvtp82rAXI5w1TyRPBbhLDBw==
- dependencies:
- "@sentry/browser" "10.12.0"
- "@sentry/core" "10.12.0"
- hoist-non-react-statics "^3.3.2"
+"@sentry/cli-win32-x64@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-3.6.1.tgz#82e691b2a7ee237b331e0ba548feec98f5e15012"
+ integrity sha512-qQkVQOMoE5U6NtigezFNYSJncP1DOuauvp7JS+1DRpE5pXTKjHkOT6niWceGQc7wH4UGk6iwzOkNzx6klNd4Yw==
-"@sentry/types@10.12.0":
- version "10.12.0"
- resolved "https://registry.yarnpkg.com/@sentry/types/-/types-10.12.0.tgz#86d24346efde7b0757474537af7ae4b3d931d6a8"
- integrity sha512-sKGj3l3V8ZKISh2Tu88bHfnm5ztkRtSLdmpZ6TmCeJdSM9pV+RRd6CMJ0RnSEXmYHselPNUod521t2NQFd4W1w==
+"@sentry/cli@3.6.1":
+ version "3.6.1"
+ resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-3.6.1.tgz#df9bc8c5cd6b35947a7fe7f9d04e942a42f95d86"
+ integrity sha512-9KGZEe/o+vdHS69JhkNztPysqknnI3W5cceovi2G3yb5z29At6SAbGGOdFe3qxYDfC/c2Uw5MKYfev8K9xFI9Q==
dependencies:
- "@sentry/core" "10.12.0"
+ progress "^2.0.3"
+ proxy-from-env "^1.1.0"
+ undici "^6.22.0"
+ which "^2.0.2"
+ optionalDependencies:
+ "@sentry/cli-darwin" "3.6.1"
+ "@sentry/cli-linux-arm" "3.6.1"
+ "@sentry/cli-linux-arm64" "3.6.1"
+ "@sentry/cli-linux-i686" "3.6.1"
+ "@sentry/cli-linux-x64" "3.6.1"
+ "@sentry/cli-win32-arm64" "3.6.1"
+ "@sentry/cli-win32-i686" "3.6.1"
+ "@sentry/cli-win32-x64" "3.6.1"
+
+"@sentry/conventions@^0.16.0":
+ version "0.16.0"
+ resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009"
+ integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==
+
+"@sentry/core@10.67.0":
+ version "10.67.0"
+ resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.67.0.tgz#38afb5484d2581d40b212303e8a1abba2daea7a2"
+ integrity sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==
+ dependencies:
+ "@sentry/conventions" "^0.16.0"
+
+"@sentry/expo-upload-sourcemaps@8.20.0":
+ version "8.20.0"
+ resolved "https://registry.yarnpkg.com/@sentry/expo-upload-sourcemaps/-/expo-upload-sourcemaps-8.20.0.tgz#af10bdd1cd3f4212e496e01deb602a8301057678"
+ integrity sha512-FWIJmNTWWY+E/tHQdFIxKMzEB0n47mF8W+1otcXr1hqEsePgBjMqeY2X3NhGRrt4b8rvTCXCjPlc3APyR+nCAg==
+ dependencies:
+ "@sentry/cli" "3.6.1"
+
+"@sentry/feedback@10.67.0":
+ version "10.67.0"
+ resolved "https://registry.yarnpkg.com/@sentry/feedback/-/feedback-10.67.0.tgz#20bbbb798f8ed84135c3a6667223e8826200d6d5"
+ integrity sha512-I4ML2/SF3enwikb6ZSoRiqolQrx0zSzTSnUgwCmugICF/jpHW0th1pCray9R+t1Zzibw/Dpj4t/DNXaSDRa2MA==
+ dependencies:
+ "@sentry/core" "10.67.0"
+
+"@sentry/react-native@~8.20.0":
+ version "8.20.0"
+ resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-8.20.0.tgz#223aecfa20fee6301f457dc4969e892ae0be2397"
+ integrity sha512-Visy8xWo4wJm3OW1QWiHB4avj0mp+En4fiY9uiaPmWhgX0RIx2fwAm73x3o5+etZdRDnC6oVhV5skofy9rEC6A==
+ dependencies:
+ "@sentry/babel-plugin-component-annotate" "5.3.0"
+ "@sentry/browser" "10.67.0"
+ "@sentry/cli" "3.6.1"
+ "@sentry/core" "10.67.0"
+ "@sentry/expo-upload-sourcemaps" "8.20.0"
+ "@sentry/react" "10.67.0"
+
+"@sentry/react@10.67.0":
+ version "10.67.0"
+ resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.67.0.tgz#a2bb90d56d4d28b113944c27dd39d994988b929b"
+ integrity sha512-fS0DplcP9eMxBIRurPC/uxa4NrFK+l9ZsnvQo7wZvNutc7DpTAH0hgFt6laVNCe57s1pFo+OZuKsYBA6JDvH4Q==
+ dependencies:
+ "@sentry/browser" "10.67.0"
+ "@sentry/conventions" "^0.16.0"
+ "@sentry/core" "10.67.0"
+
+"@sentry/replay-canvas@10.67.0":
+ version "10.67.0"
+ resolved "https://registry.yarnpkg.com/@sentry/replay-canvas/-/replay-canvas-10.67.0.tgz#fa79460852bd2a89916df380e6b21a9271add186"
+ integrity sha512-neNA4T6MFtZzMdKYetiR+LZd9BNSd0q2szMn0wk+A15PqHE/IN7a34V6JZc9rCtmzB0wldh0eWGOBb49MSNKjA==
+ dependencies:
+ "@sentry/core" "10.67.0"
+ "@sentry/replay" "10.67.0"
+
+"@sentry/replay@10.67.0":
+ version "10.67.0"
+ resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-10.67.0.tgz#c53203f69052f1371b20e4ca45f22f265ec61693"
+ integrity sha512-nkEUgPCR82EcyJkCf3XCE9H0R5KisCqyCAaSGxe7NpAoQbvASHx4MUNgXVAn+D0M494gvPZh6lFH7JgzqTcSqQ==
+ dependencies:
+ "@sentry/browser-utils" "10.67.0"
+ "@sentry/core" "10.67.0"
"@shopify/flash-list@2.0.2":
version "2.0.2"
@@ -4746,12 +4703,26 @@
resolved "https://registry.yarnpkg.com/@types/react-native-base64/-/react-native-base64-0.2.2.tgz#d4e1d537e6d547d23d96a1e64627acc13587ae6b"
integrity sha512-obr+/L9Jaxdr+xCVS/IQcYgreg5xtnui4Wqw/G1acBUtW2CnqVJj6lK6F/5F3+5d2oZEo5xDDLqy8GVn2HbEmw==
-"@types/react@~19.1.10":
- version "19.1.17"
- resolved "https://registry.yarnpkg.com/@types/react/-/react-19.1.17.tgz#8be0b9c546cede389b930a98eb3fad1897f209c3"
- integrity sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==
+"@types/react-test-renderer@^19.1.0":
+ version "19.3.0"
+ resolved "https://registry.yarnpkg.com/@types/react-test-renderer/-/react-test-renderer-19.3.0.tgz#719edd157d3982cbd905826e6a019d23709fc063"
+ integrity sha512-TX4p2D4Z2oigM3SXU7+QyLSUnSR0vR1S1Pfxm/qFL9nHQ3jclgi6ioGoWQmlIr1DeKRVmPJrmNlhgM0JlWlLUg==
dependencies:
- csstype "^3.0.2"
+ "@types/react" "*"
+
+"@types/react@*":
+ version "19.3.0"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-19.3.0.tgz#3b3ce8d8c1627fb5ccd4dadd57ef445c5a153b77"
+ integrity sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==
+ dependencies:
+ csstype "^3.2.2"
+
+"@types/react@~19.2.14":
+ version "19.2.18"
+ resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.18.tgz#eb0b6a1fb635d1a9692d5f84a3495bd8ad153707"
+ integrity sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==
+ dependencies:
+ csstype "^3.2.2"
"@types/responselike@^1.0.0":
version "1.0.3"
@@ -5080,27 +5051,11 @@
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6"
integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==
-"@unrs/resolver-binding-win32-x64-msvc@1.11.1":
- version "1.11.1"
- resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
- integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
-
-"@urql/core@^5.0.6", "@urql/core@^5.1.2":
- version "5.2.0"
- resolved "https://registry.yarnpkg.com/@urql/core/-/core-5.2.0.tgz#77ee41e192e261fea30c2ca6c2f340410b45d214"
- integrity sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==
- dependencies:
- "@0no-co/graphql.web" "^1.0.13"
- wonka "^6.3.2"
-
-"@urql/exchange-retry@^1.3.0":
- version "1.3.2"
- resolved "https://registry.yarnpkg.com/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz#042ff5f3512a062651ec7257f1b07f9db2f6fefd"
- integrity sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==
- dependencies:
- "@urql/core" "^5.1.2"
- wonka "^6.3.2"
-
+"@unrs/resolver-binding-win32-x64-msvc@1.11.1":
+ version "1.11.1"
+ resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
+ integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
+
"@xmldom/xmldom@^0.8.8":
version "0.8.11"
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608"
@@ -5136,7 +5091,7 @@ abort-controller@^3.0.0:
dependencies:
event-target-shim "^5.0.0"
-accepts@^1.3.7, accepts@^1.3.8:
+accepts@^1.3.8:
version "1.3.8"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
@@ -5189,10 +5144,10 @@ agent-base@^7.1.0, agent-base@^7.1.2:
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8"
integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==
-agent-cli-detector@^0.1.2:
- version "0.1.6"
- resolved "https://registry.yarnpkg.com/agent-cli-detector/-/agent-cli-detector-0.1.6.tgz#92b1d24146d15ef902f832cc3ac639d91a304044"
- integrity sha512-vKrPeEVN3upDF3GjWxsWBbwQgMtNJ8VB1cduPvK3svmmz4ENpS7yaPxbogsbe3w+xp9xp2Cu+0Ar41rjAR4+lA==
+agent-cli-detector@0.1.7:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz#9d0b5d8cccaec3f207efa478890f189b773fae0b"
+ integrity sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==
aggregate-error@^3.0.0:
version "3.1.0"
@@ -5342,11 +5297,6 @@ any-observable@^0.3.0:
resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b"
integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==
-any-promise@^1.0.0:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
- integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==
-
anymatch@^3.0.3, anymatch@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
@@ -5586,17 +5536,6 @@ assert-plus@^1.0.0:
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==
-assert@^2.0.0:
- version "2.1.0"
- resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd"
- integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==
- dependencies:
- call-bind "^1.0.2"
- is-nan "^1.3.2"
- object-is "^1.1.5"
- object.assign "^4.1.4"
- util "^0.12.5"
-
astral-regex@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
@@ -5612,11 +5551,6 @@ async-function@^1.0.0:
resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b"
integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==
-async-limiter@~1.0.0:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
- integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
-
async@^3.2.6:
version "3.2.6"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce"
@@ -5786,12 +5720,19 @@ babel-plugin-react-native-web@~0.21.0:
resolved "https://registry.yarnpkg.com/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz#d2f7fd673278da82577aa583457edb55d9cccbe0"
integrity sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA==
-babel-plugin-syntax-hermes-parser@0.29.1, babel-plugin-syntax-hermes-parser@^0.29.1:
- version "0.29.1"
- resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz#09ca9ecb0330eba1ef939b6d3f1f55bb06a9dc33"
- integrity sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==
+babel-plugin-syntax-hermes-parser@0.36.0:
+ version "0.36.0"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz#51e429f32f23b197c477a20525798a7a97709c80"
+ integrity sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==
+ dependencies:
+ hermes-parser "0.36.0"
+
+babel-plugin-syntax-hermes-parser@^0.36.0:
+ version "0.36.1"
+ resolved "https://registry.yarnpkg.com/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz#970277618fbec67b400bf8b01e4f61ba96ef5a40"
+ integrity sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==
dependencies:
- hermes-parser "0.29.1"
+ hermes-parser "0.36.1"
babel-plugin-transform-flow-enums@^0.0.2:
version "0.0.2"
@@ -5821,33 +5762,53 @@ babel-preset-current-node-syntax@^1.0.0, babel-preset-current-node-syntax@^1.1.0
"@babel/plugin-syntax-private-property-in-object" "^7.14.5"
"@babel/plugin-syntax-top-level-await" "^7.14.5"
-babel-preset-expo@~54.0.12:
- version "54.0.12"
- resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-54.0.12.tgz#02d0333b80e6d9567166d1bdbf0819e46dc3458c"
- integrity sha512-6xeSkdaixmQhWSYL7tfLu0pOS0BY+8ftwmdNSHtpEFSizrXYZkCjk/B6Dxr+6nwNRihixMcS0aBlWS1wlDl3pw==
+babel-preset-expo@~57.0.13:
+ version "57.0.13"
+ resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-57.0.13.tgz#916f16b807e22bfdb72c179e7f0911a8176b0d3a"
+ integrity sha512-OOl3lqNQ4IagJfsNgtxuna0wpTWt1BswdyktGTOJ5EJCgdQUk/B2vaj/jsl03+kMyuIvpxKioVtbTzxh3mdvvQ==
dependencies:
+ "@babel/generator" "^7.20.5"
"@babel/helper-module-imports" "^7.25.9"
"@babel/plugin-proposal-decorators" "^7.12.9"
"@babel/plugin-proposal-export-default-from" "^7.24.7"
+ "@babel/plugin-syntax-dynamic-import" "^7.8.3"
"@babel/plugin-syntax-export-default-from" "^7.24.7"
+ "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
+ "@babel/plugin-syntax-optional-chaining" "^7.8.3"
+ "@babel/plugin-transform-async-generator-functions" "^7.25.4"
+ "@babel/plugin-transform-async-to-generator" "^7.24.7"
+ "@babel/plugin-transform-block-scoping" "^7.25.0"
+ "@babel/plugin-transform-class-properties" "^7.25.4"
"@babel/plugin-transform-class-static-block" "^7.27.1"
+ "@babel/plugin-transform-classes" "^7.25.4"
+ "@babel/plugin-transform-destructuring" "^7.24.8"
"@babel/plugin-transform-export-namespace-from" "^7.25.9"
"@babel/plugin-transform-flow-strip-types" "^7.25.2"
+ "@babel/plugin-transform-for-of" "^7.24.7"
+ "@babel/plugin-transform-logical-assignment-operators" "^7.24.7"
"@babel/plugin-transform-modules-commonjs" "^7.24.8"
+ "@babel/plugin-transform-named-capturing-groups-regex" "^7.24.7"
+ "@babel/plugin-transform-nullish-coalescing-operator" "^7.24.7"
"@babel/plugin-transform-object-rest-spread" "^7.24.7"
+ "@babel/plugin-transform-optional-catch-binding" "^7.24.7"
+ "@babel/plugin-transform-optional-chaining" "^7.24.8"
"@babel/plugin-transform-parameters" "^7.24.7"
"@babel/plugin-transform-private-methods" "^7.24.7"
"@babel/plugin-transform-private-property-in-object" "^7.24.7"
+ "@babel/plugin-transform-react-display-name" "^7.24.7"
+ "@babel/plugin-transform-react-jsx" "^7.28.6"
+ "@babel/plugin-transform-react-jsx-development" "^7.27.1"
+ "@babel/plugin-transform-react-pure-annotations" "^7.27.1"
"@babel/plugin-transform-runtime" "^7.24.7"
- "@babel/preset-react" "^7.22.15"
+ "@babel/plugin-transform-typescript" "^7.25.2"
+ "@babel/plugin-transform-unicode-regex" "^7.24.7"
"@babel/preset-typescript" "^7.23.0"
- "@react-native/babel-preset" "0.81.5"
+ "@react-native/babel-plugin-codegen" "0.86.3"
babel-plugin-react-compiler "^1.0.0"
babel-plugin-react-native-web "~0.21.0"
- babel-plugin-syntax-hermes-parser "^0.29.1"
+ babel-plugin-syntax-hermes-parser "^0.36.0"
babel-plugin-transform-flow-enums "^0.0.2"
debug "^4.3.4"
- resolve-from "^5.0.0"
babel-preset-jest@30.0.1:
version "30.0.1"
@@ -5880,7 +5841,7 @@ balanced-match@^4.0.2:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a"
integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==
-base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1:
+base64-js@1.5.1, base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@@ -5895,13 +5856,6 @@ baseline-browser-mapping@^2.8.3:
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.9.tgz#fd0b8543c4f172595131e94965335536b3101b75"
integrity sha512-hY/u2lxLrbecMEWSB0IpGzGyDyeoMFQhCvZd2jGFSE5I17Fh01sYUBPCJtkWERw7zrac9+cIghxm/ytJa2X8iA==
-better-opn@~3.0.2:
- version "3.0.2"
- resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817"
- integrity sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==
- dependencies:
- open "^8.0.4"
-
big-integer@1.6.x:
version "1.6.52"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.52.tgz#60a887f3047614a8e1bffe5d7173490a97dc8c85"
@@ -6017,13 +5971,6 @@ browserslist@^4.25.0:
node-releases "^2.0.36"
update-browserslist-db "^1.2.3"
-bs-logger@0.x:
- version "0.2.6"
- resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8"
- integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==
- dependencies:
- fast-json-stable-stringify "2.x"
-
bser@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05"
@@ -6153,7 +6100,7 @@ call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-
es-errors "^1.3.0"
function-bind "^1.1.2"
-call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.7, call-bind@^1.0.8:
+call-bind@^1.0.2, call-bind@^1.0.7, call-bind@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c"
integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==
@@ -6301,17 +6248,16 @@ chrome-launcher@^0.15.2:
is-wsl "^2.2.0"
lighthouse-logger "^1.0.0"
-chromium-edge-launcher@^0.2.0:
- version "0.2.0"
- resolved "https://registry.yarnpkg.com/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz#0c378f28c99aefc360705fa155de0113997f62fc"
- integrity sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==
+chromium-edge-launcher@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz#34d5ca8142fc059ea2b4482bdcb184393a399ffe"
+ integrity sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==
dependencies:
"@types/node" "*"
escape-string-regexp "^4.0.0"
is-wsl "^2.2.0"
lighthouse-logger "^1.0.0"
mkdirp "^1.0.4"
- rimraf "^3.0.2"
chromium-pickle-js@^0.2.0:
version "0.2.0"
@@ -6557,11 +6503,6 @@ commander@^2.20.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
-commander@^4.0.0:
- version "4.1.1"
- resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
- integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
-
commander@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
@@ -6871,6 +6812,11 @@ csstype@^3.0.2, csstype@^3.1.0, csstype@^3.1.3:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
+csstype@^3.2.2:
+ version "3.2.3"
+ resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
+ integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
+
dargs@^8.0.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/dargs/-/dargs-8.1.0.tgz#a34859ea509cbce45485e5aa356fef70bfcc7272"
@@ -6922,10 +6868,10 @@ date-fns@^4.1.0:
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14"
integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==
-debounce@^1.2.0:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
- integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==
+debounce@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/debounce/-/debounce-2.2.0.tgz#f895fa2fbdb579a0f0d3dcf5dde19657e50eaad5"
+ integrity sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==
debug@2.6.9, debug@^2.6.9:
version "2.6.9"
@@ -7033,11 +6979,6 @@ define-data-property@^1.0.1, define-data-property@^1.1.4:
es-errors "^1.3.0"
gopd "^1.0.1"
-define-lazy-prop@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
- integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
-
define-lazy-prop@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f"
@@ -7168,6 +7109,11 @@ dmg-license@^1.0.11:
smart-buffer "^4.0.2"
verror "^1.10.0"
+dnssd-advertise@^1.1.4:
+ version "1.1.6"
+ resolved "https://registry.yarnpkg.com/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz#47a543d25ec133dcca0a77468baaacc6987bbcc8"
+ integrity sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==
+
doctrine@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -7274,7 +7220,7 @@ dot-prop@^9.0.0:
dependencies:
type-fest "^4.18.2"
-dotenv-expand@^11.0.6, dotenv-expand@~11.0.6:
+dotenv-expand@^11.0.6:
version "11.0.7"
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz#af695aea007d6fdc84c86cd8d0ad7beb40a0bd08"
integrity sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==
@@ -7472,11 +7418,6 @@ entities@^7.0.1:
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
-env-editor@^0.4.1:
- version "0.4.2"
- resolved "https://registry.yarnpkg.com/env-editor/-/env-editor-0.4.2.tgz#4e76568d0bd8f5c2b6d314a9412c8fe9aa3ae861"
- integrity sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==
-
env-paths@^2.2.0, env-paths@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2"
@@ -8052,11 +7993,6 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
-event-target-shim@6.0.2:
- version "6.0.2"
- resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-6.0.2.tgz#ea5348c3618ee8b62ff1d344f01908ee2b8a2b71"
- integrity sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==
-
event-target-shim@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
@@ -8133,344 +8069,357 @@ expect@^29.0.0, expect@^29.7.0:
jest-message-util "^29.7.0"
jest-util "^29.7.0"
-expo-application@~7.0.8:
- version "7.0.8"
- resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-7.0.8.tgz#320af0d6c39b331456d3bc833b25763c702d23db"
- integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==
-
-expo-asset@~12.0.13:
- version "12.0.13"
- resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-12.0.13.tgz#1974ed7abee2ad987a519dbdcbf7f0c647dddf5b"
- integrity sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==
- dependencies:
- "@expo/image-utils" "^0.8.8"
- expo-constants "~18.0.13"
-
-expo-audio@~1.1.1:
- version "1.1.1"
- resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-1.1.1.tgz#7b9763118e321c5dfbf2771cd4a5b6790ce4fc8d"
- integrity sha512-CPCpJ+0AEHdzWROc0f00Zh6e+irLSl2ALos/LPvxEeIcJw1APfBa4DuHPkL4CQCWsVe7EnUjFpdwpqsEUWcP0g==
-
-expo-auth-session@~7.0.11:
- version "7.0.11"
- resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-7.0.11.tgz#fa9510862030962eac0147708ef28d6c63df3f71"
- integrity sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==
- dependencies:
- expo-application "~7.0.8"
- expo-constants "~18.0.13"
- expo-crypto "~15.0.9"
- expo-linking "~8.0.12"
- expo-web-browser "~15.0.11"
+expo-application@~57.0.3:
+ version "57.0.3"
+ resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-57.0.3.tgz#92d3f386e5a02b978c377a24e7eb32d1fcee1edb"
+ integrity sha512-t/CxeLio0NjY9/cOak1tQp62/hAUjzJxmhR4rVUR7K5bb9YoEtfl1ZekTn+Iuh+dYOMYzF0l72ODMSmY3CR1NA==
+
+expo-asset@~57.0.18:
+ version "57.0.18"
+ resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-57.0.18.tgz#c81177df21f2e84f88a1a5ca99292bd7ba425a55"
+ integrity sha512-zDIQkUr0NM2S7YRj98Zohf/OBuM1q0o4ctxLz1C1oe4/33lHNLjp+2v3qUtBWVwn0zKka8UGADmS8M+83Wi6pw==
+ dependencies:
+ "@expo/image-utils" "^0.11.5"
+ expo-constants "~57.0.19"
+
+expo-audio@~57.0.5:
+ version "57.0.5"
+ resolved "https://registry.yarnpkg.com/expo-audio/-/expo-audio-57.0.5.tgz#e749518742d50436b71d83f1bd2601c5db091f6a"
+ integrity sha512-39fX5JC5zDvELB4/eDJPj2XAYRN0W9+K2DfDWm5N3aC5WZ7HRrhupWtT6hbH09NlfYpsRSqYOwEZnocFM42ynQ==
+
+expo-auth-session@~57.0.13:
+ version "57.0.13"
+ resolved "https://registry.yarnpkg.com/expo-auth-session/-/expo-auth-session-57.0.13.tgz#f96aa1a4b8ccb7163968d8b816617a41cbbfb19c"
+ integrity sha512-Ky40Vn2ZQVTesh0pEALgcxPxKZdp7WHu2ODYh0lh1YD3ciQd6WhobYYj2oq8MO9Rx9BBsXC64d2L5+TG+mDgSw==
+ dependencies:
+ expo-application "~57.0.3"
+ expo-constants "~57.0.19"
+ expo-crypto "~57.0.3"
+ expo-linking "~57.0.11"
+ expo-web-browser "~57.0.3"
invariant "^2.2.4"
-expo-av@~16.0.8:
- version "16.0.8"
- resolved "https://registry.yarnpkg.com/expo-av/-/expo-av-16.0.8.tgz#b1671127f3b2ecaeb9c69fc2301cf791d4504dd6"
- integrity sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==
-
-expo-build-properties@~1.0.10:
- version "1.0.10"
- resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-1.0.10.tgz#2c3fb4248f78828e952defa636635a653e3ad546"
- integrity sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q==
+expo-build-properties@~57.0.22:
+ version "57.0.22"
+ resolved "https://registry.yarnpkg.com/expo-build-properties/-/expo-build-properties-57.0.22.tgz#38f2d91e0db8d43d4c6471df277cc9fcdf6ecd75"
+ integrity sha512-A5oRf2I02XDRu2NnqKo7Au5kUu244unvyoRMUsB+xvg/BOlsR+G/oyDFGkjbda1cJbeKm9eqposIXbA2ri5UrQ==
dependencies:
- ajv "^8.11.0"
+ "@expo/schema-utils" "^57.0.2"
+ resolve-from "^5.0.0"
semver "^7.6.0"
-expo-clipboard@~8.0.8:
- version "8.0.8"
- resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-8.0.8.tgz#5e52054a4bbaebef090ec6fe5eaa200072ff94f7"
- integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA==
-
-expo-constants@~18.0.13:
- version "18.0.13"
- resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.13.tgz#0117f1f3d43be7b645192c0f4f431fb4efc4803d"
- integrity sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==
- dependencies:
- "@expo/config" "~12.0.13"
- "@expo/env" "~2.0.8"
+expo-clipboard@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-57.0.2.tgz#639996f99407acd34d37ba3d3b7f0b094ca62d97"
+ integrity sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==
-expo-constants@~18.0.14:
- version "18.0.14"
- resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-18.0.14.tgz#fb60b93f498d76ebf0a42e0e6e55a2b2d84ede76"
- integrity sha512-BUZm9mkl/TX7zNaN0N4C83ws7mEnesCFiKhqK+rMZwTD2+Q4wWB2kBltqnC/LrCRaMyRi40XxhCRVwuKnJkxXQ==
+expo-constants@~57.0.19:
+ version "57.0.19"
+ resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-57.0.19.tgz#9df45e23672342f44f12f290399d95097af738c6"
+ integrity sha512-3eK1kZRwCdMizQLlM/PPlPwjaPnB5qKvdRDMBMnB1ZxmvWJBf9TB4uEO4DMMfIPaFUxYFs29dsR5fPr1gh3FTQ==
dependencies:
- "@expo/config" "~12.0.14"
- "@expo/env" "~2.0.12"
+ "@expo/env" "~2.4.3"
-expo-crypto@~15.0.9:
- version "15.0.9"
- resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-15.0.9.tgz#5600c2e08e9946a8b1dfc8ceaa9891714b79ca8a"
- integrity sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==
- dependencies:
- base64-js "^1.3.0"
+expo-crypto@~57.0.3:
+ version "57.0.3"
+ resolved "https://registry.yarnpkg.com/expo-crypto/-/expo-crypto-57.0.3.tgz#e232bf2775f268baa8c5afab30085dc850410bb2"
+ integrity sha512-SAWqEfF37nc8ot7bXsVmu9AFYwQdC2kGvH/eNAHiR3bmlcf1Vv8wu9AnGKT+a90r3T8YfwMpZP4QAYlo4vCn9w==
-expo-dev-client@~6.0.21:
- version "6.0.21"
- resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-6.0.21.tgz#244f2c6b618e0a3eead8cc25af3583dad30864f4"
- integrity sha512-SWI6HD0pa4eJujkYFkvvpezUE1zmJXGLu+34azpu7+QJgO+FLutDYDj8BSTdeH/NYDEClDFjCGqVMcWETvmsCQ==
+expo-dev-client@~57.0.19:
+ version "57.0.19"
+ resolved "https://registry.yarnpkg.com/expo-dev-client/-/expo-dev-client-57.0.19.tgz#aefa665fcb736365ac161d4811723c8b4498b33e"
+ integrity sha512-7giMK6BLL685GECHem2UAQvS473ImZLi5l+Q5tq+XSyv6I9UNVPs0p07mKwgBzq8z7DR3d4nTl702ziFxTjO3Q==
dependencies:
- expo-dev-launcher "6.0.21"
- expo-dev-menu "7.0.19"
- expo-dev-menu-interface "2.0.0"
- expo-manifests "~1.0.11"
- expo-updates-interface "~2.0.0"
+ expo-dev-launcher "~57.0.20"
+ expo-dev-menu "~57.0.18"
+ expo-dev-menu-interface "~57.0.0"
+ expo-manifests "~57.0.2"
+ expo-updates-interface "~57.0.2"
-expo-dev-launcher@6.0.21:
- version "6.0.21"
- resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-6.0.21.tgz#66e4ce9f5c0f35cd359f3f27831fb0ca39cb7e16"
- integrity sha512-QZ9gcKMZbp6EsIhzS0QoGB8Cf4xeVJhjbNgWUwcoBIk8gshoFz8CkCQOnX+HNv2sSY3rdCaNpx3Xo0Rflyq7rA==
+expo-dev-launcher@~57.0.20:
+ version "57.0.20"
+ resolved "https://registry.yarnpkg.com/expo-dev-launcher/-/expo-dev-launcher-57.0.20.tgz#1a268a13294223c9577d4d9fffba85792018773c"
+ integrity sha512-F6oAHaDn9j3L/gyMGE+OalriX6PHZWnZLxpN5jc6crTrp8f6hSwGhmsHpAvdOzijfMjyi3R2UkcISFsCIIQYFA==
dependencies:
- ajv "^8.11.0"
- expo-dev-menu "7.0.19"
- expo-manifests "~1.0.11"
+ "@expo/schema-utils" "^57.0.2"
+ expo-dev-menu "~57.0.18"
+ expo-manifests "~57.0.2"
-expo-dev-menu-interface@2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-2.0.0.tgz#c0d6db65eb4abc44a2701bc2303744619ad05ca6"
- integrity sha512-BvAMPt6x+vyXpThsyjjOYyjwfjREV4OOpQkZ0tNl+nGpsPfcY9mc6DRACoWnH9KpLzyIt3BOgh3cuy/h/OxQjw==
+expo-dev-menu-interface@~57.0.0:
+ version "57.0.0"
+ resolved "https://registry.yarnpkg.com/expo-dev-menu-interface/-/expo-dev-menu-interface-57.0.0.tgz#23bd53c35d00cead3fd963a9bc3e2abf8c00467d"
+ integrity sha512-F47VdzOHYc19FhI/jBgctpO8a5UskTIxG6a1E5t3W5gF8VImuvBQffdXXfLHhsuCl7dS3v3U0R45cleeVXO1Zg==
-expo-dev-menu@7.0.19:
- version "7.0.19"
- resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-7.0.19.tgz#0a95758e99a857d2a63858a47b3d6ab0bcd7120a"
- integrity sha512-ju5MZiBCPhUKKvHy0ElZdnlhq01mkEEiR8jfrgQVvW26aWjzjLiOhppNAyXtvGbhk7WxJim3wYMiqFFrjGdfKA==
+expo-dev-menu@~57.0.18:
+ version "57.0.18"
+ resolved "https://registry.yarnpkg.com/expo-dev-menu/-/expo-dev-menu-57.0.18.tgz#5256bf45ed213a5387a02c35b4028e14d2583486"
+ integrity sha512-H7m9ENtVYz4FpJktQj+z/njIN1t7kOEaKCv8Qgv2SdAlyFTW8s2EnufxPoSlg2zUTanaauJ6wrR6XESHwW7kpw==
dependencies:
- expo-dev-menu-interface "2.0.0"
+ expo-dev-menu-interface "~57.0.0"
-expo-device@~8.0.10:
- version "8.0.10"
- resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-8.0.10.tgz#88be854d6de5568392ed814b44dad0e19d1d50f8"
- integrity sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==
+expo-device@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-57.0.2.tgz#e6f015372e508d78c7d1ffb1e0a9f0bd366521ac"
+ integrity sha512-1A0/FICR3CgrH43FeiDQP3l5eHDmiMoh4EjXfhfoHHkMgdBQdyD0kwDWskiGZlQ3E0Nd0LHkCFH6vJ1ofm/jow==
dependencies:
ua-parser-js "^0.7.33"
-expo-document-picker@~14.0.8:
- version "14.0.8"
- resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-14.0.8.tgz#ca1d99cc432c48e69a6390eb035f3301557e3699"
- integrity sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==
+expo-document-picker@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-57.0.2.tgz#d972b544ad121ef07c4cc6ee62d8b1e95c110d86"
+ integrity sha512-vVBbBNg0piMJUIxjB+DR6JaZZpcSl90ZUoywLA/EgNHTfRkZW+9axP97UxM2a0Zm0W6WfH3cLzzGAEfP9OKGwg==
-expo-file-system@~19.0.24:
- version "19.0.24"
- resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-19.0.24.tgz#71e6872e1b7a41890f8631842e9f1e631846b5ae"
- integrity sha512-hZs0Ng+coNjx7YJ8MPwfJpoU1SRul6OZ9LZycFzozUeoR+denbyr944YrMP9NVn+Zf1tdC7p0K0zgah3ZySOYg==
+expo-file-system@~57.0.7:
+ version "57.0.7"
+ resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-57.0.7.tgz#03c4587c0027f65f7e04d88796aefcfe74b03cae"
+ integrity sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==
-expo-font@~14.0.12:
- version "14.0.12"
- resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-14.0.12.tgz#ff8b176b3fee241e2ae5d3e2eabf3c783460b956"
- integrity sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==
+expo-font@~57.0.4:
+ version "57.0.4"
+ resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-57.0.4.tgz#ccd67dd2ee3168510a1a2453a5a8b8fd7f8a5fe4"
+ integrity sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==
dependencies:
fontfaceobserver "^2.1.0"
-expo-image-loader@~6.0.0:
- version "6.0.0"
- resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-6.0.0.tgz#15230442cbb90e101c080a4c81e37d974e43e072"
- integrity sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==
+expo-glass-effect@^57.0.4:
+ version "57.0.4"
+ resolved "https://registry.yarnpkg.com/expo-glass-effect/-/expo-glass-effect-57.0.4.tgz#9b7e65b1475970f0662c045b71fce99bf2710a5c"
+ integrity sha512-/AG2Kljbqfgxu+GchI2YFIbxiF9cPsIpCy4iNDzlRiZk9rvR6BwRfB6gfQVu3V2ExaoGf2h5IsEnqVI5MYNBAg==
+
+expo-image-loader@~57.0.1:
+ version "57.0.1"
+ resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-57.0.1.tgz#b031dc3e62ac09004f3e436d7081487236791f53"
+ integrity sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==
-expo-image-manipulator@~14.0.8:
- version "14.0.8"
- resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-14.0.8.tgz#1c457acbd2bcabe987fbd650c0f29120c3366ba6"
- integrity sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==
+expo-image-manipulator@~57.0.20:
+ version "57.0.20"
+ resolved "https://registry.yarnpkg.com/expo-image-manipulator/-/expo-image-manipulator-57.0.20.tgz#88648a2391e114906358a28a90ed7fd5d8b36398"
+ integrity sha512-LyuoLOJVNWNVEOABqcNCHrAiTtaRQm3usJMK4veG1hnglmt85eSuQTgUaug2TdUyAwGDJbdee4Iif+toTQwNgA==
dependencies:
- expo-image-loader "~6.0.0"
+ expo-image-loader "~57.0.1"
-expo-image-picker@~17.0.11:
- version "17.0.11"
- resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.11.tgz#8f1537df946f7c19747ba9d0043ef7e3e8cb67dc"
- integrity sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==
+expo-image-picker@~57.0.20:
+ version "57.0.20"
+ resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-57.0.20.tgz#c32efa3daad2880ebf54e9dc0cddd09e57967a34"
+ integrity sha512-plgcGuMZMWqDchjXQW6S/WepYLB5KqfbZOzwC6xe0+j5IhY4ipvVuRZcXeP/0NgKgU0Jiu/XoTK4Bi8crtZ+sQ==
dependencies:
- expo-image-loader "~6.0.0"
+ expo-image-loader "~57.0.1"
-expo-image@~3.0.11:
- version "3.0.11"
- resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-3.0.11.tgz#54195565dc710e632c10414c3609deebb7149ac5"
- integrity sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA==
+expo-image@~57.0.5:
+ version "57.0.5"
+ resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-57.0.5.tgz#fafabb49e50fafc3795bb1d4d221cc5b6a1780b1"
+ integrity sha512-jOm5lofaIKXhQ7i0Hz3I5JuNULw/aghmpIPZXddwQcEtw8+wUhlozq3M7LbsOrcsBEapcG4jFs85DsfuFoz1tQ==
+ dependencies:
+ sf-symbols-typescript "^2.2.0"
-expo-json-utils@~0.15.0:
- version "0.15.0"
- resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-0.15.0.tgz#6723574814b9e6b0a90e4e23662be76123ab6ae9"
- integrity sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==
+expo-json-utils@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-57.0.2.tgz#913c4389e1b51a4429e13e09ea1923541ca79ef3"
+ integrity sha512-9kYZECLKE3pYZQs+kt3+ggY83Lfryog9/UEo3HJKGcHUZeKKtlFBIPFjI4LzREubsvjP7AsvG0FdEX9EquIoAQ==
-expo-keep-awake@~15.0.8:
- version "15.0.8"
- resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
- integrity sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==
+expo-keep-awake@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz#ce266252ad478523bb3431d40f67bd643f86bd9c"
+ integrity sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ==
-expo-linking@~8.0.12:
- version "8.0.12"
- resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-8.0.12.tgz#773bb2d18b89750af87fa807a6298d519130bdb6"
- integrity sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==
+expo-linking@~57.0.11:
+ version "57.0.11"
+ resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-57.0.11.tgz#49bd06c942afd4094f73fac523c249702ea46ecb"
+ integrity sha512-bfTyiPb8szacs8XkGowXt5ssH/vbF26vFG8WUWQar8CcRpQcIzXY980ZkP7pK7Pcdy5MattwFdujrWOxwv5s3Q==
dependencies:
- expo-constants "~18.0.13"
+ expo-constants "~57.0.19"
invariant "^2.2.4"
-expo-localization@~17.0.9:
- version "17.0.9"
- resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-17.0.9.tgz#92b2827a4b68e42c0cd0ad1d3f1abf0ebe50a4a0"
- integrity sha512-k5eYHr7iLMob3M8cHH6bgDrDRmqnZG8xKGLhpx8ST+LsFXmSgYpJ/t0VwWm0gz64C/K669XhObdG3D6QwCGqhw==
+expo-localization@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-localization/-/expo-localization-57.0.2.tgz#13caf8073eb37ff1f689b345bc131c3d49b246fd"
+ integrity sha512-sIKYH3Ivddoge568WY6j+Kp2MAAZzWWqEnY2bokLkJFwX366tGL1mEnoY2WUi/qHatFFPop2yqDdurzbhnnimQ==
dependencies:
rtl-detect "^1.0.2"
-expo-location@~19.0.8:
- version "19.0.8"
- resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-19.0.8.tgz#1805393151b1286021c1ad36246b6fd095d09b55"
- integrity sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==
+expo-location@~57.0.20:
+ version "57.0.20"
+ resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-57.0.20.tgz#1862a3440d1fe785edb3f5681d8cc01761041d54"
+ integrity sha512-E9cr7A5eQS91bm3McMx3FiWy7SCorVTPLq9gLFFW+7dfuSqQp5FLNBZQ6NFJaS8tVhCyl2WyKMFh0TAo/+7K9Q==
+ dependencies:
+ "@expo/image-utils" "^0.11.5"
-expo-manifests@~1.0.11:
- version "1.0.11"
- resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-1.0.11.tgz#ffb21e433526a4daa7b3709b852df436e8fe46d1"
- integrity sha512-6zItytTewN37Cjhp3glUg0ozrgW2GwB8x9wtfzUNoJIMmxO38nnGdTLMaotYhRqdf5PP2Dzdmej1HDHXVNUpRw==
+expo-manifests@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-manifests/-/expo-manifests-57.0.2.tgz#c8af35f2e0f3b45cef826ac5a39d02b0a7ed54ff"
+ integrity sha512-pPIIcKR73OShwlWMg8H90WRzKUd2krDdKlKAwuQV1FtUwE7H3hQEFw0ePFQY9yFoGMF/77ryYwNKfI5umMcCog==
dependencies:
- "@expo/config" "~12.0.13"
- expo-json-utils "~0.15.0"
+ expo-json-utils "~57.0.2"
-expo-modules-autolinking@3.0.27:
- version "3.0.27"
- resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-3.0.27.tgz#5aded71d782cbd750986547d366f66279d5c0d4d"
- integrity sha512-Ge216xmfXcba+aUQssfmUo9Hf92wXX3xcXJmOsC/wpuV5B5ozm4BTkRKpoCXEkdD8eJVmFy7ATS8yD3upFOPQA==
+expo-modules-autolinking@~57.0.13:
+ version "57.0.13"
+ resolved "https://registry.yarnpkg.com/expo-modules-autolinking/-/expo-modules-autolinking-57.0.13.tgz#64c93c919ff6207d5b2183fcc4e954a440baf8d2"
+ integrity sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ==
dependencies:
- "@expo/spawn-async" "^1.7.2"
+ "@expo/require-utils" "^57.0.5"
+ "@expo/spawn-async" "^1.8.0"
chalk "^4.1.0"
commander "^7.2.0"
- require-from-string "^2.0.2"
- resolve-from "^5.0.0"
-expo-modules-core@3.0.30:
- version "3.0.30"
- resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-3.0.30.tgz#ec4e216a33083afb0ddf314615d6d31a741984d6"
- integrity sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==
+expo-modules-core@~57.0.19:
+ version "57.0.19"
+ resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-57.0.19.tgz#2a9021c7d5658c4574c70607b06312298936dd46"
+ integrity sha512-eAJwa5u30r4BTT8cFAcrY3809qG1cPszmEci7//+RjWGA1OMXtwirhVY1H+YtCJPyVGs9MuwrIjqQDMReguG2g==
dependencies:
+ "@expo/expo-modules-macros-plugin" "0.6.1"
+ expo-modules-jsi "~57.1.1"
invariant "^2.2.4"
-expo-navigation-bar@~5.0.10:
- version "5.0.10"
- resolved "https://registry.yarnpkg.com/expo-navigation-bar/-/expo-navigation-bar-5.0.10.tgz#64e4fdb91ff3872110373b56c8e65d196b40979c"
- integrity sha512-r9rdLw8mY6GPMQmVVOY/r1NBBw74DZefXHF60HxhRsdNI2kjc1wLdfWfR2rk4JVdOvdMDujnGrc9HQmqM3n8Jg==
+expo-modules-jsi@~57.1.1:
+ version "57.1.1"
+ resolved "https://registry.yarnpkg.com/expo-modules-jsi/-/expo-modules-jsi-57.1.1.tgz#0821e42a6a7b1b962cb14416889107481182f62b"
+ integrity sha512-ssnwz3ZuIgCbZj5A3J6NlhQrXZojg+BjkeWrOWqVJhyMHlV/A/cRT7s986chKku97BFt6ucmDu6/BCzrV45Wcw==
+
+expo-navigation-bar@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-navigation-bar/-/expo-navigation-bar-57.0.2.tgz#d22ede0b1c9ddb9291417994dbefbd6f21bcf423"
+ integrity sha512-Ufe49dsTZjarA7gMMwwwnNJWpXo+oTyl5Ud0g3lsGVrj2HXjyw9cb9098N8+CoWpjfIa5PghuRYYXrcCrGQIHg==
dependencies:
- "@react-native/normalize-colors" "0.81.5"
debug "^4.3.2"
- react-native-is-edge-to-edge "^1.2.1"
-expo-notifications@~0.32.17:
- version "0.32.17"
- resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.32.17.tgz#7c9786f167da39d504edc450a84bcb5489c1a54e"
- integrity sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==
+expo-notifications@~57.0.21:
+ version "57.0.21"
+ resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-57.0.21.tgz#228d9b733063c020559a643935cc16b74ea65aeb"
+ integrity sha512-CWBW6DxqRJ2MaYZW5+/WfEWwIwbiVk5UAPqx0kE676B4R1+PlAXARk/kuNeQBcVrcDNV6i9ihE9B0db8Cd2RXQ==
dependencies:
- "@expo/image-utils" "^0.8.8"
- "@ide/backoff" "^1.0.0"
+ "@expo/image-utils" "^0.11.5"
abort-controller "^3.0.0"
- assert "^2.0.0"
badgin "^1.1.5"
- expo-application "~7.0.8"
- expo-constants "~18.0.13"
-
-expo-router@~6.0.24:
- version "6.0.24"
- resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-6.0.24.tgz#727d0c7c0055dbe9d590ad8fb9af978bb2a0dfd9"
- integrity sha512-KIssKXnwjrdCxPWC8GsMTfKTwng40VrCsO16O9x6fKlfUwBW8itxY9PpCGyQeMJkiEADa+DUhtrDgl+uHg4/DA==
- dependencies:
- "@expo/metro-runtime" "^6.1.2"
- "@expo/schema-utils" "^0.1.8"
- "@radix-ui/react-slot" "1.2.0"
+ expo-application "~57.0.3"
+ expo-constants "~57.0.19"
+
+expo-router@~57.0.23:
+ version "57.0.23"
+ resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-57.0.23.tgz#d15a2dd4a983028a653ed227edb237007ba1b4a6"
+ integrity sha512-u/iWSdQovEma/owymgz8etxGAif5ZrC8YdXYeGQhDRZwXpAvraordYV0SWej1QWWbYoIkgXcoqHVkgsAff8P4w==
+ dependencies:
+ "@expo/log-box" "^57.0.4"
+ "@expo/metro-runtime" "^57.0.16"
+ "@expo/schema-utils" "^57.0.2"
+ "@expo/ui" "^57.0.20"
+ "@radix-ui/react-slot" "^1.2.0"
"@radix-ui/react-tabs" "^1.1.12"
- "@react-navigation/bottom-tabs" "^7.4.0"
- "@react-navigation/native" "^7.1.8"
- "@react-navigation/native-stack" "^7.3.16"
+ "@react-native-masked-view/masked-view" "^0.3.2"
client-only "^0.0.1"
+ color "^4.2.3"
debug "^4.3.4"
escape-string-regexp "^4.0.0"
- expo-server "^1.0.7"
+ expo-glass-effect "^57.0.4"
+ expo-server "^57.0.3"
+ expo-symbols "^57.0.3"
fast-deep-equal "^3.1.3"
invariant "^2.2.4"
- nanoid "^3.3.8"
+ nanoid "^3.3.18"
query-string "^7.1.3"
react-fast-compare "^3.2.2"
- react-native-is-edge-to-edge "^1.1.6"
- semver "~7.6.3"
+ react-is "^19.1.0"
+ react-native-drawer-layout "^4.2.2"
+ react-native-screens "^4.26.0"
server-only "^0.0.1"
sf-symbols-typescript "^2.1.0"
shallowequal "^1.1.0"
- use-latest-callback "^0.2.1"
+ standard-navigation "^0.0.5"
vaul "^1.1.2"
-expo-screen-orientation@~9.0.9:
- version "9.0.9"
- resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-9.0.9.tgz#74ed82582006f72106b7165512cdfc01367433ba"
- integrity sha512-UTU745XoqBvQJkZ820GTfMuOxlkppYqaeQ+x0wdu44cyrZiZugqe+egFF2+zC+SaxgAltU5EuO8GIj9xSF+YMw==
+expo-screen-orientation@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-screen-orientation/-/expo-screen-orientation-57.0.2.tgz#6c576475cdc5356a7345d270db023cbafb805407"
+ integrity sha512-9wIa/dU561fidHYChkg6LIDAcYr8uMBa8fOws1Km9/slaF3N/a2kgcy0TUZWvNIe7at3r4g2kVva9vRBJBkaEQ==
-expo-server@^1.0.7:
- version "1.0.7"
- resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-1.0.7.tgz#fe700518f73651cb0736e79e3a4f2f8b2e2671b7"
- integrity sha512-mcmyML3oXcqFUXUxtdtCL1O00ztNI2v76d+MdniXRUgHNxIcHZ05zo+DqBaOOT6LQnPk4vA4YHqQl7iGUfRb3g==
+expo-server@^57.0.3:
+ version "57.0.3"
+ resolved "https://registry.yarnpkg.com/expo-server/-/expo-server-57.0.3.tgz#02e038a85453f1f4eb00024256b4b3cab949cace"
+ integrity sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==
-expo-sharing@~14.0.8:
- version "14.0.8"
- resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-14.0.8.tgz#cfd5fcf77ab5f64cf3d192a40a925abb316d3545"
- integrity sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==
+expo-sharing@~57.0.22:
+ version "57.0.22"
+ resolved "https://registry.yarnpkg.com/expo-sharing/-/expo-sharing-57.0.22.tgz#03e630b34986374023693356b7a85bec8dcfdd21"
+ integrity sha512-IWmiz0Dnjd7ifsZ2Gg5XODxb6DQoJ4/H6FofW9bTL9gDjfX2+va8SV1BYVkFvJ8vKQoKR4HgkMCKDCZlrrbKkg==
+ dependencies:
+ "@expo/config-plugins" "^57.0.9"
+ "@expo/config-types" "^57.0.2"
+ "@expo/plist" "^0.8.1"
-expo-splash-screen@~31.0.13:
- version "31.0.13"
- resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz#f41f1a4c8bb1ae7fcc52b760e7dd485d7ddec642"
- integrity sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==
+expo-splash-screen@~57.0.9:
+ version "57.0.9"
+ resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz#916e23cdfcb1538ba88e091692dedcb36b13b08d"
+ integrity sha512-7cKDn7uBwg0RkcmY015/5j9st4msWJ4zQiPmQdVZoppW3cFSbv05n7xhM6CjUU466Tx4VRTqDF21HuIHkmnEtA==
dependencies:
- "@expo/prebuild-config" "^54.0.8"
+ "@expo/config-plugins" "~57.0.9"
+ "@expo/image-utils" "^0.11.5"
+ xml2js "0.6.0"
+
+expo-status-bar@~57.0.1:
+ version "57.0.1"
+ resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-57.0.1.tgz#0a8ec1657fcd0987f9612ca411a0c3d002f0eaf0"
+ integrity sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==
-expo-status-bar@~3.0.9:
- version "3.0.9"
- resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-3.0.9.tgz#87cfc803fa614f09a985b8e75e3dd7abd51ce2cb"
- integrity sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==
+expo-symbols@^57.0.3:
+ version "57.0.3"
+ resolved "https://registry.yarnpkg.com/expo-symbols/-/expo-symbols-57.0.3.tgz#291f56d981d8271842aa0878b7e197c1793c1eef"
+ integrity sha512-uuK5yHLVR7kExUCrO/ItBvp/kp1maQ9Nkb6KvNy5ZUQnN0NMKwuhJ4GvbfS9N3ACmvCjTT4gKvg4yH5UuKIYNA==
dependencies:
- react-native-is-edge-to-edge "^1.2.1"
+ "@expo-google-fonts/material-symbols" "^0.4.1"
+ sf-symbols-typescript "^2.0.0"
-expo-system-ui@~6.0.9:
- version "6.0.9"
- resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-6.0.9.tgz#09b4a4301ab25ec594ae39beb7d24197c231a30c"
- integrity sha512-eQTYGzw1V4RYiYHL9xDLYID3Wsec2aZS+ypEssmF64D38aDrqbDgz1a2MSlHLQp2jHXSs3FvojhZ9FVela1Zcg==
+expo-system-ui@~57.0.4:
+ version "57.0.4"
+ resolved "https://registry.yarnpkg.com/expo-system-ui/-/expo-system-ui-57.0.4.tgz#e70154f9b9e2e2d28d94b6bfa05992e200ba6b6d"
+ integrity sha512-z//rxQJzhTNA1PcKrUtWmrd21jAS3nEDu1b0n8B/7LTTR933OcsZYP1HnbaZifuo2ZH/h7nce2Ow61WZ1Ig2bg==
dependencies:
- "@react-native/normalize-colors" "0.81.5"
+ "@react-native/normalize-colors" "0.86.3"
debug "^4.3.2"
-expo-task-manager@~14.0.9:
- version "14.0.9"
- resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-14.0.9.tgz#7e410711cf3fd0c465a191916d699c6560c93192"
- integrity sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA==
+expo-task-manager@~57.0.20:
+ version "57.0.20"
+ resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-57.0.20.tgz#874f2fe8b6a22d158b627094ebe3d1824fe31bbb"
+ integrity sha512-gfjZPZ3OOD+d5tWnLASH1MwjwcthSFRQBQNBOwa4/bk/JVrGp92kFF8jWzH2iald1YFTOblalcHHhzQzH7NbTA==
dependencies:
- unimodules-app-loader "~6.0.8"
+ unimodules-app-loader "~57.0.2"
-expo-updates-interface@~2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz#7721cb64c37bcb46b23827b2717ef451a9378749"
- integrity sha512-pTzAIufEZdVPKql6iMi5ylVSPqV1qbEopz9G6TSECQmnNde2nwq42PxdFBaUEd8IZJ/fdJLQnOT3m6+XJ5s7jg==
+expo-updates-interface@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-57.0.2.tgz#b3d357aee2a618e6e38b275d7de9886776338fd3"
+ integrity sha512-x3SA5toiHZEkW+5oeNdUM1Rvt6M3ExegQsSwS3Z1+S9gn9utGYTGBkb8MBQivhjIZiRlQYDIhauxA8kySQEWtQ==
-expo-web-browser@~15.0.11:
- version "15.0.11"
- resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-15.0.11.tgz#6a8134e2398031ef79c89f516b15a18103903ef5"
- integrity sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==
+expo-web-browser@~57.0.3:
+ version "57.0.3"
+ resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-57.0.3.tgz#adc332852ef29546de2fa47ba33ce4df9137f98e"
+ integrity sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ==
-expo@~54.0.37:
- version "54.0.37"
- resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.37.tgz#8d465e832213ed708562b6796ade99893be10e28"
- integrity sha512-afzbtO4i/K9ZCrzyLgEOaXW56w/yuAK61JkCOMrw/S/VFCAp6YwKWA494X5mIcrVmRAmclv/ec7ga8ndTkzhxw==
+expo@~57.0.25:
+ version "57.0.25"
+ resolved "https://registry.yarnpkg.com/expo/-/expo-57.0.25.tgz#52278e265bc9ca9e9063e1220920fe4ec3c65b8f"
+ integrity sha512-oqtX4PekGgU+pBQfsbnbwxh5tKG58o27mN13wTmAhRPlQyK9SHICfCNHxBo+uQyT1BlI3OLhqTwu1qKmHYL5Ug==
dependencies:
"@babel/runtime" "^7.20.0"
- "@expo/cli" "54.0.27"
- "@expo/config" "~12.0.14"
- "@expo/config-plugins" "~54.0.5"
- "@expo/devtools" "0.1.8"
- "@expo/fingerprint" "0.15.5"
- "@expo/metro" "~54.2.0"
- "@expo/metro-config" "54.0.17"
- "@expo/vector-icons" "^15.0.3"
+ "@expo/cli" "^57.0.27"
+ "@expo/config" "~57.0.9"
+ "@expo/config-plugins" "~57.0.9"
+ "@expo/devtools" "~57.0.1"
+ "@expo/dom-webview" "~57.0.1"
+ "@expo/fingerprint" "^0.20.13"
+ "@expo/local-build-cache-provider" "^57.0.8"
+ "@expo/log-box" "^57.0.4"
+ "@expo/metro" "~56.0.2"
+ "@expo/metro-config" "~57.0.12"
"@ungap/structured-clone" "^1.3.0"
- babel-preset-expo "~54.0.12"
- expo-asset "~12.0.13"
- expo-constants "~18.0.14"
- expo-file-system "~19.0.24"
- expo-font "~14.0.12"
- expo-keep-awake "~15.0.8"
- expo-modules-autolinking "3.0.27"
- expo-modules-core "3.0.30"
+ babel-preset-expo "~57.0.13"
+ expo-asset "~57.0.18"
+ expo-constants "~57.0.19"
+ expo-file-system "~57.0.7"
+ expo-font "~57.0.4"
+ expo-keep-awake "~57.0.2"
+ expo-modules-autolinking "~57.0.13"
+ expo-modules-core "~57.0.19"
pretty-format "^29.7.0"
react-refresh "^0.14.2"
- whatwg-url-without-unicode "8.0.0-3"
+ whatwg-url-minimum "^0.1.2"
exponential-backoff@^3.1.1:
version "3.1.2"
@@ -8523,7 +8472,7 @@ fast-glob@^3.2.5, fast-glob@^3.2.9, fast-glob@^3.3.0, fast-glob@^3.3.2:
merge2 "^1.3.0"
micromatch "^4.0.8"
-fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
+fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
@@ -8545,6 +8494,11 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"
+fb-dotslash@0.5.8:
+ version "0.5.8"
+ resolved "https://registry.yarnpkg.com/fb-dotslash/-/fb-dotslash-0.5.8.tgz#c5ef3dacd75e1ddb2197c367052464ddde0115f5"
+ integrity sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==
+
fb-watchman@^2.0.0, fb-watchman@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c"
@@ -8590,6 +8544,11 @@ fetch-cookie@^2.0.3:
set-cookie-parser "^2.4.8"
tough-cookie "^4.0.0"
+fetch-nodeshim@^0.4.10:
+ version "0.4.10"
+ resolved "https://registry.yarnpkg.com/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz#0bde71d3c87fcbd87e037dd498e743d9361b0f71"
+ integrity sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==
+
figures@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
@@ -8787,11 +8746,6 @@ framesync@6.0.1:
dependencies:
tslib "^2.1.0"
-freeport-async@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/freeport-async/-/freeport-async-2.0.0.tgz#6adf2ec0c629d11abff92836acd04b399135bab4"
- integrity sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==
-
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
@@ -9048,7 +9002,7 @@ glob@^13.0.0:
minipass "^7.1.3"
path-scurry "^2.0.2"
-glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
+glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
version "7.2.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
@@ -9237,46 +9191,51 @@ he@^1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+hermes-compiler@250829098.0.17:
+ version "250829098.0.17"
+ resolved "https://registry.yarnpkg.com/hermes-compiler/-/hermes-compiler-250829098.0.17.tgz#221c648d86929223f5e3ac5cee760d6a0c2a9bab"
+ integrity sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw==
+
hermes-estree@0.20.1:
version "0.20.1"
resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.20.1.tgz#0b9a544cf883a779a8e1444b915fa365bef7f72d"
integrity sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==
-hermes-estree@0.29.1:
- version "0.29.1"
- resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.29.1.tgz#043c7db076e0e8ef8c5f6ed23828d1ba463ebcc5"
- integrity sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==
+hermes-estree@0.35.0:
+ version "0.35.0"
+ resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.35.0.tgz#767cce0b14a68b4bc06cd5db7efe889f6188c565"
+ integrity sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==
-hermes-estree@0.32.0:
- version "0.32.0"
- resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.32.0.tgz#bb7da6613ab8e67e334a1854ea1e209f487d307b"
- integrity sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==
+hermes-estree@0.36.0:
+ version "0.36.0"
+ resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.36.0.tgz#5b8c162e986159baf86bc4d01612cff9845ab0f7"
+ integrity sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==
-hermes-estree@0.33.3:
- version "0.33.3"
- resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.33.3.tgz#6d6b593d4b471119772c82bdb0212dfadabb6f17"
- integrity sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==
+hermes-estree@0.36.1:
+ version "0.36.1"
+ resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.36.1.tgz#71368d9e78238728e11ef1f458a8921d0564a572"
+ integrity sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==
-hermes-parser@0.29.1, hermes-parser@^0.29.1:
- version "0.29.1"
- resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.29.1.tgz#436b24bcd7bb1e71f92a04c396ccc0716c288d56"
- integrity sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==
+hermes-parser@0.35.0:
+ version "0.35.0"
+ resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.35.0.tgz#7625ec2f34ab897c2a17a7bea9788d136d5fd8c9"
+ integrity sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==
dependencies:
- hermes-estree "0.29.1"
+ hermes-estree "0.35.0"
-hermes-parser@0.32.0:
- version "0.32.0"
- resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.32.0.tgz#7916984ef6fdce62e7415d354cf35392061cd303"
- integrity sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==
+hermes-parser@0.36.0:
+ version "0.36.0"
+ resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.36.0.tgz#756d6b07301789c5ff0ac3ac7ff6bffa8be7098c"
+ integrity sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==
dependencies:
- hermes-estree "0.32.0"
+ hermes-estree "0.36.0"
-hermes-parser@0.33.3:
- version "0.33.3"
- resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.33.3.tgz#da50ababb7a5ab636d339e7b2f6e3848e217e09d"
- integrity sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==
+hermes-parser@0.36.1, hermes-parser@^0.36.0:
+ version "0.36.1"
+ resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.36.1.tgz#f619b9f99bf34e80fb6f7024b1c62944d2beb14a"
+ integrity sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==
dependencies:
- hermes-estree "0.33.3"
+ hermes-estree "0.36.1"
hermes-parser@^0.20.1:
version "0.20.1"
@@ -9290,7 +9249,7 @@ hey-listen@^1.0.8:
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==
-hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
+hoist-non-react-statics@^3.3.0:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@@ -9396,7 +9355,7 @@ http2-wrapper@^1.0.0-beta.5.2:
quick-lru "^5.1.1"
resolve-alpn "^1.0.0"
-https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1:
+https-proxy-agent@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
@@ -9487,13 +9446,6 @@ image-q@^4.0.0:
dependencies:
"@types/node" "16.9.1"
-image-size@^1.0.2:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.2.1.tgz#ee118aedfe666db1a6ee12bed5821cde3740276d"
- integrity sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==
- dependencies:
- queue "6.0.2"
-
import-fresh@^3.2.1, import-fresh@^3.3.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf"
@@ -9548,7 +9500,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
-inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3:
+inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -9661,7 +9613,7 @@ irregular-plurals@^1.0.0:
resolved "https://registry.yarnpkg.com/irregular-plurals/-/irregular-plurals-1.4.0.tgz#2ca9b033651111855412f16be5d77c62a458a766"
integrity sha512-kniTIJmaZYiwa17eTtWIfm0K342seyugl6vuC8DiiyiRAJWAVlLkqGCI0Im0neo0TkXw+pRcKaBPRdcKHnQJ6Q==
-is-arguments@^1.0.4, is-arguments@^1.1.1:
+is-arguments@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.2.0.tgz#ad58c6aecf563b78ef2bf04df540da8f5d7d8e1b"
integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==
@@ -9764,7 +9716,7 @@ is-date-object@^1.0.5, is-date-object@^1.1.0:
call-bound "^1.0.2"
has-tostringtag "^1.0.2"
-is-docker@^2.0.0, is-docker@^2.1.1:
+is-docker@^2.0.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
@@ -9825,7 +9777,7 @@ is-generator-fn@^2.0.0:
resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118"
integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==
-is-generator-function@^1.0.10, is-generator-function@^1.0.7:
+is-generator-function@^1.0.10:
version "1.1.1"
resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.1.tgz#7995bc35528dedea93ff0a0b05b168862cc8dc11"
integrity sha512-Gn8BWUdrTzf9XUJAvqIYP7QnSC3mKs8QjQdGdJ7HmBemzZo14wj/OVmmAwgxDX/7WhFEjboybL4VhXGIQYPlOA==
@@ -9878,14 +9830,6 @@ is-map@^2.0.2, is-map@^2.0.3:
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e"
integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==
-is-nan@^1.3.2:
- version "1.3.2"
- resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d"
- integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==
- dependencies:
- call-bind "^1.0.0"
- define-properties "^1.1.3"
-
is-negative-zero@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747"
@@ -10019,7 +9963,7 @@ is-text-path@^2.0.0:
dependencies:
text-extensions "^2.0.0"
-is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed-array@^1.1.3:
+is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15:
version "1.1.15"
resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b"
integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==
@@ -10353,13 +10297,11 @@ jest-environment-node@^29.7.0:
jest-mock "^29.7.0"
jest-util "^29.7.0"
-jest-expo@~54.0.18:
- version "54.0.18"
- resolved "https://registry.yarnpkg.com/jest-expo/-/jest-expo-54.0.18.tgz#399c254379c8440eb71270db8e6e4f4b308223cb"
- integrity sha512-couPPatkifK3v0WZW7zu1HZW5HyxY+1cY69LKHPawwbZTuIvf5wk4SYHtoUrGa/nAM2jW/YiXDLWfociEHJPvw==
+jest-expo@~57.0.5:
+ version "57.0.5"
+ resolved "https://registry.yarnpkg.com/jest-expo/-/jest-expo-57.0.5.tgz#149c0b57cc309614ea2ceb39ce622b6dcb19155a"
+ integrity sha512-U08fwjaDBwnnbKnZcOA0WQbHfKqOWTCGku+IdCaekrLZmfbbZr3P1WqEsXHo6ntidQ3DmW+TT6G1aOp+ChYfKA==
dependencies:
- "@expo/config" "~12.0.14"
- "@expo/json-file" "^10.0.16"
"@jest/create-cache-key-function" "^29.2.1"
"@jest/globals" "^29.2.1"
babel-jest "^29.2.1"
@@ -10369,7 +10311,7 @@ jest-expo@~54.0.18:
jest-watch-typeahead "2.2.1"
json5 "^2.2.3"
lodash "^4.17.19"
- react-test-renderer "19.1.0"
+ react-test-renderer "19.2.3"
server-only "^0.0.1"
stacktrace-js "^2.0.2"
@@ -10603,7 +10545,7 @@ jest-util@30.0.5:
graceful-fs "^4.2.11"
picomatch "^4.0.2"
-jest-util@^29.0.0, jest-util@^29.7.0:
+jest-util@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc"
integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==
@@ -10737,6 +10679,11 @@ joi@^18.0.1:
"@hapi/topo" "^6.0.2"
"@standard-schema/spec" "^1.0.0"
+jose@^6.0.12, jose@^6.1.0:
+ version "6.2.12"
+ resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.12.tgz#65663e146edd010b98ece83f81737d3aa95fd0d7"
+ integrity sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==
+
jpeg-js@^0.4.4:
version "0.4.4"
resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.4.4.tgz#a9f1c6f1f9f0fa80cdb3484ed9635054d28936aa"
@@ -11130,20 +11077,21 @@ listr@^0.14.3:
p-map "^2.0.0"
rxjs "^6.3.3"
-livekit-client@^2.15.7:
- version "2.15.7"
- resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.15.7.tgz#98a63cb259eaf6b54a15fe0376d212c6b37fe06b"
- integrity sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==
+livekit-client@^2.20.1:
+ version "2.22.3"
+ resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.22.3.tgz#1ca257dbe6abb722f1410710f07ac54168eb97bf"
+ integrity sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==
dependencies:
"@livekit/mutex" "1.1.1"
- "@livekit/protocol" "1.39.3"
+ "@livekit/protocol" "1.50.4"
events "^3.3.0"
+ jose "^6.1.0"
loglevel "^1.9.2"
+ machina "^7.0.0"
sdp-transform "^2.15.0"
- ts-debounce "^4.0.0"
tslib "2.8.1"
typed-emitter "^2.1.0"
- webrtc-adapter "^9.0.1"
+ webrtc-adapter "9.0.6"
load-bmfont@^1.4.1:
version "1.4.2"
@@ -11208,7 +11156,7 @@ lodash.kebabcase@^4.1.1:
resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
integrity sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==
-lodash.memoize@4.x, lodash.memoize@~4.1.2:
+lodash.memoize@~4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==
@@ -11371,6 +11319,11 @@ lucide-react-native@~0.475.0:
resolved "https://registry.yarnpkg.com/lucide-react-native/-/lucide-react-native-0.475.0.tgz#a96bbcaa8d953c845cf3038d136aba408318c484"
integrity sha512-V5tho5qQ89GD4qdzL07ZyXdrnpXZFLirGfaG6BB2vKhO6X1iA7UYYqntgBQ//ZuTUEdevskl+dVT5O4A9oOJUg==
+machina@^7.0.0:
+ version "7.0.1"
+ resolved "https://registry.yarnpkg.com/machina/-/machina-7.0.1.tgz#859ad49203a5e194ed6bd1f6ef102c308bf6b839"
+ integrity sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==
+
magic-string@^0.30.21:
version "0.30.21"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
@@ -11385,7 +11338,7 @@ make-dir@^4.0.0:
dependencies:
semver "^7.5.3"
-make-error@1.x, make-error@^1.1.1:
+make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
@@ -11518,110 +11471,112 @@ merge2@^1.3.0, merge2@^1.4.1:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
-metro-babel-transformer@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz#d8c134615530c9ee61364526d44ca4bb0c5343ea"
- integrity sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==
+metro-babel-transformer@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.84.5.tgz#38610674043edb8e007139adaf3c13082914f35d"
+ integrity sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==
dependencies:
"@babel/core" "^7.25.2"
flow-enums-runtime "^0.0.6"
- hermes-parser "0.32.0"
+ hermes-parser "0.35.0"
+ metro-cache-key "0.84.5"
nullthrows "^1.1.1"
-metro-babel-transformer@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.83.5.tgz#91f3fa269171ad5189ebba625f1f0aa124ce06ea"
- integrity sha512-d9FfmgUEVejTiSb7bkQeLRGl6aeno2UpuPm3bo3rCYwxewj03ymvOn8s8vnS4fBqAPQ+cE9iQM40wh7nGXR+eA==
+metro-babel-transformer@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-babel-transformer/-/metro-babel-transformer-0.84.6.tgz#30b79c823ababbf202dad3d5e624077ec5f2d7f1"
+ integrity sha512-B1ozl6KxFHbQjXZ2U8WiTPWXylqiD3V4EXyV5r0fqETOrqIrJlBvbVTD82UxIN+B3Vpe4l8G1tb15ocd2RyOQA==
dependencies:
"@babel/core" "^7.25.2"
flow-enums-runtime "^0.0.6"
- hermes-parser "0.33.3"
+ hermes-parser "0.35.0"
+ metro-cache-key "0.84.6"
nullthrows "^1.1.1"
-metro-cache-key@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.83.3.tgz#ae6c5d4eb1ad8d06a92bf7294ca730a8d880b573"
- integrity sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==
+metro-cache-key@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.84.5.tgz#f4eea64f8a78ad626c335493fc8d0edce65f9b7d"
+ integrity sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==
dependencies:
flow-enums-runtime "^0.0.6"
-metro-cache-key@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.83.5.tgz#96896a1768f0494a375e1d5957b7ad487e508a4c"
- integrity sha512-Ycl8PBajB7bhbAI7Rt0xEyiF8oJ0RWX8EKkolV1KfCUlC++V/GStMSGpPLwnnBZXZWkCC5edBPzv1Hz1Yi0Euw==
+metro-cache-key@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-cache-key/-/metro-cache-key-0.84.6.tgz#a51b31a9f760629f6c50651838fed6cb3d80748b"
+ integrity sha512-6tyXt1BZ/3U183XV48oif7Nm65TE+pTo0Vs31u4FxMgqiYQR/SiSr3RoXn3pmy4pd280ZmLmct0x3jZpu0pV4A==
dependencies:
flow-enums-runtime "^0.0.6"
-metro-cache@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.83.3.tgz#f1245cc48570c47d8944495e61d67f0228f10172"
- integrity sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==
+metro-cache@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.84.5.tgz#cc622b62b6d51b69bd8a2ea3f8a86076f12dc445"
+ integrity sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==
dependencies:
exponential-backoff "^3.1.1"
flow-enums-runtime "^0.0.6"
https-proxy-agent "^7.0.5"
- metro-core "0.83.3"
+ metro-core "0.84.5"
-metro-cache@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.83.5.tgz#5675f4ad56905aa78fff3dec1b6bf213e0b6c86d"
- integrity sha512-oH+s4U+IfZyg8J42bne2Skc90rcuESIYf86dYittcdWQtPfcaFXWpByPyTuWk3rR1Zz3Eh5HOrcVImfEhhJLng==
+metro-cache@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-cache/-/metro-cache-0.84.6.tgz#b8165ae832309f56326424cba9cfc27ec3117e6e"
+ integrity sha512-KBJVpb02oKNO+jlWQ1Aax7cd11YP0MClDaMgx6C4MhyZ9g95J/dC1Bo6sCzXaU9L9h/w5cQfT5zDkGW5sOj99A==
dependencies:
exponential-backoff "^3.1.1"
flow-enums-runtime "^0.0.6"
https-proxy-agent "^7.0.5"
- metro-core "0.83.5"
+ metro-core "0.84.6"
-metro-config@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.83.3.tgz#a30e7a69b5cf8c4ac4c4b68b1b4c33649ae129a2"
- integrity sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==
+metro-config@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.84.5.tgz#a45b1556bd61393cd4853393ac2b11eefdd7a217"
+ integrity sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==
dependencies:
connect "^3.6.5"
flow-enums-runtime "^0.0.6"
jest-validate "^29.7.0"
- metro "0.83.3"
- metro-cache "0.83.3"
- metro-core "0.83.3"
- metro-runtime "0.83.3"
+ metro "0.84.5"
+ metro-cache "0.84.5"
+ metro-core "0.84.5"
+ metro-runtime "0.84.5"
yaml "^2.6.1"
-metro-config@0.83.5, metro-config@^0.83.1:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.83.5.tgz#a3dd20fc5d5582aa4ad3704678e52abcf4d46b2b"
- integrity sha512-JQ/PAASXH7yczgV6OCUSRhZYME+NU8NYjI2RcaG5ga4QfQ3T/XdiLzpSb3awWZYlDCcQb36l4Vl7i0Zw7/Tf9w==
+metro-config@0.84.6, metro-config@^0.84.3:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-config/-/metro-config-0.84.6.tgz#79e5426b7634faf210c79103a12b7fd5a8ed90dd"
+ integrity sha512-cD2mLEofcuV26kxSV4hfV1Pbuh6igdUhYQriO8ZVeJ7HyZfq4/wqHV0gSR7fEO8NUp9odr60/Kzm5YakmghWwQ==
dependencies:
connect "^3.6.5"
flow-enums-runtime "^0.0.6"
jest-validate "^29.7.0"
- metro "0.83.5"
- metro-cache "0.83.5"
- metro-core "0.83.5"
- metro-runtime "0.83.5"
+ metro "0.84.6"
+ metro-cache "0.84.6"
+ metro-core "0.84.6"
+ metro-runtime "0.84.6"
yaml "^2.6.1"
-metro-core@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.83.3.tgz#007e93f7d1983777da8988dfb103ad897c9835b8"
- integrity sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==
+metro-core@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.84.5.tgz#8a7c9f7156df82cc2b9cae75504e9546d637bb7e"
+ integrity sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==
dependencies:
flow-enums-runtime "^0.0.6"
lodash.throttle "^4.1.1"
- metro-resolver "0.83.3"
+ metro-resolver "0.84.5"
-metro-core@0.83.5, metro-core@^0.83.1:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.83.5.tgz#1592033633034feb5d368d22bf18e38052146970"
- integrity sha512-YcVcLCrf0ed4mdLa82Qob0VxYqfhmlRxUS8+TO4gosZo/gLwSvtdeOjc/Vt0pe/lvMNrBap9LlmvZM8FIsMgJQ==
+metro-core@0.84.6, metro-core@^0.84.3:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-core/-/metro-core-0.84.6.tgz#de27655d3551202b407ffa75465cb705007356a8"
+ integrity sha512-ZqYskM8+f3PFMosBDJd8DLqRvT1ltRGhWL/ZfTpBvf4SoadO30VVrdhAoHFVGz3G1CJpWa3fgZH7nnnX/ybllA==
dependencies:
flow-enums-runtime "^0.0.6"
lodash.throttle "^4.1.1"
- metro-resolver "0.83.5"
+ metro-resolver "0.84.6"
-metro-file-map@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.83.3.tgz#3d79fbb1d379ab178dd895ce54cb5ecb183d74a2"
- integrity sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==
+metro-file-map@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.84.5.tgz#c3d76a90012dbd05a7da461364d8939b407e7f7d"
+ integrity sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==
dependencies:
debug "^4.4.0"
fb-watchman "^2.0.0"
@@ -11633,10 +11588,10 @@ metro-file-map@0.83.3:
nullthrows "^1.1.1"
walker "^1.0.7"
-metro-file-map@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.83.5.tgz#394aa61d54b3822f10e68c18cbd1318f18865d20"
- integrity sha512-ZEt8s3a1cnYbn40nyCD+CsZdYSlwtFh2kFym4lo+uvfM+UMMH+r/BsrC6rbNClSrt+B7rU9T+Te/sh/NL8ZZKQ==
+metro-file-map@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-file-map/-/metro-file-map-0.84.6.tgz#6e599bdde99ece1ee531c22f698f83835c452b71"
+ integrity sha512-ov9VywWBsHPt54Zc7/DxWvqjTwxuZvhfeyHg8ZAMGKtf2PcH1wS3EnoBH7In53Rspk/eXVWAO5+avxunPn081w==
dependencies:
debug "^4.4.0"
fb-watchman "^2.0.0"
@@ -11648,123 +11603,122 @@ metro-file-map@0.83.5:
nullthrows "^1.1.1"
walker "^1.0.7"
-metro-minify-terser@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz#c1c70929c86b14c8bf03e6321b4f9310bc8dbe87"
- integrity sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==
+metro-minify-terser@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.84.5.tgz#796a4986e172537d2b0d08358a96df1dc2428e60"
+ integrity sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==
dependencies:
flow-enums-runtime "^0.0.6"
terser "^5.15.0"
-metro-minify-terser@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.83.5.tgz#ee43a11a9d3442760781434c599d45eb1274e6fd"
- integrity sha512-Toe4Md1wS1PBqbvB0cFxBzKEVyyuYTUb0sgifAZh/mSvLH84qA1NAWik9sISWatzvfWf3rOGoUoO5E3f193a3Q==
+metro-minify-terser@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-minify-terser/-/metro-minify-terser-0.84.6.tgz#dcf4c84c42d8f797fdaeda285a453623c5fcb884"
+ integrity sha512-SqhB/Kxrw0XfyW0k8mEscqX/CQopm6Fp3EpdSIPzvKfGgQp9bTMahg9PAdVsXUmIWwErbmFw1VqB+VFBOKksUw==
dependencies:
flow-enums-runtime "^0.0.6"
terser "^5.15.0"
-metro-resolver@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.83.3.tgz#06207bdddc280b9335722a8c992aeec017413942"
- integrity sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==
+metro-resolver@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.84.5.tgz#bc2d8aa10eb9ec9044dec099dc6f4c28f1306f3a"
+ integrity sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==
dependencies:
flow-enums-runtime "^0.0.6"
-metro-resolver@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.83.5.tgz#72340ca8071941eafe92ff2dcb8e33c581870ef7"
- integrity sha512-7p3GtzVUpbAweJeCcUJihJeOQl1bDuimO5ueo1K0BUpUtR41q5EilbQ3klt16UTPPMpA+tISWBtsrqU556mY1A==
+metro-resolver@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-resolver/-/metro-resolver-0.84.6.tgz#d20f1d98bfd92f30f4badaa99e56244b0e24892a"
+ integrity sha512-PplpGc/OCLxgKeLNsLQO/VHsYDIvkcGGr3zNT7UanHCGRqhxKv245o+naV8cRI4/pcLqyc+9j+J0AJbT3Kp5Sg==
dependencies:
flow-enums-runtime "^0.0.6"
-metro-runtime@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.83.3.tgz#ff504df5d93f38b1af396715b327e589ba8d184d"
- integrity sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==
+metro-runtime@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.84.5.tgz#2c13f2177f5d3951f7f62188c223e84a520ed87c"
+ integrity sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==
dependencies:
"@babel/runtime" "^7.25.0"
flow-enums-runtime "^0.0.6"
-metro-runtime@0.83.5, metro-runtime@^0.83.1:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.83.5.tgz#52c1edafc6cc82e57729cc9c21700ab1e53a1777"
- integrity sha512-f+b3ue9AWTVlZe2Xrki6TAoFtKIqw30jwfk7GQ1rDUBQaE0ZQ+NkiMEtb9uwH7uAjJ87U7Tdx1Jg1OJqUfEVlA==
+metro-runtime@0.84.6, metro-runtime@^0.84.3:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-runtime/-/metro-runtime-0.84.6.tgz#13062c0cff49617e5f2dc3ef9063ab22ab018d56"
+ integrity sha512-47EYO/DOai0PA3GoAyDpyXOgHNMwbl/Z9dQlQineDLHkpD42xOwMrIx1crT0+n6F+aGoEyxZmeFwfMzaIqVqKQ==
dependencies:
"@babel/runtime" "^7.25.0"
flow-enums-runtime "^0.0.6"
-metro-source-map@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.83.3.tgz#04bb464f7928ea48bcdfd18912c8607cf317c898"
- integrity sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==
+metro-source-map@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.84.5.tgz#1338cc7ca3666dc126eb5ef4b3514a6a8b8b7f05"
+ integrity sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==
dependencies:
- "@babel/traverse" "^7.25.3"
- "@babel/traverse--for-generate-function-map" "npm:@babel/traverse@^7.25.3"
- "@babel/types" "^7.25.2"
+ "@babel/traverse" "^7.29.0"
+ "@babel/types" "^7.29.0"
flow-enums-runtime "^0.0.6"
invariant "^2.2.4"
- metro-symbolicate "0.83.3"
+ metro-symbolicate "0.84.5"
nullthrows "^1.1.1"
- ob1 "0.83.3"
+ ob1 "0.84.5"
source-map "^0.5.6"
vlq "^1.0.0"
-metro-source-map@0.83.5, metro-source-map@^0.83.1:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.83.5.tgz#384f311f83fa2bf51cbec08d77210aa951bf9ee3"
- integrity sha512-VT9bb2KO2/4tWY9Z2yeZqTUao7CicKAOps9LUg2aQzsz+04QyuXL3qgf1cLUVRjA/D6G5u1RJAlN1w9VNHtODQ==
+metro-source-map@0.84.6, metro-source-map@^0.84.3:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-source-map/-/metro-source-map-0.84.6.tgz#7e9ef7e3bdbdcec131ca447bf5db0c32bf457c50"
+ integrity sha512-4p/EplRbgNxhdqA6kAj1AmIq9qqYXYSAjgxuZ73rYA+p037xDDrxK8CyDKNfbn1k6aMuVt8LV0fpt7NHUTKiZw==
dependencies:
"@babel/traverse" "^7.29.0"
"@babel/types" "^7.29.0"
flow-enums-runtime "^0.0.6"
invariant "^2.2.4"
- metro-symbolicate "0.83.5"
+ metro-symbolicate "0.84.6"
nullthrows "^1.1.1"
- ob1 "0.83.5"
+ ob1 "0.84.6"
source-map "^0.5.6"
vlq "^1.0.0"
-metro-symbolicate@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz#67af03950f0dfe19a7c059e3983e39a31e95d03a"
- integrity sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==
+metro-symbolicate@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.84.5.tgz#dc8224940fc59ed8b934b7ab8b63fc68c081e0bf"
+ integrity sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==
dependencies:
flow-enums-runtime "^0.0.6"
invariant "^2.2.4"
- metro-source-map "0.83.3"
+ metro-source-map "0.84.5"
nullthrows "^1.1.1"
source-map "^0.5.6"
vlq "^1.0.0"
-metro-symbolicate@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.83.5.tgz#62167db423be6c68b4b9f39935c9cb7330cc9526"
- integrity sha512-EMIkrjNRz/hF+p0RDdxoE60+dkaTLPN3vaaGkFmX5lvFdO6HPfHA/Ywznzkev+za0VhPQ5KSdz49/MALBRteHA==
+metro-symbolicate@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-symbolicate/-/metro-symbolicate-0.84.6.tgz#f1898d7fb040c303a1867ca46da8b9eda217ab09"
+ integrity sha512-wJFyyF5ysbVyoYVWGL6GwVMiNnGllwDU2gF096376fl0fc8IFUlADSyNlswj31K9og2sOilXe/FdyWFPczPUYg==
dependencies:
flow-enums-runtime "^0.0.6"
invariant "^2.2.4"
- metro-source-map "0.83.5"
+ metro-source-map "0.84.6"
nullthrows "^1.1.1"
source-map "^0.5.6"
vlq "^1.0.0"
-metro-transform-plugins@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz#2c59ba841e269363cf3acb13138cb992f0c75013"
- integrity sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==
+metro-transform-plugins@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.84.5.tgz#fab2aa3e5cae11d414c871feae8cafa1c7f746f7"
+ integrity sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==
dependencies:
"@babel/core" "^7.25.2"
- "@babel/generator" "^7.25.0"
- "@babel/template" "^7.25.0"
- "@babel/traverse" "^7.25.3"
+ "@babel/generator" "^7.29.1"
+ "@babel/template" "^7.28.6"
+ "@babel/traverse" "^7.29.0"
flow-enums-runtime "^0.0.6"
nullthrows "^1.1.1"
-metro-transform-plugins@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.83.5.tgz#ba21c6a5fa9bf6c5c2c222e2c8e7a668ffb3d341"
- integrity sha512-KxYKzZL+lt3Os5H2nx7YkbkWVduLZL5kPrE/Yq+Prm/DE1VLhpfnO6HtPs8vimYFKOa58ncl60GpoX0h7Wm0Vw==
+metro-transform-plugins@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-transform-plugins/-/metro-transform-plugins-0.84.6.tgz#a97dded9594befa787055375e46f520b1d91ec47"
+ integrity sha512-pROPMFaj25Y9+3LlLqpBRz/7B/2YPnfEQz/z3juYxkh3FGQ+3c/Qh4E+khPSTdwTw0M6LitLYHMqaHMbQmuBJw==
dependencies:
"@babel/core" "^7.25.2"
"@babel/generator" "^7.29.1"
@@ -11773,83 +11727,81 @@ metro-transform-plugins@0.83.5:
flow-enums-runtime "^0.0.6"
nullthrows "^1.1.1"
-metro-transform-worker@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz#ca6ae4a02b0f61b33299e6e56bacaba32dcd607f"
- integrity sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==
+metro-transform-worker@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.84.5.tgz#57543a44e1054d1adbfa054d1a7a907981c1c455"
+ integrity sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==
dependencies:
"@babel/core" "^7.25.2"
- "@babel/generator" "^7.25.0"
- "@babel/parser" "^7.25.3"
- "@babel/types" "^7.25.2"
+ "@babel/generator" "^7.29.1"
+ "@babel/parser" "^7.29.0"
+ "@babel/types" "^7.29.0"
flow-enums-runtime "^0.0.6"
- metro "0.83.3"
- metro-babel-transformer "0.83.3"
- metro-cache "0.83.3"
- metro-cache-key "0.83.3"
- metro-minify-terser "0.83.3"
- metro-source-map "0.83.3"
- metro-transform-plugins "0.83.3"
+ metro "0.84.5"
+ metro-babel-transformer "0.84.5"
+ metro-cache "0.84.5"
+ metro-cache-key "0.84.5"
+ metro-minify-terser "0.84.5"
+ metro-source-map "0.84.5"
+ metro-transform-plugins "0.84.5"
nullthrows "^1.1.1"
-metro-transform-worker@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.83.5.tgz#8616b54282e727027fdb5c475aade719394a8e8a"
- integrity sha512-8N4pjkNXc6ytlP9oAM6MwqkvUepNSW39LKYl9NjUMpRDazBQ7oBpQDc8Sz4aI8jnH6AGhF7s1m/ayxkN1t04yA==
+metro-transform-worker@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro-transform-worker/-/metro-transform-worker-0.84.6.tgz#4a79651dec16c1b94ac9e99f465e512057c3cd1b"
+ integrity sha512-xLTVrOENaHR9DiuhnHS+zcle0rKsCy2CpXSIx7xD+zhJs+qVRNUyV7Z1zIt/0P/d0cSGVx846R+dIjHEudhcww==
dependencies:
"@babel/core" "^7.25.2"
"@babel/generator" "^7.29.1"
"@babel/parser" "^7.29.0"
"@babel/types" "^7.29.0"
flow-enums-runtime "^0.0.6"
- metro "0.83.5"
- metro-babel-transformer "0.83.5"
- metro-cache "0.83.5"
- metro-cache-key "0.83.5"
- metro-minify-terser "0.83.5"
- metro-source-map "0.83.5"
- metro-transform-plugins "0.83.5"
+ metro "0.84.6"
+ metro-babel-transformer "0.84.6"
+ metro-cache "0.84.6"
+ metro-cache-key "0.84.6"
+ metro-minify-terser "0.84.6"
+ metro-source-map "0.84.6"
+ metro-transform-plugins "0.84.6"
nullthrows "^1.1.1"
-metro@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/metro/-/metro-0.83.3.tgz#1e7e04c15519af746f8932c7f9c553d92c39e922"
- integrity sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==
+metro@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/metro/-/metro-0.84.5.tgz#c7402b9a5a80fa22501705e7d121f59d866bf06f"
+ integrity sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==
dependencies:
- "@babel/code-frame" "^7.24.7"
+ "@babel/code-frame" "^7.29.0"
"@babel/core" "^7.25.2"
- "@babel/generator" "^7.25.0"
- "@babel/parser" "^7.25.3"
- "@babel/template" "^7.25.0"
- "@babel/traverse" "^7.25.3"
- "@babel/types" "^7.25.2"
- accepts "^1.3.7"
- chalk "^4.0.0"
+ "@babel/generator" "^7.29.1"
+ "@babel/parser" "^7.29.0"
+ "@babel/template" "^7.28.6"
+ "@babel/traverse" "^7.29.0"
+ "@babel/types" "^7.29.0"
+ accepts "^2.0.0"
ci-info "^2.0.0"
connect "^3.6.5"
debug "^4.4.0"
error-stack-parser "^2.0.6"
flow-enums-runtime "^0.0.6"
graceful-fs "^4.2.4"
- hermes-parser "0.32.0"
- image-size "^1.0.2"
+ hermes-parser "0.35.0"
invariant "^2.2.4"
jest-worker "^29.7.0"
jsc-safe-url "^0.2.2"
lodash.throttle "^4.1.1"
- metro-babel-transformer "0.83.3"
- metro-cache "0.83.3"
- metro-cache-key "0.83.3"
- metro-config "0.83.3"
- metro-core "0.83.3"
- metro-file-map "0.83.3"
- metro-resolver "0.83.3"
- metro-runtime "0.83.3"
- metro-source-map "0.83.3"
- metro-symbolicate "0.83.3"
- metro-transform-plugins "0.83.3"
- metro-transform-worker "0.83.3"
- mime-types "^2.1.27"
+ metro-babel-transformer "0.84.5"
+ metro-cache "0.84.5"
+ metro-cache-key "0.84.5"
+ metro-config "0.84.5"
+ metro-core "0.84.5"
+ metro-file-map "0.84.5"
+ metro-resolver "0.84.5"
+ metro-runtime "0.84.5"
+ metro-source-map "0.84.5"
+ metro-symbolicate "0.84.5"
+ metro-transform-plugins "0.84.5"
+ metro-transform-worker "0.84.5"
+ mime-types "^3.0.1"
nullthrows "^1.1.1"
serialize-error "^2.1.0"
source-map "^0.5.6"
@@ -11857,10 +11809,10 @@ metro@0.83.3:
ws "^7.5.10"
yargs "^17.6.2"
-metro@0.83.5, metro@^0.83.1:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/metro/-/metro-0.83.5.tgz#f5441075d5211c980ac8c79109e9e6fa2df68924"
- integrity sha512-BgsXevY1MBac/3ZYv/RfNFf/4iuW9X7f4H8ZNkiH+r667HD9sVujxcmu4jvEzGCAm4/WyKdZCuyhAcyhTHOucQ==
+metro@0.84.6, metro@^0.84.3:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/metro/-/metro-0.84.6.tgz#6b28cba341c1148c4171b97444de3bf06e10ddeb"
+ integrity sha512-ty/tx/imE5Ph2gvPU788Ckim6RAC2tyZxLz2hWRgC9U7/fGKV7qY/slADy0qHPEw9cpUgc4BfmV3oUsYwm7oeg==
dependencies:
"@babel/code-frame" "^7.29.0"
"@babel/core" "^7.25.2"
@@ -11870,31 +11822,29 @@ metro@0.83.5, metro@^0.83.1:
"@babel/traverse" "^7.29.0"
"@babel/types" "^7.29.0"
accepts "^2.0.0"
- chalk "^4.0.0"
ci-info "^2.0.0"
connect "^3.6.5"
debug "^4.4.0"
error-stack-parser "^2.0.6"
flow-enums-runtime "^0.0.6"
graceful-fs "^4.2.4"
- hermes-parser "0.33.3"
- image-size "^1.0.2"
+ hermes-parser "0.35.0"
invariant "^2.2.4"
jest-worker "^29.7.0"
jsc-safe-url "^0.2.2"
lodash.throttle "^4.1.1"
- metro-babel-transformer "0.83.5"
- metro-cache "0.83.5"
- metro-cache-key "0.83.5"
- metro-config "0.83.5"
- metro-core "0.83.5"
- metro-file-map "0.83.5"
- metro-resolver "0.83.5"
- metro-runtime "0.83.5"
- metro-source-map "0.83.5"
- metro-symbolicate "0.83.5"
- metro-transform-plugins "0.83.5"
- metro-transform-worker "0.83.5"
+ metro-babel-transformer "0.84.6"
+ metro-cache "0.84.6"
+ metro-cache-key "0.84.6"
+ metro-config "0.84.6"
+ metro-core "0.84.6"
+ metro-file-map "0.84.6"
+ metro-resolver "0.84.6"
+ metro-runtime "0.84.6"
+ metro-source-map "0.84.6"
+ metro-symbolicate "0.84.6"
+ metro-transform-plugins "0.84.6"
+ metro-transform-worker "0.84.6"
mime-types "^3.0.1"
nullthrows "^1.1.1"
serialize-error "^2.1.0"
@@ -11921,7 +11871,7 @@ mime-db@1.52.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5"
integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==
-mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.34:
+mime-types@^2.1.12, mime-types@~2.1.34:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
@@ -12134,6 +12084,11 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+multitars@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/multitars/-/multitars-1.0.2.tgz#340f48eebdfe94cc4e7cf287dfcdeedb9f779003"
+ integrity sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==
+
murmurhash-js@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51"
@@ -12154,16 +12109,7 @@ mute-stream@1.0.0:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e"
integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==
-mz@^2.7.0:
- version "2.7.0"
- resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
- integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
- dependencies:
- any-promise "^1.0.0"
- object-assign "^4.0.1"
- thenify-all "^1.0.0"
-
-nanoid@^3.3.1, nanoid@^3.3.11, nanoid@^3.3.7, nanoid@^3.3.8:
+nanoid@^3.3.1, nanoid@^3.3.11:
version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
@@ -12178,6 +12124,11 @@ nanoid@^3.3.17:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913"
integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==
+nanoid@^3.3.18:
+ version "3.3.19"
+ resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a"
+ integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==
+
napi-postinstall@^0.3.0:
version "0.3.3"
resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.3.tgz#93d045c6b576803ead126711d3093995198c6eb9"
@@ -12215,11 +12166,6 @@ negotiator@~0.6.4:
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7"
integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==
-nested-error-stacks@~2.0.1:
- version "2.0.1"
- resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz#d2cc9fc5235ddb371fc44d506234339c8e4b0a4b"
- integrity sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==
-
new-github-release-url@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/new-github-release-url/-/new-github-release-url-2.0.0.tgz#335189b91f52bbb9569042a7485900a205a0500b"
@@ -12436,21 +12382,21 @@ nwsapi@^2.2.2:
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.22.tgz#109f9530cda6c156d6a713cdf5939e9f0de98b9d"
integrity sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==
-ob1@0.83.3:
- version "0.83.3"
- resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.83.3.tgz#2208e20c9070e9beff3ad067f2db458fa6b07014"
- integrity sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==
+ob1@0.84.5:
+ version "0.84.5"
+ resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.84.5.tgz#5b103040a271723e555f77be3d54aaf4c32d9db5"
+ integrity sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==
dependencies:
flow-enums-runtime "^0.0.6"
-ob1@0.83.5:
- version "0.83.5"
- resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.83.5.tgz#f9c289d759142b76577948eea7fd1f07d36f825f"
- integrity sha512-vNKPYC8L5ycVANANpF/S+WZHpfnRWKx/F3AYP4QMn6ZJTh+l2HOrId0clNkEmua58NB9vmI9Qh7YOoV/4folYg==
+ob1@0.84.6:
+ version "0.84.6"
+ resolved "https://registry.yarnpkg.com/ob1/-/ob1-0.84.6.tgz#09ea315407aaaa7a7b13aa9ee243cf5153c0d38c"
+ integrity sha512-+s+6zjd0X68hfAcn92NsEPXnSYOF3O7exw/Fj4UfRTB+UL+Tdjdkzu5/4WquXlzqgvNm8FWuuW6/0Yd8S2rYxw==
dependencies:
flow-enums-runtime "^0.0.6"
-object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
+object-assign@^4.1.0, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
@@ -12460,14 +12406,6 @@ object-inspect@^1.13.3, object-inspect@^1.13.4:
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
-object-is@^1.1.5:
- version "1.1.6"
- resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07"
- integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==
- dependencies:
- call-bind "^1.0.7"
- define-properties "^1.2.1"
-
object-keys@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
@@ -12601,15 +12539,6 @@ open@^7.0.3, open@^7.4.2:
is-docker "^2.0.0"
is-wsl "^2.1.1"
-open@^8.0.4:
- version "8.4.2"
- resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
- integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
- dependencies:
- define-lazy-prop "^2.0.0"
- is-docker "^2.1.1"
- is-wsl "^2.2.0"
-
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
@@ -12860,7 +12789,7 @@ parseurl@~1.3.3:
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
-patch-package@^8.0.0:
+patch-package@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60"
integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==
@@ -12915,7 +12844,7 @@ path-key@^4.0.0:
resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18"
integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==
-path-parse@^1.0.5, path-parse@^1.0.7:
+path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
@@ -12995,7 +12924,7 @@ pidtree@~0.6.0:
resolved "https://registry.yarnpkg.com/pidtree/-/pidtree-0.6.0.tgz#90ad7b6d42d5841e69e0a2419ef38f8883aa057c"
integrity sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==
-pirates@^4.0.1, pirates@^4.0.4, pirates@^4.0.7:
+pirates@^4.0.4, pirates@^4.0.7:
version "4.0.7"
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22"
integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==
@@ -13097,6 +13026,15 @@ postcss@^8.4.4:
picocolors "^1.1.1"
source-map-js "^1.2.1"
+postcss@^8.5.14:
+ version "8.5.28"
+ resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9"
+ integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==
+ dependencies:
+ nanoid "^3.3.18"
+ picocolors "^1.1.1"
+ source-map-js "^1.2.1"
+
postcss@^8.5.16:
version "8.5.25"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.25.tgz#5012a598eaaa897f21bbe8553be3cb7bd2bd78cb"
@@ -13106,15 +13044,6 @@ postcss@^8.5.16:
picocolors "^1.1.1"
source-map-js "^1.2.1"
-postcss@~8.4.32:
- version "8.4.49"
- resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19"
- integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==
- dependencies:
- nanoid "^3.3.7"
- picocolors "^1.1.1"
- source-map-js "^1.2.1"
-
postinstall-postinstall@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/postinstall-postinstall/-/postinstall-postinstall-2.1.0.tgz#4f7f77441ef539d1512c40bd04c71b06a4704ca3"
@@ -13142,11 +13071,6 @@ prettier@~3.3.3:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105"
integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==
-pretty-bytes@^5.6.0:
- version "5.6.0"
- resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
- integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
-
pretty-format@^22.0.3, pretty-format@^22.4.3:
version "22.4.3"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-22.4.3.tgz#f873d780839a9c02e9664c8a082e9ee79eaac16f"
@@ -13291,11 +13215,6 @@ pure-rand@^6.0.0:
resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2"
integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==
-qrcode-terminal@0.11.0:
- version "0.11.0"
- resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz#ffc6c28a2fc0bfb47052b47e23f4f446a5fbdb9e"
- integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==
-
query-string@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/query-string/-/query-string-7.1.3.tgz#a1cf90e994abb113a325804a972d98276fe02328"
@@ -13316,13 +13235,6 @@ queue-microtask@^1.2.2:
resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
-queue@6.0.2:
- version "6.0.2"
- resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65"
- integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==
- dependencies:
- inherits "~2.0.3"
-
quick-lru@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
@@ -13350,7 +13262,7 @@ rbush@^3.0.1:
dependencies:
quickselect "^2.0.0"
-rc@1.2.8, rc@~1.2.7:
+rc@1.2.8:
version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@@ -13395,12 +13307,12 @@ react-devtools-core@^6.1.5:
shell-quote "^1.6.1"
ws "^7"
-react-dom@19.1.0:
- version "19.1.0"
- resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.1.0.tgz#133558deca37fa1d682708df8904b25186793623"
- integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==
+react-dom@19.2.3:
+ version "19.2.3"
+ resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.3.tgz#f0b61d7e5c4a86773889fcc1853af3ed5f215b17"
+ integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==
dependencies:
- scheduler "^0.26.0"
+ scheduler "^0.27.0"
react-error-boundary@~4.0.13:
version "4.0.13"
@@ -13447,6 +13359,11 @@ react-is@^19.1.0:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.1.1.tgz#038ebe313cf18e1fd1235d51c87360eb87f7c36a"
integrity sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==
+react-is@^19.2.3:
+ version "19.3.0"
+ resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.3.0.tgz#716028d67e9993d23a2d353c6d08a3c1ef2bbeb4"
+ integrity sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==
+
react-native-base64@~0.2.1:
version "0.2.2"
resolved "https://registry.yarnpkg.com/react-native-base64/-/react-native-base64-0.2.2.tgz#9cadd1bbb9e458f31c7307a23f0ab5cd8577f92e"
@@ -13472,6 +13389,14 @@ react-native-css@^3.0.7:
comment-json "^4.2.5"
debug "^4.4.1"
+react-native-drawer-layout@^4.2.2:
+ version "4.2.10"
+ resolved "https://registry.yarnpkg.com/react-native-drawer-layout/-/react-native-drawer-layout-4.2.10.tgz#80eb37b6cce4bca95a2b58ca7efc7003ea2a3cdb"
+ integrity sha512-O6TQdZ5LSm3dqnuR4rX9KPtE+9dVg7jsezEYz1l01rkQTe4fQvYZIoPu2sZFl5X2N70uhRdjnPULySVR3sBUwA==
+ dependencies:
+ color "^4.2.3"
+ use-latest-callback "^0.2.4"
+
react-native-edge-to-edge@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/react-native-edge-to-edge/-/react-native-edge-to-edge-1.6.0.tgz#2ba63b941704a7f713e298185c26cde4d9e4b973"
@@ -13485,12 +13410,13 @@ react-native-flash-message@~0.4.2:
prop-types "^15.8.1"
react-native-iphone-screen-helper "^2.0.2"
-react-native-gesture-handler@~2.28.0:
- version "2.28.0"
- resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz#07fb4f5eae72f810aac3019b060d26c1835bfd0c"
- integrity sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==
+react-native-gesture-handler@~2.32.0:
+ version "2.32.0"
+ resolved "https://registry.yarnpkg.com/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz#59b8658a4e1586ce6fb0ca0053ac80f6acf7f882"
+ integrity sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==
dependencies:
"@egjs/hammerjs" "^2.0.17"
+ "@types/react-test-renderer" "^19.1.0"
hoist-non-react-statics "^3.3.0"
invariant "^2.2.4"
@@ -13499,15 +13425,20 @@ react-native-iphone-screen-helper@^2.0.2:
resolved "https://registry.yarnpkg.com/react-native-iphone-screen-helper/-/react-native-iphone-screen-helper-2.2.1.tgz#34125de16426f1011ecec595be01876be9ccf2bf"
integrity sha512-gMHawcFa9O8St9rJ6zugW55O/sM3UUTLqc4MDaZQewBTIDjfvHzLmk2A/+PxySl5ZTfv6fyIV5g+2ozLRW5kcw==
-react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.2.1:
+react-native-is-edge-to-edge@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz#64e10851abd9d176cbf2b40562f751622bde3358"
integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==
-react-native-keyboard-controller@1.18.5:
- version "1.18.5"
- resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.18.5.tgz#ae12131f2019c574178479d2c55784f55e08bb68"
- integrity sha512-wbYN6Tcu3G5a05dhRYBgjgd74KqoYWuUmroLpigRg9cXy5uYo7prTMIvMgvLtARQtUF7BOtFggUnzgoBOgk0TQ==
+react-native-is-edge-to-edge@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz#feb9a6a8faf0874298947edd556e5af22044e139"
+ integrity sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==
+
+react-native-keyboard-controller@1.21.9:
+ version "1.21.9"
+ resolved "https://registry.yarnpkg.com/react-native-keyboard-controller/-/react-native-keyboard-controller-1.21.9.tgz#2859601d8936355eb90d760c60f28a200782221d"
+ integrity sha512-+TkkFldht4+AXBQeDy1hLE7iqiW8/NkY/ekhcFsKIiRdI9qC5JDzx0TfAg1iYZB2IeOXppmURIy2jFCUjOcV1w==
dependencies:
react-native-is-edge-to-edge "^1.2.1"
@@ -13516,43 +13447,43 @@ react-native-logs@~5.3.0:
resolved "https://registry.yarnpkg.com/react-native-logs/-/react-native-logs-5.3.0.tgz#49ad1ec1fd37a5ef90280cc3e27e7233727fc921"
integrity sha512-tq4S0JFy06ggu1D/udYeV80qPy5koURNNcKrVJmv0Hf3x44akysctaE4ARybD5Pv7MnFD8fP1VFhycSLjqXHQw==
-react-native-mmkv@~3.3.0:
+react-native-mmkv@3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/react-native-mmkv/-/react-native-mmkv-3.3.3.tgz#1f0326f6314e23725af8145964343224d074e6cd"
integrity sha512-GMsfOmNzx0p5+CtrCFRVtpOOMYNJXuksBVARSQrCFaZwjUyHJdQzcN900GGaFFNTxw2fs8s5Xje//RDKj9+PZA==
-react-native-quick-base64@2.1.1:
- version "2.1.1"
- resolved "https://registry.yarnpkg.com/react-native-quick-base64/-/react-native-quick-base64-2.1.1.tgz#1fa72713e1da4b35e6f1f7b780e8a0328f0d3bf4"
- integrity sha512-/L+SaapDLcLcHA3Kj0/cvEhFS/oLXyD6DUXwdckTR/75bzUjqf1FrusUTvJho/lzXnCR1VtBGn+EEvrLmkDTmw==
- dependencies:
- base64-js "^1.5.1"
-
-react-native-reanimated@~4.1.1:
- version "4.1.7"
- resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.1.7.tgz#b4e8524503a1b6ec1b5a40c460ee807a6a9fd2cf"
- integrity sha512-Q4H6xA3Tn7QL0/E/KjI86I1KK4tcf+ErRE04LH34Etka2oVQhW6oXQ+Q8ZcDCVxiWp5vgbBH6XcH8BOo4w/Rhg==
+react-native-reanimated@4.5.1:
+ version "4.5.1"
+ resolved "https://registry.yarnpkg.com/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz#be81ed3a96bf70baeeccdc24d3350883099b7dd3"
+ integrity sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==
dependencies:
- react-native-is-edge-to-edge "^1.2.1"
- semver "^7.7.2"
+ react-native-is-edge-to-edge "^1.3.1"
+ semver "^7.7.3"
react-native-restart@0.0.27:
version "0.0.27"
resolved "https://registry.yarnpkg.com/react-native-restart/-/react-native-restart-0.0.27.tgz#43aa8210312c9dfa5ec7bd4b2f35238ad7972b19"
integrity sha512-8KScVICrXwcTSJ1rjWkqVTHyEKQIttm5AIMGSK1QG1+RS5owYlE4z/1DykOTdWfVl9l16FIk0w9Xzk9ZO6jxlA==
-react-native-safe-area-context@~5.6.0:
- version "5.6.2"
- resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz#283e006f5b434fb247fcb4be0971ad7473d5c560"
- integrity sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==
+react-native-safe-area-context@5.7.0, react-native-safe-area-context@~5.7.0:
+ version "5.7.0"
+ resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz#035699d5ec17fefb98cc1fa44a9ec852c7d530d0"
+ integrity sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==
-react-native-screens@~4.16.0:
- version "4.16.0"
- resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-4.16.0.tgz#efa42e77a092aa0b5277c9ae41391ea0240e0870"
- integrity sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q==
+react-native-screens@^4.26.0:
+ version "4.28.0"
+ resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-4.28.0.tgz#d92249446c42329faeaf18d26a90af26a7989344"
+ integrity sha512-0/79Z8RCibuaAHti+DJ2Dq0m6UFWh4+p1bH58K3cjiQ7IDWs5kU9tCAj4lXcJMHHonbF2mIE2BeaPSEvOpIiFg==
+ dependencies:
+ react-freeze "^1.0.0"
+ warn-once "^0.1.0"
+
+react-native-screens@~4.26.0:
+ version "4.26.2"
+ resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-4.26.2.tgz#cfc007736526a0958e8e8d3ff5f77202c323a852"
+ integrity sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==
dependencies:
react-freeze "^1.0.0"
- react-native-is-edge-to-edge "^1.2.1"
warn-once "^0.1.0"
react-native-svg-transformer@~1.5.1:
@@ -13565,19 +13496,19 @@ react-native-svg-transformer@~1.5.1:
"@svgr/plugin-svgo" "^8.1.0"
path-dirname "^1.0.2"
-react-native-svg@15.12.1:
- version "15.12.1"
- resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.12.1.tgz#7ba756dd6a235f86a2c312a1e7911f9b0d18ad3a"
- integrity sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==
+react-native-svg@15.15.4:
+ version "15.15.4"
+ resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-15.15.4.tgz#c029be85c20ead09c63dffa6b1ade06012f095ed"
+ integrity sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==
dependencies:
css-select "^5.1.0"
css-tree "^1.1.3"
warn-once "0.1.1"
-react-native-url-polyfill@^1.3.0:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-1.3.0.tgz#c1763de0f2a8c22cc3e959b654c8790622b6ef6a"
- integrity sha512-w9JfSkvpqqlix9UjDvJjm1EjSt652zVQ6iwCIj1cVVkwXf4jQhQgTNXY6EVTwuAmUjg6BC6k9RHCBynoLFo3IQ==
+react-native-url-polyfill@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-3.0.0.tgz#16b809c35d45f7d4cf968967bc35ab73b7542826"
+ integrity sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==
dependencies:
whatwg-url-without-unicode "8.0.0-3"
@@ -13595,69 +13526,68 @@ react-native-web@^0.21.0:
postcss-value-parser "^4.2.0"
styleq "^0.1.3"
-react-native-webview@13.15.0:
- version "13.15.0"
- resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.15.0.tgz#b6d2f8d8dd65897db76659ddd8198d2c74ec5a79"
- integrity sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==
+react-native-webview@13.16.1:
+ version "13.16.1"
+ resolved "https://registry.yarnpkg.com/react-native-webview/-/react-native-webview-13.16.1.tgz#d5a6483fcb823eac704bd2a3d82c263771c6ede3"
+ integrity sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==
dependencies:
escape-string-regexp "^4.0.0"
invariant "2.2.4"
-react-native-worklets@0.5.1:
- version "0.5.1"
- resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.5.1.tgz#d153242655e3757b6c62a474768831157316ad33"
- integrity sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==
- dependencies:
- "@babel/plugin-transform-arrow-functions" "^7.0.0-0"
- "@babel/plugin-transform-class-properties" "^7.0.0-0"
- "@babel/plugin-transform-classes" "^7.0.0-0"
- "@babel/plugin-transform-nullish-coalescing-operator" "^7.0.0-0"
- "@babel/plugin-transform-optional-chaining" "^7.0.0-0"
- "@babel/plugin-transform-shorthand-properties" "^7.0.0-0"
- "@babel/plugin-transform-template-literals" "^7.0.0-0"
- "@babel/plugin-transform-unicode-regex" "^7.0.0-0"
- "@babel/preset-typescript" "^7.16.7"
+react-native-worklets@0.10.1:
+ version "0.10.1"
+ resolved "https://registry.yarnpkg.com/react-native-worklets/-/react-native-worklets-0.10.1.tgz#7a3038a3b5ec66cab030a00e1568b6599696f5f2"
+ integrity sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==
+ dependencies:
+ "@babel/plugin-transform-arrow-functions" "^7.27.1"
+ "@babel/plugin-transform-class-properties" "^7.28.6"
+ "@babel/plugin-transform-classes" "^7.28.6"
+ "@babel/plugin-transform-nullish-coalescing-operator" "^7.28.6"
+ "@babel/plugin-transform-optional-chaining" "^7.28.6"
+ "@babel/plugin-transform-shorthand-properties" "^7.27.1"
+ "@babel/plugin-transform-template-literals" "^7.27.1"
+ "@babel/plugin-transform-unicode-regex" "^7.27.1"
+ "@babel/preset-typescript" "^7.28.5"
+ "@babel/types" "^7.27.1"
convert-source-map "^2.0.0"
- semver "7.7.2"
-
-react-native@0.81.5:
- version "0.81.5"
- resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.81.5.tgz#6c963f137d3979b22aef2d8482067775c8fe2fed"
- integrity sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==
- dependencies:
- "@jest/create-cache-key-function" "^29.7.0"
- "@react-native/assets-registry" "0.81.5"
- "@react-native/codegen" "0.81.5"
- "@react-native/community-cli-plugin" "0.81.5"
- "@react-native/gradle-plugin" "0.81.5"
- "@react-native/js-polyfills" "0.81.5"
- "@react-native/normalize-colors" "0.81.5"
- "@react-native/virtualized-lists" "0.81.5"
+ semver "^7.7.4"
+
+react-native@0.86.3:
+ version "0.86.3"
+ resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.86.3.tgz#39f40d11dc2ca6d56262cdf1cb9e5ac1cfc8218b"
+ integrity sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==
+ dependencies:
+ "@react-native/assets-registry" "0.86.3"
+ "@react-native/codegen" "0.86.3"
+ "@react-native/community-cli-plugin" "0.86.3"
+ "@react-native/gradle-plugin" "0.86.3"
+ "@react-native/js-polyfills" "0.86.3"
+ "@react-native/normalize-colors" "0.86.3"
+ "@react-native/virtualized-lists" "0.86.3"
abort-controller "^3.0.0"
anser "^1.4.9"
ansi-regex "^5.0.0"
- babel-jest "^29.7.0"
- babel-plugin-syntax-hermes-parser "0.29.1"
+ babel-plugin-syntax-hermes-parser "0.36.0"
base64-js "^1.5.1"
commander "^12.0.0"
flow-enums-runtime "^0.0.6"
- glob "^7.1.1"
+ hermes-compiler "250829098.0.17"
invariant "^2.2.4"
- jest-environment-node "^29.7.0"
memoize-one "^5.0.0"
- metro-runtime "^0.83.1"
- metro-source-map "^0.83.1"
+ metro-runtime "^0.84.3"
+ metro-source-map "^0.84.3"
nullthrows "^1.1.1"
pretty-format "^29.7.0"
promise "^8.3.0"
react-devtools-core "^6.1.5"
react-refresh "^0.14.0"
regenerator-runtime "^0.13.2"
- scheduler "0.26.0"
+ scheduler "0.27.0"
semver "^7.1.3"
stacktrace-parser "^0.1.10"
+ tinyglobby "^0.2.15"
whatwg-fetch "^3.0.0"
- ws "^6.2.3"
+ ws "^7.5.10"
yargs "^17.6.2"
react-query-kit@~3.3.0:
@@ -13709,13 +13639,13 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
get-nonce "^1.0.0"
tslib "^2.0.0"
-react-test-renderer@19.1.0:
- version "19.1.0"
- resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.1.0.tgz#89e1baa9e45a6da064b9760f92251d5b8e1f34ab"
- integrity sha512-jXkSl3CpvPYEF+p/eGDLB4sPoDX8pKkYvRl9+rR8HxLY0X04vW7hCm1/0zHoUSjPZ3bDa+wXWNTDVIw/R8aDVw==
+react-test-renderer@19.2.3:
+ version "19.2.3"
+ resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-19.2.3.tgz#d20f5193867c98b2df9e13b4e72bb83f311f6a43"
+ integrity sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==
dependencies:
- react-is "^19.1.0"
- scheduler "^0.26.0"
+ react-is "^19.2.3"
+ scheduler "^0.27.0"
react-transition-group@^4.4.5:
version "4.4.5"
@@ -13727,10 +13657,10 @@ react-transition-group@^4.4.5:
loose-envify "^1.4.0"
prop-types "^15.6.2"
-react@19.1.0:
- version "19.1.0"
- resolved "https://registry.yarnpkg.com/react/-/react-19.1.0.tgz#926864b6c48da7627f004795d6cce50e90793b75"
- integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==
+react@19.2.3:
+ version "19.2.3"
+ resolved "https://registry.yarnpkg.com/react/-/react-19.2.3.tgz#d83e5e8e7a258cf6b4fe28640515f99b87cd19b8"
+ integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==
read-binary-file-arch@^1.0.6:
version "1.0.6"
@@ -13861,7 +13791,7 @@ regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4:
gopd "^1.2.0"
set-function-name "^2.0.2"
-regexpu-core@^6.2.0:
+regexpu-core@^6.2.0, regexpu-core@^6.3.1:
version "6.4.0"
resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.4.0.tgz#3580ce0c4faedef599eccb146612436b62a176e5"
integrity sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==
@@ -13916,15 +13846,6 @@ require-from-string@^2.0.2:
resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
-requireg@^0.2.2:
- version "0.2.2"
- resolved "https://registry.yarnpkg.com/requireg/-/requireg-0.2.2.tgz#437e77a5316a54c9bcdbbf5d1f755fe093089830"
- integrity sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==
- dependencies:
- nested-error-stacks "~2.0.1"
- rc "~1.2.7"
- resolve "~1.7.1"
-
requires-port@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
@@ -13981,12 +13902,12 @@ resolve-workspace-root@^2.0.0:
resolved "https://registry.yarnpkg.com/resolve-workspace-root/-/resolve-workspace-root-2.0.0.tgz#a0098daa0067cd0efa6eb525c57c8fb4a61e78f8"
integrity sha512-IsaBUZETJD5WsI11Wt8PKHwaIe45or6pwNc8yflvLJ4DWtImK9kuLoH5kUva/2Mmx/RdIyr4aONNSa2v9LTJsw==
-resolve.exports@^2.0.0, resolve.exports@^2.0.3:
+resolve.exports@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.3.tgz#41955e6f1b4013b7586f873749a635dea07ebe3f"
integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==
-resolve@^1.10.0, resolve@^1.20.0, resolve@^1.22.10, resolve@^1.22.2, resolve@^1.22.4, resolve@^1.22.8:
+resolve@^1.10.0, resolve@^1.20.0, resolve@^1.22.10, resolve@^1.22.4, resolve@^1.22.8:
version "1.22.10"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39"
integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==
@@ -14004,13 +13925,6 @@ resolve@^2.0.0-next.5:
path-parse "^1.0.7"
supports-preserve-symlinks-flag "^1.0.0"
-resolve@~1.7.1:
- version "1.7.1"
- resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.7.1.tgz#aadd656374fd298aee895bc026b8297418677fd3"
- integrity sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==
- dependencies:
- path-parse "^1.0.5"
-
responselike@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc"
@@ -14167,6 +14081,11 @@ safe-regex@^2.1.1:
resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+sandbox-cli-detector@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/sandbox-cli-detector/-/sandbox-cli-detector-0.2.0.tgz#ebf563e4e994ff485e3ed9314b1a110c913aa5e8"
+ integrity sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg==
+
sanitize-filename@^1.6.3:
version "1.6.3"
resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz#755ebd752045931977e30b2025d340d7c9090378"
@@ -14203,10 +14122,10 @@ saxes@^6.0.0:
dependencies:
xmlchars "^2.2.0"
-scheduler@0.26.0, scheduler@^0.26.0:
- version "0.26.0"
- resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.26.0.tgz#4ce8a8c2a2095f13ea11bf9a445be50c555d6337"
- integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==
+scheduler@0.27.0, scheduler@^0.27.0:
+ version "0.27.0"
+ resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd"
+ integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==
scoped-regex@^3.0.0:
version "3.0.0"
@@ -14233,30 +14152,25 @@ semver-compare@^1.0.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
-semver@7.7.2, semver@^7.1.3, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.1:
- version "7.7.2"
- resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
- integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
-
semver@^6.2.0, semver@^6.3.0, semver@^6.3.1:
version "6.3.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
+semver@^7.1.3, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.1:
+ version "7.7.2"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58"
+ integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==
+
semver@^7.3.2, semver@~7.7.3:
version "7.7.3"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946"
integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==
-semver@^7.7.2:
- version "7.7.4"
- resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
- integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==
-
-semver@~7.6.3:
- version "7.6.3"
- resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143"
- integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==
+semver@^7.7.3, semver@^7.7.4:
+ version "7.8.5"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69"
+ integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==
send@0.19.0:
version "0.19.0"
@@ -14384,7 +14298,7 @@ setprototypeof@1.2.0:
resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
-sf-symbols-typescript@^2.1.0:
+sf-symbols-typescript@^2.0.0, sf-symbols-typescript@^2.1.0, sf-symbols-typescript@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz#926d6e0715e3d8784cadf7658431e36581254208"
integrity sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==
@@ -14631,7 +14545,7 @@ source-map-support@0.5.13:
buffer-from "^1.0.0"
source-map "^0.6.0"
-source-map-support@^0.5.19, source-map-support@~0.5.20, source-map-support@~0.5.21:
+source-map-support@^0.5.19, source-map-support@~0.5.20:
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
@@ -14760,6 +14674,11 @@ stacktrace-parser@^0.1.10:
dependencies:
type-fest "^0.7.1"
+standard-navigation@^0.0.5:
+ version "0.0.5"
+ resolved "https://registry.yarnpkg.com/standard-navigation/-/standard-navigation-0.0.5.tgz#8ce5fef5a251991f58ed69283bee5cc1d242625e"
+ integrity sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==
+
stat-mode@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465"
@@ -15043,19 +14962,6 @@ styleq@^0.1.3:
resolved "https://registry.yarnpkg.com/styleq/-/styleq-0.1.3.tgz#8efb2892debd51ce7b31dc09c227ad920decab71"
integrity sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==
-sucrase@~3.35.1:
- version "3.35.1"
- resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1"
- integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==
- dependencies:
- "@jridgewell/gen-mapping" "^0.3.2"
- commander "^4.0.0"
- lines-and-columns "^1.1.6"
- mz "^2.7.0"
- pirates "^4.0.1"
- tinyglobby "^0.2.11"
- ts-interface-checker "^0.1.9"
-
sumchecker@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42"
@@ -15204,17 +15110,6 @@ tar@^7.4.3:
minizlib "^3.1.0"
yallist "^5.0.0"
-tar@^7.5.2:
- version "7.5.13"
- resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.13.tgz#0d214ed56781a26edc313581c0e2d929ceeb866d"
- integrity sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==
- dependencies:
- "@isaacs/fs-minipass" "^4.0.0"
- chownr "^3.0.0"
- minipass "^7.1.2"
- minizlib "^3.1.0"
- yallist "^5.0.0"
-
tar@^7.5.6, tar@^7.5.7:
version "7.5.7"
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.7.tgz#adf99774008ba1c89819f15dbd6019c630539405"
@@ -15279,20 +15174,6 @@ text-table@^0.2.0:
resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==
-thenify-all@^1.0.0:
- version "1.6.0"
- resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
- integrity sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==
- dependencies:
- thenify ">= 3.1.0 < 4"
-
-"thenify@>= 3.1.0 < 4":
- version "3.3.1"
- resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
- integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
- dependencies:
- any-promise "^1.0.0"
-
throat@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"
@@ -15325,14 +15206,6 @@ tinyexec@^1.0.0:
resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.1.tgz#70c31ab7abbb4aea0a24f55d120e5990bfa1e0b1"
integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==
-tinyglobby@^0.2.11:
- version "0.2.16"
- resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
- integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
- dependencies:
- fdir "^6.5.0"
- picomatch "^4.0.4"
-
tinyglobby@^0.2.12, tinyglobby@^0.2.13:
version "0.2.15"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2"
@@ -15341,6 +15214,14 @@ tinyglobby@^0.2.12, tinyglobby@^0.2.13:
fdir "^6.5.0"
picomatch "^4.0.3"
+tinyglobby@^0.2.15:
+ version "0.2.17"
+ resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631"
+ integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==
+ dependencies:
+ fdir "^6.5.0"
+ picomatch "^4.0.4"
+
tinyqueue@^1.2.0:
version "1.2.3"
resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-1.2.3.tgz#b6a61de23060584da29f82362e45df1ec7353f3d"
@@ -15395,6 +15276,11 @@ token-types@^4.1.1:
"@tokenizer/token" "^0.3.0"
ieee754 "^1.2.1"
+toqr@^0.1.1:
+ version "0.1.1"
+ resolved "https://registry.yarnpkg.com/toqr/-/toqr-0.1.1.tgz#fb987ea706be36affc89336ca26543a16062725a"
+ integrity sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==
+
tough-cookie@^4.0.0, tough-cookie@^4.1.2:
version "4.1.4"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36"
@@ -15434,30 +15320,6 @@ ts-api-utils@^1.3.0:
resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064"
integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==
-ts-debounce@^4.0.0:
- version "4.0.0"
- resolved "https://registry.yarnpkg.com/ts-debounce/-/ts-debounce-4.0.0.tgz#33440ef64fab53793c3d546a8ca6ae539ec15841"
- integrity sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==
-
-ts-interface-checker@^0.1.9:
- version "0.1.13"
- resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699"
- integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==
-
-ts-jest@~29.1.2:
- version "29.1.5"
- resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.5.tgz#d6c0471cc78bffa2cb4664a0a6741ef36cfe8f69"
- integrity sha512-UuClSYxM7byvvYfyWdFI+/2UxMmwNyJb0NPkZPQE2hew3RurV7l7zURgOHAd/1I1ZdPpe3GUsXNXAcN8TFKSIg==
- dependencies:
- bs-logger "0.x"
- fast-json-stable-stringify "2.x"
- jest-util "^29.0.0"
- json5 "^2.2.3"
- lodash.memoize "4.x"
- make-error "1.x"
- semver "^7.5.3"
- yargs-parser "^21.0.1"
-
ts-node@~10.9.2:
version "10.9.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f"
@@ -15618,10 +15480,10 @@ typed-emitter@^2.1.0:
optionalDependencies:
rxjs "^7.5.2"
-typescript@~5.9.2:
- version "5.9.3"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
- integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
+typescript@~6.0.3:
+ version "6.0.3"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21"
+ integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==
ua-parser-js@^0.7.33:
version "0.7.41"
@@ -15653,10 +15515,10 @@ undici-types@~7.16.0:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
-undici@^6.18.2:
- version "6.21.3"
- resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a"
- integrity sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==
+undici@^6.22.0:
+ version "6.29.0"
+ resolved "https://registry.yarnpkg.com/undici/-/undici-6.29.0.tgz#140fa16e337ac706d6ff12f03fbe5c4357e5f9a3"
+ integrity sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.1"
@@ -15686,10 +15548,10 @@ unicorn-magic@^0.1.0:
resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4"
integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==
-unimodules-app-loader@~6.0.8:
- version "6.0.8"
- resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz#81c868b726e24b7e37d708fe0117e1869c721cdb"
- integrity sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA==
+unimodules-app-loader@~57.0.2:
+ version "57.0.2"
+ resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-57.0.2.tgz#950a025d13c71c6202069447c10466dcba606001"
+ integrity sha512-1Ja+r1VR556m1cTn4BBwAe0AQpX8mDobvN+6Kx12h0jqf0euHfvkPHg83ijuC7pWHff9vTOUc7qr5t8vnmnljg==
unique-filename@^4.0.0:
version "4.0.0"
@@ -15806,11 +15668,6 @@ use-callback-ref@^1.3.3:
dependencies:
tslib "^2.0.0"
-use-latest-callback@^0.2.1:
- version "0.2.6"
- resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.6.tgz#e5ea752808c86219acc179ace0ae3c1203255e77"
- integrity sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==
-
use-latest-callback@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/use-latest-callback/-/use-latest-callback-0.2.4.tgz#35c0f028f85a3f4cf025b06011110e87cc18f57e"
@@ -15824,7 +15681,7 @@ use-sidecar@^1.1.3:
detect-node-es "^1.1.0"
tslib "^2.0.0"
-use-sync-external-store@^1.2.2, use-sync-external-store@^1.5.0:
+use-sync-external-store@^1.2.2:
version "1.5.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0"
integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==
@@ -15858,17 +15715,6 @@ util-deprecate@^1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
-util@^0.12.5:
- version "0.12.5"
- resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc"
- integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==
- dependencies:
- inherits "^2.0.3"
- is-arguments "^1.0.4"
- is-generator-function "^1.0.7"
- is-typed-array "^1.1.3"
- which-typed-array "^1.1.2"
-
utils-merge@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
@@ -15967,7 +15813,7 @@ walker@^1.0.7, walker@^1.0.8:
dependencies:
makeerror "1.0.12"
-warn-once@0.1.1, warn-once@^0.1.0, warn-once@^0.1.1:
+warn-once@0.1.1, warn-once@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/warn-once/-/warn-once-0.1.1.tgz#952088f4fb56896e73fd4e6a3767272a3fccce43"
integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==
@@ -15979,10 +15825,10 @@ wcwidth@^1.0.1:
dependencies:
defaults "^1.0.3"
-web-streams-polyfill@^4.1.0:
- version "4.2.0"
- resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.2.0.tgz#93295e67af95889a1e044a6beff1366c82720650"
- integrity sha512-0rYDzGOh9EZpig92umN5g5D/9A1Kff7k0/mzPSSCY8jEQeYkgRMoY7LhbXtUCWzLCMX0TUE9aoHkjFNB7D9pfA==
+web-streams-polyfill@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.3.0.tgz#2f5fe004e2c53f09f7e03aef25b892953cab99a6"
+ integrity sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw==
webidl-conversions@^3.0.0:
version "3.0.1"
@@ -15999,10 +15845,10 @@ webidl-conversions@^7.0.0:
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a"
integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==
-webrtc-adapter@^9.0.1:
- version "9.0.3"
- resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz#b446ed7cd72129d00c652dd7b9a5716d9ffdd87d"
- integrity sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==
+webrtc-adapter@9.0.6:
+ version "9.0.6"
+ resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz#d89ce0d7b6b8b49f07bdbd1273074006744905a6"
+ integrity sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==
dependencies:
sdp "^3.2.0"
@@ -16031,6 +15877,11 @@ whatwg-mimetype@^3.0.0:
resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7"
integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==
+whatwg-url-minimum@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz#0ed1c6c74f4653288d6204c44eef3ded70977d12"
+ integrity sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==
+
whatwg-url-without-unicode@8.0.0-3:
version "8.0.0-3"
resolved "https://registry.yarnpkg.com/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz#ab6df4bf6caaa6c85a59f6e82c026151d4bb376b"
@@ -16101,7 +15952,7 @@ which-collection@^1.0.2:
is-weakmap "^2.0.2"
is-weakset "^2.0.3"
-which-typed-array@^1.1.16, which-typed-array@^1.1.19, which-typed-array@^1.1.2:
+which-typed-array@^1.1.16, which-typed-array@^1.1.19:
version "1.1.19"
resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956"
integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==
@@ -16135,11 +15986,6 @@ widest-line@^5.0.0:
dependencies:
string-width "^7.0.0"
-wonka@^6.3.2:
- version "6.3.5"
- resolved "https://registry.yarnpkg.com/wonka/-/wonka-6.3.5.tgz#33fa54ea700ff3e87b56fe32202112a9e8fea1a2"
- integrity sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==
-
word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
@@ -16219,13 +16065,6 @@ write-file-atomic@^5.0.1:
imurmurhash "^0.1.4"
signal-exit "^4.0.1"
-ws@^6.2.3:
- version "6.2.3"
- resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.3.tgz#ccc96e4add5fd6fedbc491903075c85c5a11d9ee"
- integrity sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==
- dependencies:
- async-limiter "~1.0.0"
-
ws@^7, ws@^7.5.10:
version "7.5.10"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9"
@@ -16362,7 +16201,7 @@ yaml@~2.6.1:
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.1.tgz#42f2b1ba89203f374609572d5349fb8686500773"
integrity sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==
-yargs-parser@^21.0.1, yargs-parser@^21.1.1:
+yargs-parser@^21.1.1:
version "21.1.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35"
integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==
@@ -16413,7 +16252,7 @@ zod-validation-error@^3.0.3:
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.5.3.tgz#85ba33290200d8db9f043621e284f40dddefb7e5"
integrity sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==
-zod@^3.22.4:
+zod@^3.22.4, zod@^3.25.76:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==