diff --git a/apps/expo/app/(app)/(tabs)/(home)/index.tsx b/apps/expo/app/(app)/(tabs)/(home)/index.tsx
index 7dd7da5100..994bef2923 100644
--- a/apps/expo/app/(app)/(tabs)/(home)/index.tsx
+++ b/apps/expo/app/(app)/(tabs)/(home)/index.tsx
@@ -29,6 +29,7 @@ import { ShoppingListTile } from 'expo-app/features/packs/components/ShoppingLis
import { WeightAnalysisTile } from 'expo-app/features/packs/components/WeightAnalysisTile';
import { TrailConditionsTile } from 'expo-app/features/trips/components/TrailConditionsTile';
import { UpcomingTripsTile } from 'expo-app/features/trips/components/UpcomingTripsTile';
+import { VoiceCommandsTile } from 'expo-app/features/voice/components/VoiceCommandsTile';
import { WeatherAlertsTile } from 'expo-app/features/weather/components/WeatherAlertsTile';
import { WeatherTile } from 'expo-app/features/weather/components/WeatherTile';
import { WildlifeTile } from 'expo-app/features/wildlife/components/WildlifeTile';
@@ -154,6 +155,11 @@ const tileInfo = {
],
component: WildlifeTile,
},
+ 'voice-commands': {
+ title: 'Voice Commands',
+ keywords: ['voice', 'microphone', 'hands-free', 'navigation', 'gps', 'speak', 'commands'],
+ component: VoiceCommandsTile,
+ },
};
type TileName = keyof typeof tileInfo;
@@ -246,6 +252,7 @@ export default function DashboardScreen() {
'gap 4',
'guides',
...(featureFlags.enableWildlifeIdentification ? ['wildlife'] : []),
+ ...(featureFlags.enableVoiceCommands ? ['voice-commands'] : []),
]).current;
const filteredTiles = useMemo(() => {
diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx
index aade62b523..d9bd18c2e3 100644
--- a/apps/expo/app/(app)/_layout.tsx
+++ b/apps/expo/app/(app)/_layout.tsx
@@ -237,6 +237,13 @@ export default function AppLayout() {
getPackTemplateItemDetailOptions((route.params as { id: string })?.id)
}
/>
+
>
);
diff --git a/apps/expo/app/(app)/voice-commands/index.tsx b/apps/expo/app/(app)/voice-commands/index.tsx
new file mode 100644
index 0000000000..6d29753659
--- /dev/null
+++ b/apps/expo/app/(app)/voice-commands/index.tsx
@@ -0,0 +1,17 @@
+import { VoiceCommandScreen } from 'expo-app/features/voice/screens/VoiceCommandScreen';
+import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
+import { StatusBar } from 'expo-status-bar';
+import { Platform } from 'react-native';
+
+export default function VoiceCommandsRoute() {
+ const { colorScheme } = useColorScheme();
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/expo/config.ts b/apps/expo/config.ts
index e2c23aa59e..8ec393d0c9 100644
--- a/apps/expo/config.ts
+++ b/apps/expo/config.ts
@@ -8,4 +8,5 @@ export const featureFlags = {
enableTrailConditions: false,
enableFeed: false,
enableWildlifeIdentification: false,
+ enableVoiceCommands: false,
};
diff --git a/apps/expo/features/catalog/types.ts b/apps/expo/features/catalog/types.ts
index 61f4b7f4a7..6c86cb7679 100644
--- a/apps/expo/features/catalog/types.ts
+++ b/apps/expo/features/catalog/types.ts
@@ -1,5 +1,5 @@
import type { WeightUnit } from 'expo-app/types';
-import type { PackItemInput } from '../packs';
+import type { PackItemInput } from '../packs/input';
export interface CatalogItemLink {
id: string;
diff --git a/apps/expo/features/packs/components/PackCard.tsx b/apps/expo/features/packs/components/PackCard.tsx
index d167c76834..5e41ae5b8d 100644
--- a/apps/expo/features/packs/components/PackCard.tsx
+++ b/apps/expo/features/packs/components/PackCard.tsx
@@ -145,10 +145,14 @@ export function PackCard({
size="icon"
disabled={isDuplicating}
onPress={() =>
- Alert.alert('Duplicate pack?', 'This will create a copy of this pack in your collection.', [
- { text: 'Cancel', style: 'cancel' },
- { text: 'Duplicate', onPress: () => duplicatePack(pack.id) },
- ])
+ Alert.alert(
+ 'Duplicate pack?',
+ 'This will create a copy of this pack in your collection.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Duplicate', onPress: () => duplicatePack(pack.id) },
+ ],
+ )
}
>
{isDuplicating ? (
@@ -165,10 +169,14 @@ export function PackCard({
variant="plain"
size="icon"
onPress={() =>
- Alert.alert('Delete pack?', 'Are you sure you want to delete this pack? This action cannot be undone.', [
- { text: 'Cancel', style: 'cancel' },
- { text: 'OK', style: 'destructive', onPress: () => deletePack(pack.id) },
- ])
+ Alert.alert(
+ 'Delete pack?',
+ 'Are you sure you want to delete this pack? This action cannot be undone.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'OK', style: 'destructive', onPress: () => deletePack(pack.id) },
+ ],
+ )
}
>
diff --git a/apps/expo/features/packs/hooks/useCreatePackFromPack.ts b/apps/expo/features/packs/hooks/useCreatePackFromPack.ts
index 32f99abe1d..ec31f09cc9 100644
--- a/apps/expo/features/packs/hooks/useCreatePackFromPack.ts
+++ b/apps/expo/features/packs/hooks/useCreatePackFromPack.ts
@@ -23,7 +23,7 @@ export function useCreatePackFromPack() {
deleted: false,
};
- // @ts-ignore: Safe because Legend-State uses Proxy
+ // @ts-expect-error: Safe because Legend-State uses Proxy
packsStore[newPackId].set(newPack);
// Copy each item from the source pack
@@ -40,7 +40,7 @@ export function useCreatePackFromPack() {
updatedAt: undefined,
};
- // @ts-ignore: Safe because Legend-State uses Proxy
+ // @ts-expect-error: Safe because Legend-State uses Proxy
packItemsStore[newItemId].set(newItem);
}
}
diff --git a/apps/expo/features/packs/input.ts b/apps/expo/features/packs/input.ts
new file mode 100644
index 0000000000..8d9a4895e0
--- /dev/null
+++ b/apps/expo/features/packs/input.ts
@@ -0,0 +1,15 @@
+import type { WeightUnit } from 'expo-app/types';
+
+export interface PackItemInput {
+ name: string;
+ description?: string;
+ weight: number;
+ weightUnit: WeightUnit;
+ quantity: number;
+ category?: string;
+ consumable: boolean;
+ worn: boolean;
+ notes?: string;
+ image?: string | null;
+ catalogItemId?: number;
+}
diff --git a/apps/expo/features/profile/types.ts b/apps/expo/features/profile/types.ts
index 16645c2203..f487d58a38 100644
--- a/apps/expo/features/profile/types.ts
+++ b/apps/expo/features/profile/types.ts
@@ -1,4 +1,4 @@
-import type { WeightUnit } from '../packs';
+import type { WeightUnit } from '../packs/types';
export interface User {
id: number;
diff --git a/apps/expo/features/voice/components/VoiceCommandPanel.tsx b/apps/expo/features/voice/components/VoiceCommandPanel.tsx
new file mode 100644
index 0000000000..e9bab0c527
--- /dev/null
+++ b/apps/expo/features/voice/components/VoiceCommandPanel.tsx
@@ -0,0 +1,144 @@
+import { Text } from '@packrat/ui/nativewindui';
+import { Icon, type MaterialIconName } from '@roninoss/icons';
+import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
+import { Pressable, View } from 'react-native';
+import type { VoiceCommand, VoiceListeningState } from '../types';
+
+interface VoiceCommandPanelProps {
+ listeningState: VoiceListeningState;
+ lastTranscript: string;
+ lastCommand: string | null;
+ isTracking: boolean;
+ waypointCount: number;
+ availableCommands: VoiceCommand[];
+ onStartListening: () => void;
+ onStopListening: () => void;
+ onTestCommand?: (transcript: string) => void;
+}
+
+const STATE_COLORS: Record = {
+ idle: 'bg-muted',
+ listening: 'bg-red-500',
+ processing: 'bg-amber-500',
+ error: 'bg-destructive',
+};
+
+const STATE_ICONS: Record = {
+ idle: 'microphone-outline',
+ listening: 'microphone',
+ processing: 'dots-horizontal',
+ error: 'information',
+};
+
+/**
+ * Main voice command control panel.
+ * Shows the microphone button, current status, last transcript,
+ * and a reference list of available commands.
+ */
+export function VoiceCommandPanel({
+ listeningState,
+ lastTranscript,
+ lastCommand,
+ isTracking,
+ waypointCount,
+ availableCommands,
+ onStartListening,
+ onStopListening,
+ onTestCommand,
+}: VoiceCommandPanelProps) {
+ const { t } = useTranslation();
+
+ const isListening = listeningState === 'listening';
+ const micBgClass = STATE_COLORS[listeningState];
+ const micIcon = STATE_ICONS[listeningState];
+
+ return (
+
+ {/* Status Row */}
+
+
+
+ {isTracking ? t('voice.trackingActive') : t('voice.trackingInactive')}
+
+
+
+
+ {t('voice.waypointsCount', { count: waypointCount })}
+
+
+
+
+ {/* Microphone Button */}
+
+
+
+
+
+ {listeningState === 'idle' && t('voice.holdToSpeak')}
+ {listeningState === 'listening' && t('voice.listening')}
+ {listeningState === 'processing' && t('voice.processing')}
+ {listeningState === 'error' && t('voice.error')}
+
+
+ {t('voice.holdMicDescription')}
+
+
+
+ {/* Last Transcript */}
+ {lastTranscript ? (
+
+
+ {t('voice.lastHeard')}
+
+ "{lastTranscript}"
+ {lastCommand && (
+
+ ✓ {t('voice.executedCommand', { command: lastCommand.replace(/_/g, ' ') })}
+
+ )}
+
+ ) : null}
+
+ {/* Available Commands Reference */}
+
+
+ {t('voice.availableCommands')}
+
+
+ {availableCommands.map((cmd, index) => (
+ onTestCommand?.(cmd.patterns[0] ?? '')}
+ className={`p-3 flex-row items-center gap-3 ${
+ index < availableCommands.length - 1 ? 'border-b border-border' : ''
+ }`}
+ accessibilityRole="button"
+ accessibilityLabel={`${t('voice.testCommand')} ${cmd.patterns[0]}`}
+ >
+
+
+
+
+ "{cmd.patterns[0]}"
+ {cmd.description}
+
+
+
+ ))}
+
+
+ {t('voice.tapCommandToTest')}
+
+
+
+ );
+}
diff --git a/apps/expo/features/voice/components/VoiceCommandsTile.tsx b/apps/expo/features/voice/components/VoiceCommandsTile.tsx
new file mode 100644
index 0000000000..bdf42c303d
--- /dev/null
+++ b/apps/expo/features/voice/components/VoiceCommandsTile.tsx
@@ -0,0 +1,47 @@
+import { ListItem } from '@packrat/ui/nativewindui';
+import { Icon } from '@roninoss/icons';
+import { featureFlags } from 'expo-app/config';
+import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
+import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
+import { useRouter } from 'expo-router';
+import { Platform, View } from 'react-native';
+
+/**
+ * Dashboard tile that navigates to the voice commands screen.
+ */
+export function VoiceCommandsTile() {
+ const router = useRouter();
+ const { colors } = useColorScheme();
+ const { t } = useTranslation();
+
+ if (!featureFlags.enableVoiceCommands) return null;
+
+ return (
+
+
+
+
+
+
+ }
+ rightView={
+
+
+
+ }
+ item={{
+ title: t('voice.voiceCommands'),
+ subTitle: t('voice.handsFreeNavigation'),
+ }}
+ onPress={() => router.push('/voice-commands')}
+ target="Cell"
+ index={0}
+ removeSeparator={Platform.OS === 'ios'}
+ />
+
+ );
+}
diff --git a/apps/expo/features/voice/hooks/__tests__/useVoiceCommands.test.ts b/apps/expo/features/voice/hooks/__tests__/useVoiceCommands.test.ts
new file mode 100644
index 0000000000..13aab9076d
--- /dev/null
+++ b/apps/expo/features/voice/hooks/__tests__/useVoiceCommands.test.ts
@@ -0,0 +1,251 @@
+/**
+ * Unit tests for voice-command logic in useVoiceCommands.
+ *
+ * Tests the pure helper functions (matchCommand, extractNavigationTarget) and
+ * timer-cleanup behaviour that is independent of the React / Expo environment.
+ *
+ * Run with: cd apps/expo && bun test features/voice
+ */
+
+import { describe, expect, it, mock } from 'bun:test';
+
+// ---------------------------------------------------------------------------
+// Mock Expo and React Native modules before importing the module under test.
+// bun:test hoists mock.module calls so they take effect before ESM imports.
+// ---------------------------------------------------------------------------
+
+mock.module('expo-av', () => ({
+ Audio: {
+ Recording: {
+ createAsync: async () => ({
+ recording: {
+ stopAndUnloadAsync: async () => {},
+ getURI: () => null,
+ },
+ }),
+ },
+ RecordingOptionsPresets: { HIGH_QUALITY: {} },
+ requestPermissionsAsync: async () => ({ granted: true }),
+ setAudioModeAsync: async () => {},
+ },
+}));
+
+mock.module('expo-speech', () => ({
+ stop: () => {},
+ speak: () => {},
+ isSpeakingAsync: async () => false,
+}));
+
+mock.module('expo-location', () => ({
+ Accuracy: { BestForNavigation: 6, High: 4 },
+ requestForegroundPermissionsAsync: async () => ({ status: 'granted' }),
+ watchPositionAsync: async () => ({ remove: () => {} }),
+ getCurrentPositionAsync: async () => ({
+ coords: {
+ latitude: 0,
+ longitude: 0,
+ altitude: null,
+ accuracy: null,
+ speed: null,
+ heading: null,
+ },
+ timestamp: 0,
+ }),
+}));
+
+mock.module('react', () => ({
+ useCallback: (fn: unknown) => fn,
+ useEffect: () => {},
+ useRef: (initial: unknown) => ({ current: initial }),
+ useState: (initial: unknown) => [initial, () => {}],
+}));
+
+// Use dynamic import so the mock.module calls above take effect first
+const { matchCommand, extractNavigationTarget } = await import('../useVoiceCommands');
+
+// ---------------------------------------------------------------------------
+// Command matching — disambiguation tests (Issue 4)
+// ---------------------------------------------------------------------------
+
+describe('matchCommand – where_am_i vs navigate_to disambiguation', () => {
+ it('"navigate to my location" does NOT match where_am_i', () => {
+ const result = matchCommand('navigate to my location');
+ expect(result?.name).not.toBe('where_am_i');
+ });
+
+ it('"navigate to my location" matches navigate_to', () => {
+ const result = matchCommand('navigate to my location');
+ expect(result?.name).toBe('navigate_to');
+ });
+
+ it('"where am i" still matches where_am_i', () => {
+ const result = matchCommand('where am i');
+ expect(result?.name).toBe('where_am_i');
+ });
+
+ it('"what\'s my location" still matches where_am_i', () => {
+ const result = matchCommand("what's my location");
+ expect(result?.name).toBe('where_am_i');
+ });
+
+ it('"current location" still matches where_am_i', () => {
+ const result = matchCommand('current location');
+ expect(result?.name).toBe('where_am_i');
+ });
+
+ it('"navigate to summit" matches navigate_to', () => {
+ const result = matchCommand('navigate to summit');
+ expect(result?.name).toBe('navigate_to');
+ });
+
+ it('"go to lake" matches navigate_to', () => {
+ const result = matchCommand('go to lake');
+ expect(result?.name).toBe('navigate_to');
+ });
+
+ it('unknown phrase returns null', () => {
+ expect(matchCommand('play some music')).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// extractNavigationTarget
+// ---------------------------------------------------------------------------
+
+describe('extractNavigationTarget', () => {
+ it('extracts target after "navigate to"', () => {
+ expect(extractNavigationTarget('navigate to my location')).toBe('my location');
+ });
+
+ it('extracts target after "go to"', () => {
+ expect(extractNavigationTarget('go to the summit')).toBe('the summit');
+ });
+
+ it('extracts target after "take me to"', () => {
+ expect(extractNavigationTarget('take me to camp alpha')).toBe('camp alpha');
+ });
+
+ it('extracts target after "directions to"', () => {
+ expect(extractNavigationTarget('directions to base camp')).toBe('base camp');
+ });
+
+ it('returns trimmed input when no known prefix found', () => {
+ expect(extractNavigationTarget(' unknown input ')).toBe('unknown input');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Timer cleanup tests (Issues 2 & 3) — verified through simulated hook logic
+// ---------------------------------------------------------------------------
+
+describe('startListening timer cleanup (Issue 2)', () => {
+ it('calling startListening twice clears the first timeout before setting a second', () => {
+ const clearedIds: ReturnType[] = [];
+ const scheduledIds: ReturnType[] = [];
+
+ let idCounter = 1000;
+ const originalSetTimeout = globalThis.setTimeout;
+ const originalClearTimeout = globalThis.clearTimeout;
+
+ // Install spies
+ (globalThis as any).setTimeout = (_fn: () => void, _ms: number) => {
+ const id = ++idCounter as unknown as ReturnType;
+ scheduledIds.push(id);
+ return id;
+ };
+ (globalThis as any).clearTimeout = (id: ReturnType) => {
+ clearedIds.push(id);
+ };
+
+ // Replicate the fixed startListening logic (Issue 2 fix)
+ let listenTimeoutRef: ReturnType | null = null;
+
+ function simulateStartListening() {
+ if (listenTimeoutRef !== null) {
+ clearTimeout(listenTimeoutRef);
+ listenTimeoutRef = null; // <-- the fix: null out after clearing
+ }
+ listenTimeoutRef = setTimeout(() => {}, 10000);
+ }
+
+ simulateStartListening(); // first call — no prior timer
+ expect(scheduledIds).toHaveLength(1);
+ expect(clearedIds).toHaveLength(0);
+
+ simulateStartListening(); // second call — must clear the first
+ expect(scheduledIds).toHaveLength(2);
+ expect(clearedIds).toHaveLength(1);
+ expect(clearedIds[0]).toBe(scheduledIds[0]);
+
+ // Restore originals
+ (globalThis as any).setTimeout = originalSetTimeout;
+ (globalThis as any).clearTimeout = originalClearTimeout;
+ });
+});
+
+describe('processTranscript timer cleanup (Issue 3)', () => {
+ it('when a transcript arrives before the 10s timeout, the timeout is cleared', () => {
+ const clearedIds: ReturnType[] = [];
+ const scheduledIds: ReturnType[] = [];
+
+ let idCounter = 2000;
+ const originalSetTimeout = globalThis.setTimeout;
+ const originalClearTimeout = globalThis.clearTimeout;
+
+ (globalThis as any).setTimeout = (_fn: () => void, _ms: number) => {
+ const id = ++idCounter as unknown as ReturnType;
+ scheduledIds.push(id);
+ return id;
+ };
+ (globalThis as any).clearTimeout = (id: ReturnType) => {
+ clearedIds.push(id);
+ };
+
+ let listenTimeoutRef: ReturnType | null = null;
+
+ function simulateStartListening() {
+ if (listenTimeoutRef !== null) {
+ clearTimeout(listenTimeoutRef);
+ listenTimeoutRef = null;
+ }
+ listenTimeoutRef = setTimeout(() => {}, 10000);
+ }
+
+ function simulateProcessTranscript() {
+ // Issue 3 fix: cancel auto-timeout when transcript arrives
+ if (listenTimeoutRef !== null) {
+ clearTimeout(listenTimeoutRef);
+ listenTimeoutRef = null;
+ }
+ }
+
+ simulateStartListening();
+ const autoTimeoutId = scheduledIds.at(0);
+ expect(autoTimeoutId).toBeDefined();
+ expect(clearedIds).toHaveLength(0);
+
+ // Transcript arrives — the auto-timeout must be cancelled
+ simulateProcessTranscript();
+ expect(clearedIds).toContain(autoTimeoutId);
+ expect(listenTimeoutRef).toBeNull();
+
+ // Restore originals
+ (globalThis as any).setTimeout = originalSetTimeout;
+ (globalThis as any).clearTimeout = originalClearTimeout;
+ });
+
+ it('after transcript clears the timer, the "timed out" callback does not fire', async () => {
+ let timedOutCalled = false;
+
+ const timerId = setTimeout(() => {
+ timedOutCalled = true;
+ }, 50); // short delay
+
+ // Transcript arrives before timeout fires
+ clearTimeout(timerId);
+
+ // Wait beyond the delay; the callback must NOT have fired
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(timedOutCalled).toBe(false);
+ });
+});
diff --git a/apps/expo/features/voice/hooks/index.ts b/apps/expo/features/voice/hooks/index.ts
new file mode 100644
index 0000000000..2626019ffd
--- /dev/null
+++ b/apps/expo/features/voice/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './useGPSTracking';
+export * from './useSpeech';
+export * from './useVoiceCommands';
diff --git a/apps/expo/features/voice/hooks/useGPSTracking.ts b/apps/expo/features/voice/hooks/useGPSTracking.ts
new file mode 100644
index 0000000000..edae8f4555
--- /dev/null
+++ b/apps/expo/features/voice/hooks/useGPSTracking.ts
@@ -0,0 +1,160 @@
+import * as Location from 'expo-location';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { GPSPosition, Waypoint } from '../types';
+
+/**
+ * Hook for GPS tracking and waypoint management.
+ * Uses expo-location for on-device GPS (no internet required).
+ */
+export function useGPSTracking() {
+ const [isTracking, setIsTracking] = useState(false);
+ const [currentPosition, setCurrentPosition] = useState(null);
+ const [waypoints, setWaypoints] = useState([]);
+ const [permissionGranted, setPermissionGranted] = useState(false);
+ const watchRef = useRef(null);
+ // Prevents concurrent permission requests racing each other (#12)
+ const permissionRequestInFlightRef = useRef(false);
+ // Stable counter for auto-generated waypoint names — avoids waypoints.length dep
+ const waypointCountRef = useRef(0);
+
+ useEffect(() => {
+ if (permissionRequestInFlightRef.current) return;
+ permissionRequestInFlightRef.current = true;
+ Location.requestForegroundPermissionsAsync()
+ .then(({ status }) => {
+ setPermissionGranted(status === 'granted');
+ })
+ .finally(() => {
+ permissionRequestInFlightRef.current = false;
+ });
+ }, []);
+
+ const startTracking = useCallback(async () => {
+ // Guard: prevent creating a second subscription while already tracking (#3).
+ // Return true because tracking is already active — the caller's intent is satisfied.
+ if (watchRef.current) return true;
+
+ if (!permissionGranted) {
+ if (permissionRequestInFlightRef.current) return false;
+ permissionRequestInFlightRef.current = true;
+ const { status } = await Location.requestForegroundPermissionsAsync();
+ permissionRequestInFlightRef.current = false;
+ if (status !== 'granted') return false;
+ setPermissionGranted(true);
+ }
+
+ try {
+ const subscription = await Location.watchPositionAsync(
+ {
+ accuracy: Location.Accuracy.BestForNavigation,
+ timeInterval: 5000,
+ distanceInterval: 10,
+ },
+ (location) => {
+ setCurrentPosition({
+ latitude: location.coords.latitude,
+ longitude: location.coords.longitude,
+ altitude: location.coords.altitude,
+ accuracy: location.coords.accuracy,
+ speed: location.coords.speed,
+ heading: location.coords.heading,
+ timestamp: location.timestamp,
+ });
+ },
+ );
+ watchRef.current = subscription;
+ setIsTracking(true);
+ return true;
+ } catch (err) {
+ console.warn('[useGPSTracking] watchPositionAsync failed:', err);
+ return false;
+ }
+ }, [permissionGranted]);
+
+ const stopTracking = useCallback(() => {
+ if (watchRef.current) {
+ watchRef.current.remove();
+ watchRef.current = null;
+ }
+ setIsTracking(false);
+ }, []);
+
+ const getCurrentPosition = useCallback(async (): Promise => {
+ if (!permissionGranted) return null;
+ try {
+ const location = await Location.getCurrentPositionAsync({
+ accuracy: Location.Accuracy.High,
+ });
+ const pos: GPSPosition = {
+ latitude: location.coords.latitude,
+ longitude: location.coords.longitude,
+ altitude: location.coords.altitude,
+ accuracy: location.coords.accuracy,
+ speed: location.coords.speed,
+ heading: location.coords.heading,
+ timestamp: location.timestamp,
+ };
+ setCurrentPosition(pos);
+ return pos;
+ } catch {
+ return null;
+ }
+ }, [permissionGranted]);
+
+ const markWaypoint = useCallback(
+ async (name?: string): Promise => {
+ const pos = currentPosition ?? (await getCurrentPosition());
+ if (!pos) return null;
+
+ // Increment counter via ref so we don't need waypoints.length in deps (#6)
+ waypointCountRef.current += 1;
+ const waypoint: Waypoint = {
+ id: `wp_${Date.now()}`,
+ name: name ?? `Waypoint ${waypointCountRef.current}`,
+ latitude: pos.latitude,
+ longitude: pos.longitude,
+ createdAt: new Date().toISOString(),
+ };
+ setWaypoints((prev) => [...prev, waypoint]);
+ return waypoint;
+ },
+ [currentPosition, getCurrentPosition],
+ );
+
+ const getDistanceTo = useCallback(
+ (target: { latitude: number; longitude: number }): number | null => {
+ if (!currentPosition) return null;
+ const R = 6371e3; // metres
+ const φ1 = (currentPosition.latitude * Math.PI) / 180;
+ const φ2 = (target.latitude * Math.PI) / 180;
+ const Δφ = ((target.latitude - currentPosition.latitude) * Math.PI) / 180;
+ const Δλ = ((target.longitude - currentPosition.longitude) * Math.PI) / 180;
+ const a =
+ Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
+ Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+ },
+ [currentPosition],
+ );
+
+ useEffect(() => {
+ return () => {
+ if (watchRef.current) {
+ watchRef.current.remove();
+ }
+ };
+ }, []);
+
+ return {
+ isTracking,
+ currentPosition,
+ waypoints,
+ permissionGranted,
+ startTracking,
+ stopTracking,
+ getCurrentPosition,
+ markWaypoint,
+ getDistanceTo,
+ };
+}
diff --git a/apps/expo/features/voice/hooks/useSpeech.ts b/apps/expo/features/voice/hooks/useSpeech.ts
new file mode 100644
index 0000000000..32e7b62e1f
--- /dev/null
+++ b/apps/expo/features/voice/hooks/useSpeech.ts
@@ -0,0 +1,27 @@
+import * as Speech from 'expo-speech';
+import { useCallback } from 'react';
+
+/**
+ * Hook for text-to-speech voice feedback.
+ * Uses expo-speech for on-device TTS (no internet required).
+ */
+export function useSpeech() {
+ const speak = useCallback((text: string) => {
+ Speech.stop();
+ Speech.speak(text, {
+ language: 'en-US',
+ pitch: 1.0,
+ rate: 0.9,
+ });
+ }, []);
+
+ const stop = useCallback(() => {
+ Speech.stop();
+ }, []);
+
+ const isSpeaking = useCallback(async (): Promise => {
+ return Speech.isSpeakingAsync();
+ }, []);
+
+ return { speak, stop, isSpeaking };
+}
diff --git a/apps/expo/features/voice/hooks/useVoiceCommands.ts b/apps/expo/features/voice/hooks/useVoiceCommands.ts
new file mode 100644
index 0000000000..65ca02991f
--- /dev/null
+++ b/apps/expo/features/voice/hooks/useVoiceCommands.ts
@@ -0,0 +1,358 @@
+import { Audio } from 'expo-av';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { VoiceCommand, VoiceCommandName, VoiceListeningState } from '../types';
+import { useGPSTracking } from './useGPSTracking';
+import { useSpeech } from './useSpeech';
+
+/**
+ * Registered voice commands with their trigger patterns.
+ * Pattern matching is case-insensitive substring match,
+ * enabling future integration with any speech-to-text backend
+ * (Vosk, Picovoice, Whisper, Web Speech API, etc.).
+ */
+export const VOICE_COMMANDS: VoiceCommand[] = [
+ {
+ name: 'start_tracking',
+ patterns: ['start tracking', 'begin tracking', 'start gps', 'track me'],
+ description: 'Begin GPS location tracking',
+ },
+ {
+ name: 'stop_tracking',
+ patterns: ['stop tracking', 'end tracking', 'stop gps', 'pause tracking'],
+ description: 'End GPS location tracking',
+ },
+ {
+ name: 'mark_waypoint',
+ patterns: ['mark waypoint', 'save location', 'add waypoint', 'mark location', 'drop pin'],
+ description: 'Create a waypoint at the current location',
+ },
+ {
+ name: 'where_am_i',
+ patterns: ['where am i', "what's my location", 'current location'],
+ description: 'Announce current GPS coordinates',
+ },
+ {
+ name: 'how_far',
+ patterns: [
+ 'how far',
+ 'distance to',
+ 'how far to destination',
+ 'distance remaining',
+ 'how much further',
+ ],
+ description: 'Announce distance to last waypoint',
+ },
+ {
+ name: 'navigate_to',
+ patterns: ['navigate to', 'go to', 'take me to', 'directions to'],
+ description: 'Start navigation to a named waypoint',
+ },
+];
+
+/** Ordered longest-first so longer prefixes match before shorter ones (e.g. "directions to" before "go to"). */
+const NAVIGATE_TO_PREFIXES = ['directions to', 'navigate to', 'take me to', 'go to'] as const;
+
+/**
+ * Match a transcript against registered command patterns.
+ * Exported for testing purposes.
+ */
+export function matchCommand(transcript: string): VoiceCommand | null {
+ const lower = transcript.toLowerCase().trim();
+ for (const cmd of VOICE_COMMANDS) {
+ if (cmd.patterns.some((p) => lower.includes(p))) {
+ return cmd;
+ }
+ }
+ return null;
+}
+
+/**
+ * Extract the destination noun phrase from a navigate_to transcript.
+ * Strips the first matching command prefix for reliable extraction (#8).
+ * Exported for testing purposes.
+ */
+export function extractNavigationTarget(transcript: string): string {
+ const lower = transcript.toLowerCase();
+ for (const prefix of NAVIGATE_TO_PREFIXES) {
+ const idx = lower.indexOf(prefix);
+ if (idx !== -1) {
+ return transcript.slice(idx + prefix.length).trim();
+ }
+ }
+ return transcript.trim();
+}
+
+/**
+ * Format metres into a human-readable distance string.
+ */
+function formatDistance(metres: number): string {
+ if (metres < 1000) {
+ return `${Math.round(metres)} meters`;
+ }
+ return `${(metres / 1000).toFixed(1)} kilometers`;
+}
+
+/**
+ * Main hook that wires together voice recognition, GPS tracking, and TTS.
+ *
+ * Voice input is accepted via the `processTranscript` function, which lets
+ * any speech-to-text backend (Vosk, Picovoice, Web Speech API, etc.) feed
+ * recognised text into the command pipeline.
+ *
+ * The `startListening` / `stopListening` lifecycle hooks are provided so UI
+ * components can trigger recording on button press — the actual microphone
+ * work is delegated to the chosen STT integration.
+ */
+export function useVoiceCommands() {
+ const { speak } = useSpeech();
+
+ // Destructure only the values we need so that useCallback deps are stable (#2)
+ const {
+ startTracking,
+ stopTracking,
+ markWaypoint,
+ getCurrentPosition,
+ getDistanceTo,
+ currentPosition,
+ waypoints,
+ isTracking,
+ } = useGPSTracking();
+
+ const [listeningState, setListeningState] = useState('idle');
+ const [lastCommand, setLastCommand] = useState(null);
+ const [lastTranscript, setLastTranscript] = useState('');
+ const listeningTimeoutRef = useRef | null>(null);
+ const recordingRef = useRef(null);
+ // Stable ref so startListening's timeout can call stopListening without a dep
+ const stopListeningRef = useRef<(() => Promise) | null>(null);
+
+ // Clear the auto-timeout and any active recording when the hook unmounts (#4)
+ useEffect(() => {
+ return () => {
+ if (listeningTimeoutRef.current) {
+ clearTimeout(listeningTimeoutRef.current);
+ }
+ if (recordingRef.current) {
+ recordingRef.current.stopAndUnloadAsync().catch(() => {});
+ recordingRef.current = null;
+ }
+ };
+ }, []);
+
+ /** Called by a speech-to-text backend with the recognised text. */
+ const processTranscript = useCallback(
+ async (transcript: string) => {
+ // Cancel the auto-timeout so it doesn't fire after command processing (#3)
+ if (listeningTimeoutRef.current) {
+ clearTimeout(listeningTimeoutRef.current);
+ listeningTimeoutRef.current = null;
+ }
+ setLastTranscript(transcript);
+ setListeningState('processing');
+
+ const command = matchCommand(transcript);
+ if (!command) {
+ speak("I didn't recognise that command. Please try again.");
+ setListeningState('error');
+ return;
+ }
+
+ setLastCommand(command.name);
+
+ try {
+ switch (command.name) {
+ case 'start_tracking': {
+ const started = await startTracking();
+ if (!started) {
+ speak('Unable to start tracking. Check permissions.');
+ setListeningState('error');
+ return;
+ }
+ speak('GPS tracking started.');
+ break;
+ }
+
+ case 'stop_tracking': {
+ stopTracking();
+ speak('GPS tracking stopped.');
+ break;
+ }
+
+ case 'mark_waypoint': {
+ const waypoint = await markWaypoint();
+ if (waypoint) {
+ speak(`Waypoint ${waypoint.name} marked at your current location.`);
+ } else {
+ speak('Unable to mark waypoint. GPS position not available.');
+ setListeningState('error');
+ return;
+ }
+ break;
+ }
+
+ case 'where_am_i': {
+ const pos = currentPosition ?? (await getCurrentPosition());
+ if (pos) {
+ speak(
+ `You are at latitude ${pos.latitude.toFixed(4)}, longitude ${pos.longitude.toFixed(4)}.`,
+ );
+ } else {
+ speak('Unable to determine your location. Check GPS permissions.');
+ setListeningState('error');
+ return;
+ }
+ break;
+ }
+
+ case 'how_far': {
+ const lastWaypoint = waypoints[waypoints.length - 1];
+ if (!lastWaypoint) {
+ speak('No waypoints saved. Mark a waypoint first.');
+ } else {
+ const dist = getDistanceTo(lastWaypoint);
+ if (dist !== null) {
+ speak(`You are ${formatDistance(dist)} from ${lastWaypoint.name}.`);
+ } else {
+ speak('Unable to calculate distance. GPS position not available.');
+ setListeningState('error');
+ return;
+ }
+ }
+ break;
+ }
+
+ case 'navigate_to': {
+ const target = extractNavigationTarget(transcript);
+ const found = waypoints.find((w) =>
+ w.name.toLowerCase().includes(target.toLowerCase()),
+ );
+ if (found) {
+ const dist = getDistanceTo(found);
+ if (dist !== null) {
+ speak(`Navigating to ${found.name}. Distance is ${formatDistance(dist)}.`);
+ } else {
+ speak(`Navigating to ${found.name}.`);
+ }
+ } else {
+ speak(
+ target
+ ? `Waypoint "${target}" not found. Try marking it first.`
+ : 'Please say the waypoint name after "navigate to".',
+ );
+ }
+ break;
+ }
+ }
+ } catch {
+ speak('An error occurred while processing your command.');
+ setListeningState('error');
+ return;
+ }
+
+ setListeningState('idle');
+ },
+ [
+ speak,
+ startTracking,
+ stopTracking,
+ markWaypoint,
+ getCurrentPosition,
+ getDistanceTo,
+ currentPosition,
+ waypoints,
+ ],
+ );
+
+ /** Called when user presses the microphone button to start recording. */
+ const startListening = useCallback(async () => {
+ // Bail out if already recording to avoid a second subscription (#3 pattern)
+ if (recordingRef.current) return;
+
+ // Clear any previously scheduled timeout before starting a new one
+ if (listeningTimeoutRef.current) {
+ clearTimeout(listeningTimeoutRef.current);
+ listeningTimeoutRef.current = null;
+ }
+
+ try {
+ // Request mic permission and configure the audio session
+ const { granted } = await Audio.requestPermissionsAsync();
+ if (!granted) {
+ speak('Microphone permission is required for voice commands.');
+ return;
+ }
+
+ await Audio.setAudioModeAsync({
+ allowsRecordingIOS: true,
+ playsInSilentModeIOS: true,
+ });
+
+ const { recording } = await Audio.Recording.createAsync(
+ Audio.RecordingOptionsPresets.HIGH_QUALITY,
+ );
+ recordingRef.current = recording;
+ } catch (err) {
+ console.warn('[useVoiceCommands] Failed to start recording:', err);
+ speak('Unable to start microphone. Please try again.');
+ return;
+ }
+
+ setListeningState('listening');
+
+ // Auto-timeout after 10 seconds if no transcript arrives
+ listeningTimeoutRef.current = setTimeout(() => {
+ stopListeningRef.current?.();
+ speak('Listening timed out. Please try again.');
+ }, 10000);
+ }, [speak]);
+
+ /** Called when user releases the microphone button or recording ends. */
+ const stopListening = useCallback(async () => {
+ if (listeningTimeoutRef.current) {
+ clearTimeout(listeningTimeoutRef.current);
+ listeningTimeoutRef.current = null;
+ }
+ // Use functional update to avoid stale closure on listeningState (#5)
+ setListeningState((prev) => (prev === 'listening' ? 'idle' : prev));
+
+ if (recordingRef.current) {
+ try {
+ await recordingRef.current.stopAndUnloadAsync();
+ // The URI of the recorded audio file (for STT transcription)
+ const uri = recordingRef.current.getURI();
+ recordingRef.current = null;
+
+ // TODO: wire to real STT backend (e.g., OpenAI Whisper, Google Speech-to-Text)
+ // Send `uri` to the STT service and call processTranscript() with the result.
+ // Example: const transcript = await transcribeAudio(uri);
+ // if (transcript) processTranscript(transcript);
+ console.debug('[useVoiceCommands] Recording saved to:', uri);
+ } catch (err) {
+ console.warn('[useVoiceCommands] Failed to stop recording:', err);
+ recordingRef.current = null;
+ }
+ }
+ }, []);
+ // Keep ref in sync so startListening's timeout can call stopListening without a circular dep
+ stopListeningRef.current = stopListening;
+
+ return {
+ // State
+ listeningState,
+ lastCommand,
+ lastTranscript,
+
+ // GPS state passthrough
+ isTracking,
+ currentPosition,
+ waypoints,
+
+ // Actions
+ startListening,
+ stopListening,
+ processTranscript,
+
+ // Available commands reference
+ availableCommands: VOICE_COMMANDS,
+ };
+}
diff --git a/apps/expo/features/voice/index.ts b/apps/expo/features/voice/index.ts
new file mode 100644
index 0000000000..a9a0a249f5
--- /dev/null
+++ b/apps/expo/features/voice/index.ts
@@ -0,0 +1,2 @@
+export * from './hooks';
+export * from './types';
diff --git a/apps/expo/features/voice/screens/VoiceCommandScreen.tsx b/apps/expo/features/voice/screens/VoiceCommandScreen.tsx
new file mode 100644
index 0000000000..ce9f7a14af
--- /dev/null
+++ b/apps/expo/features/voice/screens/VoiceCommandScreen.tsx
@@ -0,0 +1,59 @@
+import { Text } from '@packrat/ui/nativewindui';
+import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
+import { ScrollView, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { VoiceCommandPanel } from '../components/VoiceCommandPanel';
+import { useVoiceCommands } from '../hooks/useVoiceCommands';
+
+export function VoiceCommandScreen() {
+ const { t } = useTranslation();
+ const {
+ listeningState,
+ lastCommand,
+ lastTranscript,
+ isTracking,
+ waypoints,
+ availableCommands,
+ startListening,
+ stopListening,
+ processTranscript,
+ } = useVoiceCommands();
+
+ return (
+
+
+ {/* Header */}
+
+ {t('voice.voiceCommands')}
+
+ {t('voice.screenDescription')}
+
+
+
+ {/* Offline badge */}
+
+
+
+ {t('voice.offlineCapable')}
+
+ {t('voice.offlineDescription')}
+
+
+
+
+
+ );
+}
diff --git a/apps/expo/features/voice/types.ts b/apps/expo/features/voice/types.ts
new file mode 100644
index 0000000000..91e1e5790a
--- /dev/null
+++ b/apps/expo/features/voice/types.ts
@@ -0,0 +1,42 @@
+export type VoiceCommandName =
+ | 'start_tracking'
+ | 'stop_tracking'
+ | 'mark_waypoint'
+ | 'where_am_i'
+ | 'how_far'
+ | 'navigate_to';
+
+export interface VoiceCommand {
+ name: VoiceCommandName;
+ patterns: string[];
+ description: string;
+}
+
+export interface Waypoint {
+ id: string;
+ name: string;
+ latitude: number;
+ longitude: number;
+ createdAt: string;
+}
+
+export interface GPSPosition {
+ latitude: number;
+ longitude: number;
+ altitude: number | null;
+ accuracy: number | null;
+ speed: number | null;
+ heading: number | null;
+ timestamp: number;
+}
+
+export type VoiceListeningState = 'idle' | 'listening' | 'processing' | 'error';
+
+export interface VoiceCommandsState {
+ listeningState: VoiceListeningState;
+ lastCommand: VoiceCommandName | null;
+ lastTranscript: string;
+ isTracking: boolean;
+ currentPosition: GPSPosition | null;
+ waypoints: Waypoint[];
+}
diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json
index ae4205a0fa..40c1091a72 100644
--- a/apps/expo/lib/i18n/locales/en.json
+++ b/apps/expo/lib/i18n/locales/en.json
@@ -1142,5 +1142,27 @@
"fish": "fish",
"other": "other"
}
+ },
+ "voice": {
+ "voiceCommands": "Voice Commands",
+ "handsFreeNavigation": "Hands-free trail navigation",
+ "screenDescription": "Control navigation hands-free using your voice.",
+ "offlineCapable": "Works Offline",
+ "offlineDescription": "No internet required",
+ "holdToSpeak": "Hold to Speak",
+ "holdMicDescription": "Hold the button and speak a command",
+ "startListening": "Start listening",
+ "stopListening": "Stop listening",
+ "listening": "Listening…",
+ "processing": "Processing…",
+ "error": "Error",
+ "trackingActive": "GPS Tracking Active",
+ "trackingInactive": "GPS Tracking Off",
+ "waypointsCount": "{{count}} waypoints",
+ "lastHeard": "Last heard",
+ "executedCommand": "Executed: {{command}}",
+ "availableCommands": "Available Commands",
+ "tapCommandToTest": "Tap a command to test it",
+ "testCommand": "Test command"
}
}
diff --git a/apps/expo/lib/utils/itemCalculations.ts b/apps/expo/lib/utils/itemCalculations.ts
index ed2d30a59e..72139e71c8 100644
--- a/apps/expo/lib/utils/itemCalculations.ts
+++ b/apps/expo/lib/utils/itemCalculations.ts
@@ -1,6 +1,6 @@
import { makeEnumGuard } from '@packrat/guards';
import type { CatalogItem } from 'expo-app/features/catalog/types';
-import type { PackTemplateItem } from 'expo-app/features/pack-templates';
+import type { PackTemplateItem } from 'expo-app/features/pack-templates/types';
import type { PackItem, WeightUnit } from 'expo-app/features/packs';
type Item = CatalogItem | PackItem | PackTemplateItem;
diff --git a/apps/expo/package.json b/apps/expo/package.json
index 848f961879..454bb1a7be 100644
--- a/apps/expo/package.json
+++ b/apps/expo/package.json
@@ -77,13 +77,15 @@
"@stardazed/streams-text-encoding": "^1.0.2",
"@tanstack/react-form": "^1.0.5",
"@tanstack/react-query": "^5.70.0",
- "ai": "^5.0.136",
- "axios": "^1.8.4",
+ "ai": "catalog:",
+ "axios": "catalog:",
"burnt": "^0.13.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
"expo": "^54.0.0",
"expo-apple-authentication": "~8.0.8",
+ "expo-av": "~15.1.7",
"expo-blur": "~15.0.8",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.13",
@@ -100,6 +102,7 @@
"expo-navigation-bar": "~5.0.10",
"expo-router": "~6.0.23",
"expo-secure-store": "~15.0.8",
+ "expo-speech": "~13.1.0",
"expo-sqlite": "~16.0.10",
"expo-status-bar": "~3.0.9",
"expo-store-review": "~9.0.9",
@@ -112,9 +115,9 @@
"jotai": "^2.12.2",
"llama.rn": "0.10.1",
"nativewind": "^4.2.3",
- "radash": "^12.1.1",
- "react": "19.1.0",
- "react-dom": "19.1.0",
+ "radash": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:",
"react-i18next": "^16.5.6",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
@@ -130,8 +133,7 @@
"react-native-web": "^0.21.0",
"tailwind-merge": "^2.5.5",
"use-debounce": "^10.0.5",
- "zod": "^3.24.2",
- "date-fns": "^4.1.0"
+ "zod": "catalog:"
},
"devDependencies": {
"@babel/core": "^7.20.0",
@@ -147,8 +149,8 @@
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.11",
"rimraf": "^6.0.1",
- "tailwindcss": "^3.4.17",
- "typescript": "^5.8.2",
+ "tailwindcss": "catalog:",
+ "typescript": "catalog:",
"vitest": "~3.1.0"
}
}
diff --git a/package.json b/package.json
index 1c28b79f59..e33a348c1d 100644
--- a/package.json
+++ b/package.json
@@ -28,9 +28,9 @@
"ios": "cd apps/expo && bun ios",
"lefthook": "lefthook install",
"lint": "biome check --write",
- "lint-unsafe": "biome check --write --unsafe",
"lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts",
"lint:strict": "biome check && bun run lint:custom",
+ "lint-unsafe": "biome check --write --unsafe",
"test:api:unit": "vitest run --config packages/api/vitest.unit.config.ts",
"test:e2e:android": "bash .github/scripts/e2e.sh android",
"test:e2e:ios": "bash .github/scripts/e2e.sh ios",
@@ -47,9 +47,6 @@
"lefthook": "^1.11.14",
"sort-package-json": "^3.6.1"
},
- "patchedDependencies": {
- "@packrat-ai/nativewindui@1.1.0": "patches/@packrat-ai+nativewindui@1.1.0.patch"
- },
"catalog": {
"ai": "^5.0.136",
"axios": "^1.12.0",
@@ -88,6 +85,9 @@
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7"
},
+ "patchedDependencies": {
+ "@packrat-ai/nativewindui@1.1.0": "patches/@packrat-ai+nativewindui@1.1.0.patch"
+ },
"trustedDependencies": [
"@sentry/cli"
]
diff --git a/packages/api/src/db/schema.ts b/packages/api/src/db/schema.ts
index aa8f4ec46e..ecf0c617a0 100644
--- a/packages/api/src/db/schema.ts
+++ b/packages/api/src/db/schema.ts
@@ -15,7 +15,7 @@ import {
varchar,
vector,
} from 'drizzle-orm/pg-core';
-import type { ValidationError } from '../types/etl';
+import type { ValidationError } from '../types/validation';
const availabilityEnum = pgEnum('availability', ['in_stock', 'out_of_stock', 'preorder']);
diff --git a/packages/api/src/types/etl.ts b/packages/api/src/types/etl.ts
index e1a323fb39..6d31e97fca 100644
--- a/packages/api/src/types/etl.ts
+++ b/packages/api/src/types/etl.ts
@@ -1,10 +1,7 @@
import type { NewCatalogItem } from '@packrat/api/db/schema';
+import type { ValidationError } from './validation';
-export interface ValidationError {
- field: string;
- reason: string;
- value?: string | number | boolean | null | undefined;
-}
+export type { ValidationError } from './validation';
export interface ValidatedCatalogItem {
item: Partial;
diff --git a/packages/api/src/types/validation.ts b/packages/api/src/types/validation.ts
new file mode 100644
index 0000000000..e2d273875b
--- /dev/null
+++ b/packages/api/src/types/validation.ts
@@ -0,0 +1,5 @@
+export interface ValidationError {
+ field: string;
+ reason: string;
+ value?: string | number | boolean | null | undefined;
+}
diff --git a/scripts/lint/no-circular-deps.ts b/scripts/lint/no-circular-deps.ts
index e72a48ecc8..214c4eb6e9 100644
--- a/scripts/lint/no-circular-deps.ts
+++ b/scripts/lint/no-circular-deps.ts
@@ -157,21 +157,31 @@ function collectFiles(): string[] {
const IMPORT_RE = /(?:import|export)\s+(?:[^'"]*\s+from\s+)?['"]([^'"]+)['"]/g;
const REQUIRE_RE = /(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
+// Strip block comments (/* … */) and line comments (// …) so that example
+// import paths in JSDoc comments are not mistaken for real imports.
+const BLOCK_COMMENT_RE = /\/\*[\s\S]*?\*\//g;
+const LINE_COMMENT_RE = /\/\/[^\n]*/g;
+
+function stripComments(source: string): string {
+ return source.replace(BLOCK_COMMENT_RE, '').replace(LINE_COMMENT_RE, '');
+}
+
function extractImports(source: string): string[] {
const specifiers: string[] = [];
+ const stripped = stripComments(source);
IMPORT_RE.lastIndex = 0;
- let importMatch = IMPORT_RE.exec(source);
+ let importMatch = IMPORT_RE.exec(stripped);
while (importMatch !== null) {
if (importMatch[1]) specifiers.push(importMatch[1]);
- importMatch = IMPORT_RE.exec(source);
+ importMatch = IMPORT_RE.exec(stripped);
}
REQUIRE_RE.lastIndex = 0;
- let requireMatch = REQUIRE_RE.exec(source);
+ let requireMatch = REQUIRE_RE.exec(stripped);
while (requireMatch !== null) {
if (requireMatch[1]) specifiers.push(requireMatch[1]);
- requireMatch = REQUIRE_RE.exec(source);
+ requireMatch = REQUIRE_RE.exec(stripped);
}
return specifiers;