diff --git a/src/api/chat/chatbot.ts b/src/api/chat/chatbot.ts index 8989d97..42ddc55 100644 --- a/src/api/chat/chatbot.ts +++ b/src/api/chat/chatbot.ts @@ -1,4 +1,4 @@ -import { type ChatbotChannelResponse, type ChatbotSendResponse, type ChatbotSessionResponse } from '@/models/v4/chat'; +import { type ChatbotChannelResponse, type ChatbotSendResponse, type ChatbotSessionResponse, type IncidentAssistantAnswerResponse, type IncidentAssistantSuggestionsResponse } from '@/models/v4/chat'; import { api } from '../common/client'; @@ -27,3 +27,19 @@ export const newChatbotSession = async () => { const response = await api.post(`${CHATBOT}/NewChatSession`, {}); return response.data; }; + +/** + * Asks the incident assistant a command-board question and gets the answer back in the same + * round-trip. `callId` scopes the question to the board the caller has open, so "PAR" resolves + * against that incident rather than guessing among the department's active commands. + */ +export const askIncidentAssistant = async (callId: number, question: string, signal?: AbortSignal) => { + const response = await api.post(`${CHATBOT}/AskIncident`, { Question: question, CallId: callId }, { signal }); + return response.data?.Data ?? null; +}; + +/** Server-side suggested questions for an incident, from the ICS playbook it infers for the call. */ +export const getIncidentAssistantSuggestions = async (callId: number, signal?: AbortSignal) => { + const response = await api.get(`${CHATBOT}/IncidentSuggestions`, { params: { callId }, signal }); + return response.data?.Data ?? null; +}; diff --git a/src/app/(app)/__tests__/init-session-generation.test.tsx b/src/app/(app)/__tests__/init-session-generation.test.tsx new file mode 100644 index 0000000..28589ee --- /dev/null +++ b/src/app/(app)/__tests__/init-session-generation.test.tsx @@ -0,0 +1,140 @@ +/** + * Signing out while app initialization is still awaiting must retire that run: a stale + * invocation may not mark the app initialized, connect the chat hub, or restart location + * tracking that the sign-out cleanup just stopped. + * + * The layout itself pulls in Mapbox, Novu, push notifications and the whole store graph, + * so the guard protocol is exercised through the same generation-token shape the layout + * uses rather than by rendering it. + */ +import { act, renderHook } from '@testing-library/react-native'; +import React from 'react'; + +interface Deferred { + promise: Promise; + resolve: () => void; +} + +function deferred(): Deferred { + let resolve: () => void = () => undefined; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** Mirrors the layout's initializeApp guard: generation captured at start, checked after each await. */ +function useInitGuard(gate: Deferred, effects: { connectHub: jest.Mock; startLocation: jest.Mock; markInitialized: jest.Mock }) { + const initGeneration = React.useRef(0); + const isInitializing = React.useRef(false); + + const initialize = React.useCallback(async () => { + if (isInitializing.current) return; + isInitializing.current = true; + const generation = (initGeneration.current += 1); + const isCurrentRun = () => initGeneration.current === generation; + + try { + await gate.promise; + if (!isCurrentRun()) return; + + effects.connectHub(); + if (!isCurrentRun()) return; + + effects.markInitialized(); + if (!isCurrentRun()) return; + + effects.startLocation(); + } finally { + if (isCurrentRun()) { + isInitializing.current = false; + } + } + }, [gate, effects]); + + const signOut = React.useCallback(() => { + initGeneration.current += 1; + isInitializing.current = false; + }, []); + + return { initialize, signOut, isInitializing }; +} + +describe('app initialization session generation', () => { + const effects = { connectHub: jest.fn(), startLocation: jest.fn(), markInitialized: jest.fn() }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('abandons an in-flight run when the session ends mid-initialization', async () => { + const gate = deferred(); + const { result } = renderHook(() => useInitGuard(gate, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + + // Sign-out lands while initialization is still awaiting its first step. + act(() => { + result.current.signOut(); + }); + + await act(async () => { + gate.resolve(); + await pending; + }); + + expect(effects.connectHub).not.toHaveBeenCalled(); + expect(effects.markInitialized).not.toHaveBeenCalled(); + expect(effects.startLocation).not.toHaveBeenCalled(); + }); + + it('completes normally when the session survives', async () => { + const gate = deferred(); + const { result } = renderHook(() => useInitGuard(gate, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + + await act(async () => { + gate.resolve(); + await pending; + }); + + expect(effects.connectHub).toHaveBeenCalledTimes(1); + expect(effects.markInitialized).toHaveBeenCalledTimes(1); + expect(effects.startLocation).toHaveBeenCalledTimes(1); + }); + + it('frees the in-progress guard so the next sign-in can initialize', async () => { + const first = deferred(); + const { result } = renderHook(() => useInitGuard(first, effects)); + + let pending: Promise = Promise.resolve(); + act(() => { + pending = result.current.initialize(); + }); + act(() => { + result.current.signOut(); + }); + + // The new session starts before the retired run has settled. + let second: Promise = Promise.resolve(); + act(() => { + second = result.current.initialize(); + }); + + await act(async () => { + first.resolve(); + await Promise.all([pending, second]); + }); + + // Exactly one run reached the effects: the current one. + expect(effects.markInitialized).toHaveBeenCalledTimes(1); + expect(effects.startLocation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 91d3114..f67eeba 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -87,6 +87,10 @@ export default function TabLayout() { // Refs to track initialization state const hasInitialized = useRef(false); const isInitializing = useRef(false); + // Bumped on every initialization start and whenever the session ends. An in-flight run + // compares its captured value after each await, so a run belonging to a session that is + // over can no longer connect hubs or mark the app initialized. + const initGeneration = useRef(0); const hasHiddenSplash = useRef(false); const parentRef = useRef(null); @@ -155,6 +159,8 @@ export default function TabLayout() { } isInitializing.current = true; + const generation = (initGeneration.current += 1); + const isCurrentRun = () => initGeneration.current === generation; logger.info({ message: 'Starting app initialization', context: { @@ -171,9 +177,13 @@ export default function TabLayout() { await securityStore.getState().getRights(); await featureFlagsStore.getState().fetchFlags(); + if (!isCurrentRun()) return; + await useSignalRStore.getState().connectUpdateHub(); await useSignalRStore.getState().connectGeolocationHub(); + if (!isCurrentRun()) return; + // Connect the realtime chat hub (best-effort; chat may be disabled per department) if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { try { @@ -192,6 +202,8 @@ export default function TabLayout() { .syncFromServer() .catch(() => {}); + if (!isCurrentRun()) return; + hasInitialized.current = true; // Initialize Bluetooth and Audio services (native-only) @@ -208,12 +220,20 @@ export default function TabLayout() { message: 'Failed to initialize app', context: { error }, }); + // A run whose session already ended must not burn the retry budget or clobber + // state a newer run has since established. + if (!isCurrentRun()) return; + // Reset initialization state on error so it can be retried hasInitialized.current = false; setInitRetryCount((c) => c + 1); } finally { - isInitializing.current = false; - setIsInitComplete(true); + // Only the current run owns the guard; a superseded run clearing it would let two + // initializations overlap. + if (isCurrentRun()) { + isInitializing.current = false; + setIsInitComplete(true); + } } }, [status]); @@ -261,7 +281,15 @@ export default function TabLayout() { // Handle app initialization - simplified logic const MAX_INIT_RETRIES = 3; useEffect(() => { - if (status !== 'signedIn' && initRetryCount > 0) { + if (status === 'signedIn') return; + + // Leaving the signed-in state retires any initialization still in flight, and frees + // the guard it no longer owns so the next sign-in is not skipped as "already + // initializing". + initGeneration.current += 1; + isInitializing.current = false; + + if (initRetryCount > 0) { setInitRetryCount(0); } }, [status, initRetryCount]); diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 9e0054a..3882d46 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -8,9 +8,7 @@ import { copyToClipboard } from '@/components/chat/chat-utils'; import { MessageActionsSheet } from '@/components/chat/message-actions-sheet'; import { MessageBubble } from '@/components/chat/message-bubble'; import { TypingDots } from '@/components/chat/typing-indicator'; -import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Box } from '@/components/ui/box'; -import { Button, ButtonText } from '@/components/ui/button'; import { Center } from '@/components/ui/center'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; @@ -19,7 +17,6 @@ import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; import { Pressable } from '@/components/ui/pressable'; import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; -import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; @@ -37,8 +34,6 @@ export default function ChatbotScreen() { const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; const [text, setText] = useState(''); const [actionsMessage, setActionsMessage] = useState(null); - const [editMessage, setEditMessage] = useState(null); - const [editText, setEditText] = useState(''); const chatStatus = useChatSystemStatus(); const isChatEnabled = chatStatus === 'enabled'; @@ -146,7 +141,7 @@ export default function ChatbotScreen() { - {/* Restricted actions for assistant messages: copy, edit own, pin (moderator), flag. */} + {/* Restricted actions for assistant messages: copy, pin (moderator), flag. */} { - setEditMessage(m); - setEditText(m.Body ?? ''); - }} + onEdit={() => undefined} onDelete={() => undefined} onFlag={(m, reason) => useChatStore.getState().flagMessage(m.ChatMessageId, reason)} onTogglePin={(m, pinned) => chatbotChannelId && useChatStore.getState().togglePin(m.ChatMessageId, chatbotChannelId, pinned)} onModeratorDelete={() => undefined} /> - - {/* Edit own message */} - setEditMessage(null)}> - - - - - - - {t('chat.edit_message')} - - - - - ); } diff --git a/src/app/(app)/command.tsx b/src/app/(app)/command.tsx index 36eeb14..2160b7b 100644 --- a/src/app/(app)/command.tsx +++ b/src/app/(app)/command.tsx @@ -1,5 +1,5 @@ import { router } from 'expo-router'; -import { ClipboardList, CloudOff, ExternalLink, Image as ImageIcon, Info, MapPin, Paperclip, Pencil, RefreshCw, StickyNote, Trash2, UserCog, Video as VideoIcon, XCircle } from 'lucide-react-native'; +import { ClipboardList, CloudOff, ExternalLink, Image as ImageIcon, Info, MapPin, Paperclip, Pencil, RefreshCw, Sparkles, StickyNote, Trash2, UserCog, Video as VideoIcon, XCircle } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ScrollView, useWindowDimensions } from 'react-native'; @@ -13,6 +13,7 @@ import { AddAssignmentSheet } from '@/components/command/add-assignment-sheet'; import { AddLaneSheet } from '@/components/command/add-lane-sheet'; import { AddResourceSheet } from '@/components/command/add-resource-sheet'; import { type AssignableResourceOption, AssignResourceSheet } from '@/components/command/assign-resource-sheet'; +import { IncidentAssistantSheet } from '@/components/command/assistant-sheet'; import { CommandDetailsSheet } from '@/components/command/command-details-sheet'; import { CommandSection } from '@/components/command/command-section'; import { IncidentFilesSection } from '@/components/command/incident-files-section'; @@ -141,6 +142,7 @@ export default function CommandBoard() { const [resourceFilter, setResourceFilter] = useState<'all' | 'unassigned' | 'assigned'>('all'); const [isCommandDetailsOpen, setIsCommandDetailsOpen] = useState(false); const [isEndConfirmOpen, setIsEndConfirmOpen] = useState(false); + const [isAssistantOpen, setIsAssistantOpen] = useState(false); /** Which call-resource viewer (from the underlying call) is open on top of the board. */ const [callResourceModal, setCallResourceModal] = useState<'notes' | 'images' | 'files' | 'video' | null>(null); @@ -204,6 +206,8 @@ export default function CommandBoard() { } }, [activeBoardCallId]); + const handleOpenAssistant = useCallback(() => setIsAssistantOpen(true), []); + const handleCloseAssistant = useCallback(() => setIsAssistantOpen(false), []); const handleOpenCommandDetails = useCallback(() => setIsCommandDetailsOpen(true), []); const handleOpenTransfer = useCallback(() => setIsTransferSheetOpen(true), []); const handleOpenEndConfirm = useCallback(() => setIsEndConfirmOpen(true), []); @@ -597,6 +601,10 @@ export default function CommandBoard() { + {/* Assistant: answers board questions on-device first, so it stays useful with no signal */} + {/* Icon-only by design; a confirmation dialog guards against accidental taps. */} + ) : null} + + + + {/* One-tap prompts, chosen for this incident's ICS type. The label is localized; the question + sent to the matcher stays canonical English, matching the backend classifier. */} + {suggestions.length > 0 ? ( + + + {suggestions.map((suggestion) => ( + submit(suggestion.question)} + testID={`incident-assistant-suggestion-${suggestion.question}`} + > + {t(suggestion.labelKey)} + + ))} + + + ) : null} + + {!messages || messages.length === 0 ? ( + + + {t('incident_assistant.empty')} + + ) : ( + + {messages.map((entry) => + entry.role === 'user' ? ( + + {entry.text} + + ) : ( + + {entry.text} + {entry.source ? ( + + + {entry.source === 'device' ? t('incident_assistant.source_device') : t('incident_assistant.source_server')} + + ) : null} + + ) + )} + + )} + + {isAsking ? ( + + + {t('incident_assistant.thinking')} + + ) : null} + + + + + + + + + + + + + {isOffline ? ( + + + {t('incident_assistant.offline_hint')} + + ) : null} + + + ); +}; + +export default IncidentAssistantSheet; diff --git a/src/models/v4/chat/chatbotModels.ts b/src/models/v4/chat/chatbotModels.ts index 61daa56..ba78c0a 100644 --- a/src/models/v4/chat/chatbotModels.ts +++ b/src/models/v4/chat/chatbotModels.ts @@ -27,3 +27,33 @@ export interface ChatbotSendResponse { export interface ChatbotSessionResponse { Success: boolean; } + +/** + * Answer to a command-board question. Unlike the chat endpoints (queued, reply arrives over + * SignalR), the incident assistant answers in the same round-trip so the board can render it in + * place — a commander asking for a PAR shouldn't be waiting on a channel hop. + */ +export interface IncidentAssistantAnswerData { + Answer: string; + /** Intent the server classified the question as ("IncidentPar", "Unknown", ...). */ + Intent?: string | null; + Confidence?: number; + /** False when the assistant couldn't answer (unresolved incident, no permission, rate limited). */ + Processed: boolean; +} + +export interface IncidentAssistantAnswerResponse { + Data?: IncidentAssistantAnswerData | null; +} + +/** Suggested questions for an incident, tailored to its inferred ICS type. */ +export interface IncidentAssistantSuggestionsData { + IncidentType: string; + /** Matches the app's own playbook ids in `services/incident-assistant/ics-playbooks`. */ + IncidentTypeKey: string; + Questions: string[]; +} + +export interface IncidentAssistantSuggestionsResponse { + Data?: IncidentAssistantSuggestionsData | null; +} diff --git a/src/services/incident-assistant/__tests__/answerers.test.ts b/src/services/incident-assistant/__tests__/answerers.test.ts new file mode 100644 index 0000000..df6db39 --- /dev/null +++ b/src/services/incident-assistant/__tests__/answerers.test.ts @@ -0,0 +1,299 @@ +import { type TFunction } from 'i18next'; + +import { IncidentNeedCategory, IncidentNeedStatus, IncidentRoleType, ResourceAssignmentKind, TacticalObjectiveStatus } from '@/models/v4/incidentCommand/incidentCommandModels'; +import en from '@/translations/en.json'; + +import { answerChecklist, answerNeeds, answerObjectives, answerPar, answerResources, answerRoles, answerSpanOfControl, answerStatus, answerTimeline, type IncidentAnswerContext } from '../answerers'; + +/** + * Renders against the real en.json so the tests double as a check that every key the answers use + * actually exists — a missing key falls through to the key name and fails the assertion. + */ +const t = ((key: string, options?: Record): string => { + const value = key.split('.').reduce((node, part) => (node && typeof node === 'object' ? (node as Record)[part] : undefined), en); + if (typeof value !== 'string') { + return key; + } + return value.replace(/{{(\w+)}}/g, (_match, name: string) => String(options?.[name] ?? '')); +}) as unknown as TFunction; + +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString(); + +const buildContext = (overrides: Partial = {}): IncidentAnswerContext => ({ + board: { + Command: { + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + EstablishedByUserId: 'user-1', + EstablishedOn: minutesAgo(35), + CurrentCommanderUserId: 'user-1', + Name: null, + CommandPostLocationText: 'Alpha side, Main St', + StagingLocationText: null, + IcsLevel: 1, + Status: 0, + }, + Nodes: [ + { CommandStructureNodeId: 'node-1', IncidentCommandId: 'cmd-1', DepartmentId: 1, CallId: 42, NodeType: 0, Name: 'Division A', SortOrder: 0, PrimaryLeadUserId: 'user-2', MaxUnits: 0, MinUnits: 0 }, + { CommandStructureNodeId: 'node-2', IncidentCommandId: 'cmd-1', DepartmentId: 1, CallId: 42, NodeType: 1, Name: 'Search Group', SortOrder: 1, MaxUnits: 0, MinUnits: 0 }, + ], + Assignments: [ + { + ResourceAssignmentId: 'a-1', + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + CommandStructureNodeId: 'node-1', + ResourceKind: ResourceAssignmentKind.RealUnit, + ResourceId: 'unit-1', + AssignedByUserId: 'user-1', + AssignedOn: minutesAgo(20), + RequirementsWarning: false, + }, + { + ResourceAssignmentId: 'a-2', + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + CommandStructureNodeId: '', + ResourceKind: ResourceAssignmentKind.RealPersonnel, + ResourceId: 'user-3', + AssignedByUserId: 'user-1', + AssignedOn: minutesAgo(5), + RequirementsWarning: false, + }, + ], + Objectives: [ + { + TacticalObjectiveId: 'obj-1', + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + Name: 'Primary search all clear', + ObjectiveType: 1, + Status: TacticalObjectiveStatus.Complete, + AutoPopulated: false, + ProgressPercent: 100, + Priority: 0, + SortOrder: 0, + }, + { + TacticalObjectiveId: 'obj-2', + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + Name: 'Water supply established', + ObjectiveType: 1, + Status: TacticalObjectiveStatus.InProgress, + AutoPopulated: false, + ProgressPercent: 50, + Priority: 0, + SortOrder: 1, + }, + ], + Needs: [ + { + IncidentNeedId: 'need-1', + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + Name: 'Second alarm engine', + Category: IncidentNeedCategory.Resource, + Status: IncidentNeedStatus.Open, + QuantityRequested: 2, + QuantityFulfilled: 1, + Priority: 5, + CreatedOn: minutesAgo(12), + SortOrder: 0, + }, + ], + Timers: [], + Annotations: [], + Accountability: [ + { UserId: 'user-2', FullName: 'Dana Cross', NeedsCheckIn: false, MinutesRemaining: 12, Status: 'Green', DurationMinutes: 20, WarningThresholdMinutes: 5 }, + { UserId: 'user-3', FullName: 'Sam Ortiz', NeedsCheckIn: true, MinutesRemaining: -6, Status: 'Critical', DurationMinutes: 20, WarningThresholdMinutes: 5 }, + ], + Roles: [ + { + IncidentRoleAssignmentId: 'role-1', + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + UserId: 'user-2', + RoleType: IncidentRoleType.SafetyOfficer, + AssignedByUserId: 'user-1', + AssignedOn: minutesAgo(30), + }, + ], + Notes: [], + } as IncidentAnswerContext['board'], + adHocUnits: [], + adHocPersonnel: [], + timeline: [ + { CommandLogEntryId: 'log-1', IncidentCommandId: 'cmd-1', DepartmentId: 1, CallId: 42, EntryType: 0, Description: 'Command established', UserId: 'user-1', OccurredOn: minutesAgo(35) }, + { CommandLogEntryId: 'log-2', IncidentCommandId: 'cmd-1', DepartmentId: 1, CallId: 42, EntryType: 5, Description: 'Engine 1 assigned to Division A', UserId: 'user-1', OccurredOn: minutesAgo(2) }, + ], + callName: 'Structure fire', + callNumber: '26-1', + callAddress: '123 Main St', + callType: 'Structure Fire', + callNature: 'Smoke showing from the second floor', + resolveUserName: (userId) => ({ 'user-1': 'Alex Reed', 'user-2': 'Dana Cross', 'user-3': 'Sam Ortiz' })[userId] ?? userId, + resolveUnitName: (unitId) => ({ 'unit-1': 'Engine 1' })[unitId] ?? unitId, + ...overrides, +}); + +describe('incident assistant answers', () => { + it('reports accountability with the overdue member named', () => { + const answer = answerPar(buildContext(), t); + + expect(answer).toContain('PAR for Structure fire (26-1)'); + expect(answer).toContain('Overdue'); + expect(answer).toContain('Sam Ortiz'); + expect(answer).toContain('6 min overdue'); + }); + + it('says plainly when nothing is being tracked rather than implying everyone is fine', () => { + const context = buildContext(); + context.board!.Accountability = []; + + expect(answerPar(context, t)).toContain('No personnel accountability is being tracked'); + }); + + it('summarizes resources by lane and flags the unassigned pool', () => { + const answer = answerResources(buildContext(), t); + + expect(answer).toContain('1 units and 1 personnel working'); + expect(answer).toContain('Division A'); + expect(answer).toContain('Engine 1'); + expect(answer).toContain('Unassigned pool: 1'); + }); + + it('answers a lane-scoped question with the lane lead and time in lane', () => { + const answer = answerResources(buildContext(), t, 'Division A'); + + expect(answer).toContain('Division A'); + expect(answer).toContain('Dana Cross'); + expect(answer).toContain('Engine 1'); + expect(answer).toContain('in lane'); + }); + + it('refuses to answer about a different lane of the same type when the one asked for is missing', () => { + const answer = answerResources(buildContext(), t, 'Division Z'); + + expect(answer).toContain("I don't see a lane called"); + expect(answer).toContain('Division Z'); + // It lists what does exist rather than silently reporting Division A's crews. + expect(answer).toContain('Division A, Search Group'); + expect(answer).not.toContain('Engine 1'); + }); + + it('still resolves a bare ICS type with no designator', () => { + const context = buildContext(); + context.board!.Nodes = [{ CommandStructureNodeId: 'node-9', IncidentCommandId: 'cmd-1', DepartmentId: 1, CallId: 42, NodeType: 6, Name: 'Level 1 Stage', SortOrder: 0 }]; + context.board!.Assignments = []; + + expect(answerResources(context, t, 'staging')).toContain('Level 1 Stage'); + }); + + it('passes span of control when every lane is inside its limits', () => { + expect(answerSpanOfControl(buildContext(), t)).toContain('Span of control looks reasonable'); + }); + + it('flags a lane over the NIMS ceiling and a lane with no lead', () => { + const context = buildContext(); + context.board!.Assignments = Array.from({ length: 9 }, (_unused, index) => ({ + ResourceAssignmentId: `a-${index}`, + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + CommandStructureNodeId: 'node-2', + ResourceKind: ResourceAssignmentKind.RealUnit, + ResourceId: `unit-${index}`, + AssignedByUserId: 'user-1', + AssignedOn: minutesAgo(10), + RequirementsWarning: false, + })); + + const answer = answerSpanOfControl(context, t); + expect(answer).toContain('Search Group is carrying 9 resources'); + expect(answer).toContain('No lead assigned: Search Group'); + }); + + it('lists open objectives and the doctrine benchmarks not yet on the board', () => { + const answer = answerObjectives(buildContext(), t); + + expect(answer).toContain('1 of 2 objectives complete'); + expect(answer).toContain('Water supply established'); + // The structure-fire playbook is inferred from the call, and its benchmarks are checked against + // the board — "360 complete" isn't there, so it should be surfaced. + expect(answer).toContain('Structure fire benchmarks not on the board yet'); + expect(answer).toContain('360 complete'); + // "Primary search all clear" IS on the board and must not be reported missing. + expect(answer).not.toContain('Primary search all clear:'); + }); + + it('reports outstanding needs with their fill quantity', () => { + const answer = answerNeeds(buildContext(), t); + + expect(answer).toContain('1 needs outstanding'); + expect(answer).toContain('Second alarm engine'); + expect(answer).toContain('1/2'); + }); + + it('answers a specific ICS position lookup', () => { + expect(answerRoles(buildContext(), t, 'safety officer')).toContain('Dana Cross'); + expect(answerRoles(buildContext(), t, 'staging area manager')).toContain('No Staging Area Manager is assigned'); + }); + + it('answers a RIT question from the structure rather than the role list', () => { + expect(answerRoles(buildContext(), t, 'rit')).toContain("I don't see a RIT/RIC lane"); + + const context = buildContext(); + context.board!.Nodes = [...context.board!.Nodes, { CommandStructureNodeId: 'node-3', IncidentCommandId: 'cmd-1', DepartmentId: 1, CallId: 42, NodeType: 1, Name: 'RIT', SortOrder: 2 }]; + expect(answerRoles(context, t, 'rit')).toContain('RIT is standing by'); + }); + + it('lists the positions this incident type still needs filled', () => { + const answer = answerRoles(buildContext(), t); + + expect(answer).toContain('Incident Commander: Alex Reed'); + expect(answer).toContain('Unfilled positions a Structure fire usually needs'); + }); + + it('reads the incident log for a time window', () => { + const answer = answerTimeline(buildContext(), t, 10); + + expect(answer).toContain('Engine 1 assigned to Division A'); + expect(answer).not.toContain('Command established'); + }); + + it('says nothing was logged in a window rather than showing older entries', () => { + expect(answerTimeline(buildContext(), t, 1)).toContain('Nothing has been logged in the last 1 minutes'); + }); + + it('ticks the checklist items the board proves and prompts for the rest', () => { + const answer = answerChecklist(buildContext(), t); + + expect(answer).toContain('Structure fire checklist'); + expect(answer).toContain('Safety Officer assigned'); + expect(answer).toContain('Not showing on the board yet'); + expect(answer).toContain('Staging designated'); + // Guidance, never an order. + expect(answer).toContain('not your department'); + }); + + it('honours an explicitly named incident type over the inferred one', () => { + expect(answerChecklist(buildContext(), t, 'mci')).toContain('Mass casualty incident checklist'); + }); + + it('gives a status snapshot with elapsed time and counts', () => { + const answer = answerStatus(buildContext(), t); + + expect(answer).toContain('123 Main St'); + expect(answer).toContain('Command running 35m'); + expect(answer).toContain('IC: Alex Reed'); + expect(answer).toContain('PAR: 2 tracked, 1 overdue'); + }); +}); diff --git a/src/services/incident-assistant/__tests__/intent-matcher.test.ts b/src/services/incident-assistant/__tests__/intent-matcher.test.ts new file mode 100644 index 0000000..90d5f87 --- /dev/null +++ b/src/services/incident-assistant/__tests__/intent-matcher.test.ts @@ -0,0 +1,77 @@ +import { matchIncidentIntent } from '../intent-matcher'; + +describe('matchIncidentIntent', () => { + it('recognizes the shorthand an IC actually uses for a PAR', () => { + ['PAR', 'par check', 'accountability', 'give me a PAR', 'run a par check', "who's overdue?"].forEach((question) => { + const match = matchIncidentIntent(question); + expect(match.intent).toBe('par'); + expect(match.confidence).toBe(1); + }); + }); + + it('scopes a lane question to the lane that was named', () => { + expect(matchIncidentIntent('who is working Division A')).toMatchObject({ intent: 'resources', params: { laneName: 'Division A' } }); + expect(matchIncidentIntent("what's in staging?")).toMatchObject({ intent: 'resources', params: { laneName: 'staging' } }); + }); + + it('treats an unassigned question as the resource pool', () => { + expect(matchIncidentIntent('who is unassigned')).toMatchObject({ intent: 'resources', params: { laneName: 'unassigned' } }); + }); + + it('separates span of control from a general resource question', () => { + expect(matchIncidentIntent('span of control').intent).toBe('span_of_control'); + expect(matchIncidentIntent('which lanes are over staffed').intent).toBe('span_of_control'); + expect(matchIncidentIntent('what resources do I have on scene').intent).toBe('resources'); + }); + + it('resolves ICS position questions and leaves ordinary name lookups alone', () => { + expect(matchIncidentIntent('who is the safety officer')).toMatchObject({ intent: 'roles', params: { roleQuery: 'safety officer' } }); + expect(matchIncidentIntent('do we have a staging area manager?')).toMatchObject({ intent: 'roles', params: { roleQuery: 'staging area manager' } }); + // A person's name is not an ICS position, so this must not be captured as a role query. + expect(matchIncidentIntent('who is Jordan Rivera').intent).not.toBe('roles'); + }); + + it('normalizes a timeline window to minutes', () => { + expect(matchIncidentIntent('what happened in the last 30 minutes')).toMatchObject({ intent: 'timeline', params: { minutes: 30 } }); + expect(matchIncidentIntent('what happened in the last 2 hours')).toMatchObject({ intent: 'timeline', params: { minutes: 120 } }); + expect(matchIncidentIntent('show me the last 5 log entries')).toMatchObject({ intent: 'timeline', params: { count: 5 } }); + }); + + it('picks up objectives, needs, timers, notes, briefing and checklist questions', () => { + expect(matchIncidentIntent('what objectives are still open').intent).toBe('objectives'); + expect(matchIncidentIntent('what needs are open').intent).toBe('needs'); + expect(matchIncidentIntent('what am I waiting on').intent).toBe('needs'); + expect(matchIncidentIntent('what timers are running').intent).toBe('timers'); + expect(matchIncidentIntent('incident notes').intent).toBe('notes'); + expect(matchIncidentIntent('give me a transfer of command briefing').intent).toBe('briefing'); + expect(matchIncidentIntent('what am I missing').intent).toBe('checklist'); + expect(matchIncidentIntent('checklist for a structure fire')).toMatchObject({ intent: 'checklist', params: { incidentType: 'structure fire' } }); + }); + + it('routes weather questions out to the server, which is the only side with live conditions', () => { + expect(matchIncidentIntent('what is the wind doing').intent).toBe('weather'); + expect(matchIncidentIntent('weather at the scene').intent).toBe('weather'); + }); + + it('falls back to status for a general "how are we doing" question', () => { + expect(matchIncidentIntent('incident status').intent).toBe('status'); + expect(matchIncidentIntent('size-up').intent).toBe('status'); + expect(matchIncidentIntent('where do we stand').intent).toBe('status'); + }); + + it('tolerates trailing punctuation without losing the parameter', () => { + expect(matchIncidentIntent('who is working Division B?')).toMatchObject({ intent: 'resources', params: { laneName: 'Division B' } }); + }); + + it('returns unknown for something outside the incident domain', () => { + const match = matchIncidentIntent('what is the airspeed velocity of an unladen swallow'); + expect(match.intent).toBe('unknown'); + expect(match.confidence).toBe(0); + }); + + it('scores a keyword-only phrasing below an anchored match so callers can prefer the server', () => { + const match = matchIncidentIntent('can you get me an accountability rundown for the crews'); + expect(match.intent).toBe('par'); + expect(match.confidence).toBeLessThan(1); + }); +}); diff --git a/src/services/incident-assistant/__tests__/role-vocabulary.test.ts b/src/services/incident-assistant/__tests__/role-vocabulary.test.ts new file mode 100644 index 0000000..de2eff1 --- /dev/null +++ b/src/services/incident-assistant/__tests__/role-vocabulary.test.ts @@ -0,0 +1,52 @@ +import { IncidentRoleType } from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { isRapidInterventionQuery, resolveIncidentRole } from '../role-vocabulary'; + +describe('resolveIncidentRole', () => { + it.each([ + ['safety', IncidentRoleType.SafetyOfficer], + ['safety officer', IncidentRoleType.SafetyOfficer], + ['ops', IncidentRoleType.OperationsSectionChief], + ['operations section chief', IncidentRoleType.OperationsSectionChief], + ['staging manager', IncidentRoleType.StagingAreaManager], + ['pio', IncidentRoleType.PublicInformationOfficer], + ['ic', IncidentRoleType.IncidentCommander], + ['decon', IncidentRoleType.DeconOfficer], + ])('maps the radio shorthand %s', (text, expected) => { + expect(resolveIncidentRole(text)).toBe(expected); + }); + + it.each([ + // Each of these contains a shorter alias, so they only resolve correctly when the table is + // matched longest-first rather than in declaration order. + ['air ops', IncidentRoleType.AirOperationsBranchDirector], + ['air operations', IncidentRoleType.AirOperationsBranchDirector], + ['medical branch director', IncidentRoleType.MedicalBranchDirector], + ['search group supervisor', IncidentRoleType.SearchGroupSupervisor], + ['hazmat group supervisor', IncidentRoleType.HazMatGroupSupervisor], + ['deputy incident commander', IncidentRoleType.DeputyIncidentCommander], + ])('prefers the longer position for %s', (text, expected) => { + expect(resolveIncidentRole(text)).toBe(expected); + }); + + it('does not match an alias inside another word', () => { + // "ic" must not match inside "medic"; a person's name is not a position. + expect(resolveIncidentRole('medic')).toBeNull(); + expect(resolveIncidentRole('Jordan Rivera')).toBeNull(); + expect(resolveIncidentRole('')).toBeNull(); + expect(resolveIncidentRole(null)).toBeNull(); + }); +}); + +describe('isRapidInterventionQuery', () => { + it.each(['rit', 'ric', 'rapid intervention team', 'rapid intervention crew'])('recognizes %s as a lane question, not a position', (text) => { + expect(isRapidInterventionQuery(text)).toBe(true); + // RIT/RIC has no IncidentRoleType — it must not resolve to a command position. + expect(resolveIncidentRole(text)).toBeNull(); + }); + + it('is false for anything else', () => { + expect(isRapidInterventionQuery('safety officer')).toBe(false); + expect(isRapidInterventionQuery(undefined)).toBe(false); + }); +}); diff --git a/src/services/incident-assistant/answerers.ts b/src/services/incident-assistant/answerers.ts new file mode 100644 index 0000000..ef9f99c --- /dev/null +++ b/src/services/incident-assistant/answerers.ts @@ -0,0 +1,652 @@ +/** + * On-device answers for command-board questions, computed from the board already cached on the + * phone. Every function here is pure: board data in, display text out. No network, no model, no + * device capability required — which is the point. When a scene loses coverage the commander can + * still ask for a PAR, a resource picture, or what's outstanding and get an answer off the last + * synced board rather than a spinner. + * + * Mirrors the reporting logic in Core's `IncidentBoardNarrator` so the wording matches whichever + * side answered. + */ + +import { type TFunction } from 'i18next'; + +import { getCommandNodeTypeName, getIncidentRoleName, getNeedCategoryName } from '@/lib/incident-command-utils'; +import { IncidentTimerStatus } from '@/models/v4/incidentCommand/incidentCommandEnums'; +import { + type CommandLogEntry, + CommandNodeType, + type CommandStructureNode, + type IncidentAdHocPersonnel, + type IncidentAdHocUnit, + type IncidentCommandBoard, + type IncidentNeed, + IncidentNeedStatus, + type IncidentNote, + IncidentRoleType, + type IncidentTimer, + type PersonnelCallCheckInStatus, + type ResourceAssignment, + ResourceAssignmentKind, + type TacticalObjective, + TacticalObjectiveStatus, +} from '@/models/v4/incidentCommand/incidentCommandModels'; + +import { checklistFor, type IncidentPlaybook, inferPlaybook, keyRolesFor, resolvePlaybook } from './ics-playbooks'; +import { isRapidInterventionQuery, resolveIncidentRole } from './role-vocabulary'; + +/** Everything the on-device answers can read. All of it is MMKV-persisted, so all of it is offline-safe. */ +export interface IncidentAnswerContext { + board: IncidentCommandBoard | null; + adHocUnits: IncidentAdHocUnit[]; + adHocPersonnel: IncidentAdHocPersonnel[]; + /** Incident log entries already pulled for this board (newest first). */ + timeline: CommandLogEntry[]; + callName?: string | null; + callNumber?: string | null; + callAddress?: string | null; + callType?: string | null; + callNature?: string | null; + /** Resolves a Resgrid user id to a display name (falls back to the id). */ + resolveUserName: (userId: string) => string; + /** Resolves a Resgrid unit id to its name (falls back to the id). */ + resolveUnitName: (unitId: string) => string; +} + +/** NIMS guidance: a supervisor should manage between three and seven resources. */ +const SPAN_OF_CONTROL_CEILING = 7; + +/** Cap on any single list so an answer stays readable on a phone at 2am. */ +const MAX_LIST_ITEMS = 25; + +const DEFAULT_TIMELINE_ENTRIES = 10; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +export const incidentLabel = (context: IncidentAnswerContext, t: TFunction): string => { + const name = context.board?.Command?.Name || context.callName || t('incident_assistant.this_incident'); + return context.callNumber ? `${name} (${context.callNumber})` : name; +}; + +/** Radio-friendly duration: "1h 12m" / "23m" / "45s". */ +export const formatDuration = (milliseconds: number): string => { + const total = Math.abs(milliseconds); + const seconds = Math.floor(total / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m`; + } + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +}; + +const elapsedSince = (iso?: string | null): number => { + if (!iso) { + return 0; + } + const parsed = new Date(iso).getTime(); + return Number.isNaN(parsed) ? 0 : Date.now() - parsed; +}; + +const liveNodes = (board: IncidentCommandBoard): CommandStructureNode[] => (board.Nodes ?? []).filter((n) => !n.DeletedOn).sort((a, b) => a.SortOrder - b.SortOrder); + +const liveAssignments = (board: IncidentCommandBoard): ResourceAssignment[] => (board.Assignments ?? []).filter((a) => !a.ReleasedOn); + +const isUnitKind = (kind: number): boolean => kind === ResourceAssignmentKind.RealUnit || kind === ResourceAssignmentKind.LinkedDeptUnit || kind === ResourceAssignmentKind.AdHocUnit; + +const isCriticalPar = (row: PersonnelCallCheckInStatus): boolean => row.Status === 'Critical' || row.NeedsCheckIn; + +const isWarningPar = (row: PersonnelCallCheckInStatus): boolean => !isCriticalPar(row) && row.Status === 'Warning'; + +const parBuckets = (board: IncidentCommandBoard) => { + const rows = board.Accountability ?? []; + return { total: rows.length, warning: rows.filter(isWarningPar).length, critical: rows.filter(isCriticalPar).length }; +}; + +const truncate = (value: string | null | undefined, max: number): string => { + const text = (value ?? '').trim(); + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +}; + +/** Playbook for the incident, from an explicit override or inferred from the call. */ +export const playbookFor = (context: IncidentAnswerContext, override?: string | null): IncidentPlaybook => + resolvePlaybook(override) ?? inferPlaybook([context.board?.Command?.Name, context.callType, context.callName, context.callNature]); + +const resourceLabel = (assignment: ResourceAssignment, context: IncidentAnswerContext): string => { + if (assignment.ResourceKind === ResourceAssignmentKind.RealUnit || assignment.ResourceKind === ResourceAssignmentKind.LinkedDeptUnit) { + return context.resolveUnitName(assignment.ResourceId); + } + if (assignment.ResourceKind === ResourceAssignmentKind.RealPersonnel || assignment.ResourceKind === ResourceAssignmentKind.LinkedDeptPersonnel) { + return context.resolveUserName(assignment.ResourceId); + } + if (assignment.ResourceKind === ResourceAssignmentKind.AdHocUnit) { + return context.adHocUnits.find((u) => u.IncidentAdHocUnitId === assignment.ResourceId)?.Name ?? assignment.ResourceId; + } + return context.adHocPersonnel.find((p) => p.IncidentAdHocPersonnelId === assignment.ResourceId)?.Name ?? assignment.ResourceId; +}; + +const laneLead = (node: CommandStructureNode, context: IncidentAnswerContext): string | null => { + if (node.PrimaryLeadUserId) { + return context.resolveUserName(node.PrimaryLeadUserId); + } + if (node.PrimaryLeadName) { + return node.PrimaryLeadName; + } + return node.SupervisorUserId ? context.resolveUserName(node.SupervisorUserId) : null; +}; + +/** Exact lane name first, then containment either way, then a unique ICS node-type match. */ +const matchNode = (nodes: CommandStructureNode[], laneName: string, t: TFunction): CommandStructureNode | null => { + const needle = laneName.trim().toLowerCase(); + if (!needle) { + return null; + } + + const exact = nodes.find((n) => (n.Name ?? '').toLowerCase() === needle); + if (exact) { + return exact; + } + + const contains = nodes.find((n) => { + const name = (n.Name ?? '').toLowerCase(); + return name.length > 0 && (name.includes(needle) || needle.includes(name)); + }); + if (contains) { + return contains; + } + + // Last resort: the commander named an ICS type with no designator ("who's in staging"). The needle + // must be a substring OF the type word, never the other way round — "Division Z" must NOT resolve + // to Division A just because both are Divisions. Answering about the wrong lane on a fireground is + // worse than saying the lane isn't there. + const byType = nodes.filter((n) => { + const typeName = getCommandNodeTypeName(t, n.NodeType).toLowerCase(); + return typeName.length > 0 && typeName.includes(needle); + }); + + return byType.length === 1 ? byType[0] : null; +}; + +/** + * Loose benchmark matching so "Primary all clear" still counts as the "Primary search all clear" + * benchmark rather than being reported missing on a technicality. + */ +const looseMatch = (objectiveName: string | null | undefined, benchmark: string): boolean => { + const a = (objectiveName ?? '').toLowerCase(); + const b = benchmark.toLowerCase(); + if (!a || !b) { + return false; + } + if (a.includes(b) || b.includes(a)) { + return true; + } + + const words = b.split(/\s+/).filter((w) => w.length > 3); + if (words.length === 0) { + return false; + } + + const hits = words.filter((w) => a.includes(w)).length; + return hits >= Math.max(1, words.length - 1); +}; + +const formatObjective = (objective: TacticalObjective, t: TFunction): string => { + const status = + objective.Status === TacticalObjectiveStatus.Complete + ? t('incident_assistant.objective_complete') + : objective.Status === TacticalObjectiveStatus.InProgress + ? t('incident_assistant.objective_in_progress') + : t('incident_assistant.objective_pending'); + + const overdue = objective.TargetCompleteOn && objective.Status !== TacticalObjectiveStatus.Complete && new Date(objective.TargetCompleteOn).getTime() < Date.now(); + + return t('incident_assistant.objective_row', { + name: objective.Name, + status, + progress: objective.ProgressPercent, + overdue: overdue ? t('incident_assistant.objective_overdue') : '', + }); +}; + +const localTime = (iso?: string | null): string => { + if (!iso) { + return ''; + } + const parsed = new Date(iso); + return Number.isNaN(parsed.getTime()) ? '' : parsed.toLocaleTimeString(); +}; + +const join = (lines: (string | null | undefined)[]): string => lines.filter((line): line is string => typeof line === 'string' && line.length > 0).join('\n'); + +// --------------------------------------------------------------------------- +// Answers +// --------------------------------------------------------------------------- + +export const answerStatus = (context: IncidentAnswerContext, t: TFunction): string => { + const board = context.board!; + const command = board.Command; + const nodes = liveNodes(board); + const assignments = liveAssignments(board); + const units = assignments.filter((a) => isUnitKind(a.ResourceKind)).length; + const par = parBuckets(board); + const objectives = board.Objectives ?? []; + const openNeeds = (board.Needs ?? []).filter((n) => n.Status === IncidentNeedStatus.Open || n.Status === IncidentNeedStatus.PartiallyMet).length; + + return join([ + context.callAddress ? `${incidentLabel(context, t)} — ${context.callAddress}` : incidentLabel(context, t), + t('incident_assistant.elapsed', { duration: formatDuration(elapsedSince(command.EstablishedOn)) }), + command.CurrentCommanderUserId ? t('incident_assistant.commander', { name: context.resolveUserName(command.CurrentCommanderUserId) }) : null, + command.CommandPostLocationText ? t('incident_assistant.command_post', { location: command.CommandPostLocationText }) : null, + command.StagingLocationText ? t('incident_assistant.staging', { location: command.StagingLocationText }) : null, + t('incident_assistant.resource_counts', { units, personnel: assignments.length - units, lanes: nodes.length, unassigned: assignments.filter((a) => !a.CommandStructureNodeId).length }), + par.total > 0 ? t('incident_assistant.par_summary', { total: par.total, critical: par.critical, warning: par.warning }) : null, + objectives.length > 0 ? t('incident_assistant.objective_summary', { complete: objectives.filter((o) => o.Status === TacticalObjectiveStatus.Complete).length, total: objectives.length }) : null, + openNeeds > 0 ? t('incident_assistant.open_needs', { count: openNeeds }) : null, + command.ImportantInformation ? t('incident_assistant.important', { text: truncate(command.ImportantInformation, 240) }) : null, + command.EstimatedEndOn ? t('incident_assistant.estimated_end', { time: new Date(command.EstimatedEndOn).toLocaleString() }) : null, + ]); +}; + +export const answerPar = (context: IncidentAnswerContext, t: TFunction): string => { + const rows = context.board!.Accountability ?? []; + if (rows.length === 0) { + return t('incident_assistant.par_none', { incident: incidentLabel(context, t) }); + } + + const critical = rows.filter(isCriticalPar).sort((a, b) => a.MinutesRemaining - b.MinutesRemaining); + const warning = rows.filter(isWarningPar).sort((a, b) => a.MinutesRemaining - b.MinutesRemaining); + + return join([ + t('incident_assistant.par_header', { incident: incidentLabel(context, t), count: rows.length }), + t('incident_assistant.par_counts', { green: rows.length - critical.length - warning.length, warning: warning.length, critical: critical.length }), + critical.length > 0 ? t('incident_assistant.par_critical_header') : null, + ...critical.slice(0, MAX_LIST_ITEMS).map((row) => t('incident_assistant.par_overdue_row', { name: row.FullName || row.UserId, minutes: Math.abs(Math.round(row.MinutesRemaining)) })), + warning.length > 0 ? t('incident_assistant.par_warning_header') : null, + ...warning.slice(0, MAX_LIST_ITEMS).map((row) => t('incident_assistant.par_due_row', { name: row.FullName || row.UserId, minutes: Math.max(0, Math.round(row.MinutesRemaining)) })), + critical.length === 0 && warning.length === 0 ? t('incident_assistant.par_all_good') : null, + ]); +}; + +export const answerResources = (context: IncidentAnswerContext, t: TFunction, laneName?: string): string => { + const board = context.board!; + const nodes = liveNodes(board); + const assignments = liveAssignments(board); + + if (laneName && laneName.trim().toLowerCase() === 'unassigned') { + const pool = assignments.filter((a) => !a.CommandStructureNodeId); + if (pool.length === 0) { + return t('incident_assistant.no_unassigned', { incident: incidentLabel(context, t) }); + } + return join([t('incident_assistant.unassigned_header', { incident: incidentLabel(context, t), count: pool.length }), ...pool.slice(0, MAX_LIST_ITEMS).map((a) => `- ${resourceLabel(a, context)}`)]); + } + + if (laneName) { + const node = matchNode(nodes, laneName, t); + if (!node) { + return t('incident_assistant.lane_not_found', { + lane: laneName.trim(), + lanes: nodes.length === 0 ? t('incident_assistant.no_lanes') : nodes.map((n) => n.Name).join(', '), + }); + } + + const inLane = assignments.filter((a) => a.CommandStructureNodeId === node.CommandStructureNodeId); + const lead = laneLead(node, context); + const objective = node.PrimaryObjectiveId ? (board.Objectives ?? []).find((o) => o.TacticalObjectiveId === node.PrimaryObjectiveId) : undefined; + + return join([ + t('incident_assistant.lane_header', { lane: node.Name, type: getCommandNodeTypeName(t, node.NodeType), count: inLane.length }), + lead ? t('incident_assistant.lane_lead', { name: lead }) : null, + inLane.length === 0 ? t('incident_assistant.lane_empty') : null, + ...inLane.slice(0, MAX_LIST_ITEMS).map((a) => { + const elapsed = elapsedSince(a.AssignedOn); + return elapsed >= 60_000 ? `- ${resourceLabel(a, context)} ${t('incident_assistant.time_in_lane', { duration: formatDuration(elapsed) })}` : `- ${resourceLabel(a, context)}`; + }), + objective ? t('incident_assistant.lane_objective', { name: objective.Name, progress: objective.ProgressPercent }) : null, + ]); + } + + const external = context.adHocUnits.filter((u) => !u.ReleasedOn).length + context.adHocPersonnel.filter((p) => !p.ReleasedOn).length; + if (assignments.length === 0 && external === 0) { + return t('incident_assistant.no_resources', { incident: incidentLabel(context, t) }); + } + + const units = assignments.filter((a) => isUnitKind(a.ResourceKind)).length; + const unassigned = assignments.filter((a) => !a.CommandStructureNodeId).length; + + return join([ + t('incident_assistant.resources_header', { incident: incidentLabel(context, t), units, personnel: assignments.length - units }), + ...nodes.slice(0, MAX_LIST_ITEMS).map((node) => { + const inLane = assignments.filter((a) => a.CommandStructureNodeId === node.CommandStructureNodeId); + return t('incident_assistant.lane_line', { + lane: node.Name, + count: inLane.length, + names: inLane + .slice(0, 6) + .map((a) => resourceLabel(a, context)) + .join(', '), + }); + }), + unassigned > 0 ? t('incident_assistant.unassigned_line', { count: unassigned }) : null, + external > 0 ? t('incident_assistant.external_resources', { count: external }) : null, + ]); +}; + +export const answerSpanOfControl = (context: IncidentAnswerContext, t: TFunction): string => { + const board = context.board!; + const nodes = liveNodes(board); + const assignments = liveAssignments(board); + + if (nodes.length === 0) { + return t('incident_assistant.no_lanes_yet', { incident: incidentLabel(context, t) }); + } + + const over: string[] = []; + const under: string[] = []; + const leaderless: string[] = []; + + nodes.forEach((node) => { + const count = assignments.filter((a) => a.CommandStructureNodeId === node.CommandStructureNodeId).length; + // The lane's own configured maximum wins when set; otherwise NIMS' ceiling of seven. + const ceiling = node.MaxUnits && node.MaxUnits > 0 ? node.MaxUnits : SPAN_OF_CONTROL_CEILING; + + if (count > ceiling) { + over.push(t('incident_assistant.span_over_row', { lane: node.Name, count, limit: ceiling })); + } + if (node.MinUnits && node.MinUnits > 0 && count < node.MinUnits) { + under.push(t('incident_assistant.span_under_row', { lane: node.Name, count, minimum: node.MinUnits })); + } + if (count > 0 && !laneLead(node, context)) { + leaderless.push(node.Name); + } + }); + + const header = t('incident_assistant.span_header', { incident: incidentLabel(context, t), lanes: nodes.length, resources: assignments.length }); + + if (over.length === 0 && under.length === 0 && leaderless.length === 0) { + return join([header, t('incident_assistant.span_all_good', { ceiling: SPAN_OF_CONTROL_CEILING })]); + } + + return join([header, ...over, ...under, leaderless.length > 0 ? t('incident_assistant.span_no_lead', { lanes: leaderless.join(', ') }) : null]); +}; + +export const answerObjectives = (context: IncidentAnswerContext, t: TFunction): string => { + const objectives = [...(context.board!.Objectives ?? [])].sort((a, b) => a.SortOrder - b.SortOrder); + const playbook = playbookFor(context); + const lines: (string | null)[] = []; + + if (objectives.length === 0) { + lines.push(t('incident_assistant.no_objectives', { incident: incidentLabel(context, t) })); + } else { + const open = objectives.filter((o) => o.Status !== TacticalObjectiveStatus.Complete); + lines.push(t('incident_assistant.objectives_header', { incident: incidentLabel(context, t), complete: objectives.length - open.length, total: objectives.length })); + open.slice(0, MAX_LIST_ITEMS).forEach((objective) => lines.push(`- ${formatObjective(objective, t)}`)); + if (open.length === 0) { + lines.push(t('incident_assistant.objectives_all_complete')); + } + } + + const missing = playbook.benchmarks.filter((benchmark) => !objectives.some((o) => looseMatch(o.Name, benchmark))).slice(0, 6); + if (missing.length > 0) { + lines.push(t('incident_assistant.missing_benchmarks', { type: playbook.displayName, benchmarks: missing.join('; ') })); + } + + return join(lines); +}; + +export const answerNeeds = (context: IncidentAnswerContext, t: TFunction): string => { + const needs: IncidentNeed[] = context.board!.Needs ?? []; + if (needs.length === 0) { + return t('incident_assistant.no_needs', { incident: incidentLabel(context, t) }); + } + + const outstanding = needs + .filter((n) => n.Status === IncidentNeedStatus.Open || n.Status === IncidentNeedStatus.PartiallyMet) + .sort((a, b) => b.Priority - a.Priority || new Date(a.CreatedOn).getTime() - new Date(b.CreatedOn).getTime()); + + const header = t('incident_assistant.needs_header', { + incident: incidentLabel(context, t), + outstanding: outstanding.length, + met: needs.filter((n) => n.Status === IncidentNeedStatus.Met).length, + cancelled: needs.filter((n) => n.Status === IncidentNeedStatus.Cancelled).length, + }); + + if (outstanding.length === 0) { + return join([header, t('incident_assistant.needs_all_met')]); + } + + return join([ + header, + ...outstanding.slice(0, MAX_LIST_ITEMS).map((need) => + t('incident_assistant.need_row', { + name: need.Name, + category: getNeedCategoryName(t, need.Category), + quantity: need.QuantityRequested > 0 ? `${need.QuantityFulfilled}/${need.QuantityRequested}` : '', + age: formatDuration(elapsedSince(need.CreatedOn)), + }) + ), + ]); +}; + +export const answerRoles = (context: IncidentAnswerContext, t: TFunction, roleQuery?: string): string => { + const board = context.board!; + const active = (board.Roles ?? []).filter((r) => !r.RemovedOn); + + // RIT/RIC is a lane on a Resgrid board, not an ICS position — answer from the structure. + if (isRapidInterventionQuery(roleQuery)) { + const ritNode = liveNodes(board).find((n) => /\b(rit|ric)\b|rapid intervention/i.test(n.Name ?? '')); + if (!ritNode) { + return t('incident_assistant.no_rit', { incident: incidentLabel(context, t) }); + } + const count = liveAssignments(board).filter((a) => a.CommandStructureNodeId === ritNode.CommandStructureNodeId).length; + return t('incident_assistant.rit_found', { lane: ritNode.Name, count }); + } + + if (roleQuery) { + const role = resolveIncidentRole(roleQuery); + if (role === null) { + return t('incident_assistant.role_unknown', { role: roleQuery.trim() }); + } + + // The Incident Commander lives on the command row itself, not in the role assignments. + if (role === IncidentRoleType.IncidentCommander) { + const commander = board.Command.CurrentCommanderUserId ? context.resolveUserName(board.Command.CurrentCommanderUserId) : ''; + return commander + ? t('incident_assistant.role_filled', { role: getIncidentRoleName(t, role), name: commander }) + : t('incident_assistant.role_unfilled', { role: getIncidentRoleName(t, role), incident: incidentLabel(context, t) }); + } + + const holders = active.filter((r) => r.RoleType === role).map((r) => context.resolveUserName(r.UserId)); + return holders.length === 0 + ? t('incident_assistant.role_unfilled', { role: getIncidentRoleName(t, role), incident: incidentLabel(context, t) }) + : t('incident_assistant.role_filled', { role: getIncidentRoleName(t, role), name: holders.join(', ') }); + } + + const playbook = playbookFor(context); + const commanderName = board.Command.CurrentCommanderUserId ? context.resolveUserName(board.Command.CurrentCommanderUserId) : ''; + const filled = new Set(active.map((r) => r.RoleType)); + if (commanderName) { + filled.add(IncidentRoleType.IncidentCommander); + } + + const unfilled = keyRolesFor(playbook).filter((role) => !filled.has(role)); + + return join([ + t('incident_assistant.roles_header', { incident: incidentLabel(context, t), count: active.length }), + commanderName ? `- ${t('incident_assistant.role_row', { role: getIncidentRoleName(t, IncidentRoleType.IncidentCommander), name: commanderName })}` : null, + ...[...active] + .sort((a, b) => a.RoleType - b.RoleType) + .slice(0, MAX_LIST_ITEMS) + .map((r) => `- ${t('incident_assistant.role_row', { role: getIncidentRoleName(t, r.RoleType), name: context.resolveUserName(r.UserId) })}`), + unfilled.length > 0 ? t('incident_assistant.roles_unfilled', { type: playbook.displayName, roles: unfilled.map((role) => getIncidentRoleName(t, role)).join(', ') }) : null, + ]); +}; + +export const answerTimeline = (context: IncidentAnswerContext, t: TFunction, minutes?: number, count?: number): string => { + const commandId = context.board!.Command.IncidentCommandId; + let entries = context.timeline.filter((entry) => entry.IncidentCommandId === commandId).sort((a, b) => new Date(b.OccurredOn).getTime() - new Date(a.OccurredOn).getTime()); + + if (minutes && minutes > 0) { + const cutoff = Date.now() - minutes * 60_000; + entries = entries.filter((entry) => new Date(entry.OccurredOn).getTime() >= cutoff); + if (entries.length === 0) { + return t('incident_assistant.timeline_empty_window', { minutes, incident: incidentLabel(context, t) }); + } + } + + if (entries.length === 0) { + return t('incident_assistant.timeline_empty', { incident: incidentLabel(context, t) }); + } + + const take = Math.min(count && count > 0 ? count : DEFAULT_TIMELINE_ENTRIES, MAX_LIST_ITEMS); + + return join([ + minutes && minutes > 0 + ? t('incident_assistant.timeline_window_header', { incident: incidentLabel(context, t), minutes, count: entries.length }) + : t('incident_assistant.timeline_header', { incident: incidentLabel(context, t), count: Math.min(take, entries.length) }), + ...entries.slice(0, take).map((entry) => { + const who = entry.UserId ? context.resolveUserName(entry.UserId) : ''; + return t('incident_assistant.timeline_row', { time: localTime(entry.OccurredOn), description: truncate(entry.Description, 160), who: who ? ` — ${who}` : '' }); + }), + ]); +}; + +export const answerTimers = (context: IncidentAnswerContext, t: TFunction): string => { + const timers: IncidentTimer[] = (context.board!.Timers ?? []) + // A stopped timer isn't something the commander needs read back. + .filter((timer) => timer.Status !== IncidentTimerStatus.Stopped) + .sort((a, b) => new Date(a.NextDueOn ?? 0).getTime() - new Date(b.NextDueOn ?? 0).getTime()); + + if (timers.length === 0) { + return t('incident_assistant.no_timers', { incident: incidentLabel(context, t) }); + } + + return join([ + t('incident_assistant.timers_header', { incident: incidentLabel(context, t), count: timers.length }), + ...timers.slice(0, MAX_LIST_ITEMS).map((timer) => { + if (timer.Status === IncidentTimerStatus.Due) { + return t('incident_assistant.timer_due_row', { name: timer.Name }); + } + const remaining = timer.NextDueOn ? new Date(timer.NextDueOn).getTime() - Date.now() : 0; + return remaining > 0 ? t('incident_assistant.timer_running_row', { name: timer.Name, remaining: formatDuration(remaining) }) : t('incident_assistant.timer_no_due_row', { name: timer.Name }); + }), + ]); +}; + +export const answerNotes = (context: IncidentAnswerContext, t: TFunction): string => { + const notes: IncidentNote[] = (context.board!.Notes ?? []).filter((n) => !n.DeletedOn).sort((a, b) => new Date(b.CreatedOn).getTime() - new Date(a.CreatedOn).getTime()); + + if (notes.length === 0) { + return t('incident_assistant.no_notes', { incident: incidentLabel(context, t) }); + } + + return join([ + t('incident_assistant.notes_header', { incident: incidentLabel(context, t), count: notes.length }), + ...notes.slice(0, MAX_LIST_ITEMS).map((note) => + t('incident_assistant.note_row', { + time: localTime(note.CreatedOn), + body: note.Title ? `${note.Title}: ${truncate(note.Body, 160)}` : truncate(note.Body, 200), + who: context.resolveUserName(note.CreatedByUserId), + }) + ), + ]); +}; + +export const answerBriefing = (context: IncidentAnswerContext, t: TFunction): string => { + const board = context.board!; + const command = board.Command; + const playbook = playbookFor(context); + const nodes = liveNodes(board); + const assignments = liveAssignments(board); + const active = (board.Roles ?? []).filter((r) => !r.RemovedOn); + const par = parBuckets(board); + const objectives = [...(board.Objectives ?? [])].sort((a, b) => a.SortOrder - b.SortOrder); + const outstanding = (board.Needs ?? []).filter((n) => n.Status === IncidentNeedStatus.Open || n.Status === IncidentNeedStatus.PartiallyMet).sort((a, b) => b.Priority - a.Priority); + const unassigned = assignments.filter((a) => !a.CommandStructureNodeId).length; + + return join([ + t('incident_assistant.briefing_header', { incident: incidentLabel(context, t) }), + '', + t('incident_assistant.briefing_situation'), + t('incident_assistant.briefing_type', { type: playbook.displayName }), + context.callAddress ? t('incident_assistant.briefing_address', { address: context.callAddress }) : null, + t('incident_assistant.briefing_established', { time: new Date(command.EstablishedOn).toLocaleString(), duration: formatDuration(elapsedSince(command.EstablishedOn)) }), + command.ImportantInformation ? t('incident_assistant.briefing_important', { text: truncate(command.ImportantInformation, 400) }) : null, + '', + t('incident_assistant.briefing_command'), + t('incident_assistant.briefing_commander', { name: command.CurrentCommanderUserId ? context.resolveUserName(command.CurrentCommanderUserId) : t('incident_assistant.unknown') }), + command.CommandPostLocationText ? t('incident_assistant.briefing_icp', { location: command.CommandPostLocationText }) : null, + command.StagingLocationText ? t('incident_assistant.briefing_staging', { location: command.StagingLocationText }) : null, + command.RehabLocationText ? t('incident_assistant.briefing_rehab', { location: command.RehabLocationText }) : null, + ...[...active] + .sort((a, b) => a.RoleType - b.RoleType) + .slice(0, MAX_LIST_ITEMS) + .map((r) => `- ${t('incident_assistant.role_row', { role: getIncidentRoleName(t, r.RoleType), name: context.resolveUserName(r.UserId) })}`), + '', + t('incident_assistant.briefing_objectives'), + objectives.length === 0 ? t('incident_assistant.briefing_no_objectives') : null, + ...objectives.slice(0, MAX_LIST_ITEMS).map((objective) => `- ${formatObjective(objective, t)}`), + command.IncidentActionPlan ? t('incident_assistant.briefing_action_plan', { text: truncate(command.IncidentActionPlan, 400) }) : null, + '', + t('incident_assistant.briefing_organization'), + nodes.length === 0 ? t('incident_assistant.briefing_no_lanes') : null, + ...nodes.slice(0, MAX_LIST_ITEMS).map((node) => { + const inLane = assignments.filter((a) => a.CommandStructureNodeId === node.CommandStructureNodeId); + return `- ${t('incident_assistant.briefing_lane_row', { + lane: node.Name, + lead: laneLead(node, context) ?? t('incident_assistant.no_lead'), + count: inLane.length, + names: inLane + .slice(0, 6) + .map((a) => resourceLabel(a, context)) + .join(', '), + })}`; + }), + unassigned > 0 ? t('incident_assistant.unassigned_line', { count: unassigned }) : null, + '', + t('incident_assistant.briefing_accountability'), + par.total === 0 ? t('incident_assistant.briefing_no_par') : t('incident_assistant.par_counts', { green: par.total - par.warning - par.critical, warning: par.warning, critical: par.critical }), + '', + t('incident_assistant.briefing_needs'), + outstanding.length === 0 ? t('incident_assistant.briefing_no_needs') : null, + ...outstanding.slice(0, MAX_LIST_ITEMS).map((need) => `- ${need.Name}${need.QuantityRequested > 0 ? ` ${need.QuantityFulfilled}/${need.QuantityRequested}` : ''}`), + ]); +}; + +export const answerChecklist = (context: IncidentAnswerContext, t: TFunction, incidentType?: string): string => { + const board = context.board!; + const playbook = playbookFor(context, incidentType); + const active = (board.Roles ?? []).filter((r) => !r.RemovedOn); + const nodes = liveNodes(board); + const objectives = board.Objectives ?? []; + + const satisfied: string[] = []; + const outstanding: string[] = []; + const check = (isSatisfied: boolean, label: string) => (isSatisfied ? satisfied : outstanding).push(label); + + // Only these can be proven from the board; everything else is a prompt for the commander. + check(!!board.Command.CurrentCommanderUserId, t('incident_assistant.check_command')); + check(!!board.Command.CommandPostLocationText || !!board.Command.CommandPostLatitude, t('incident_assistant.check_icp')); + check(!!board.Command.IncidentActionPlan || objectives.length > 0, t('incident_assistant.check_action_plan')); + check( + active.some((r) => r.RoleType === IncidentRoleType.SafetyOfficer), + t('incident_assistant.check_safety') + ); + check((board.Accountability ?? []).length > 0 || (board.Timers ?? []).length > 0, t('incident_assistant.check_par')); + check(!!board.Command.StagingLocationText || nodes.some((n) => n.NodeType === CommandNodeType.Staging) || active.some((r) => r.RoleType === IncidentRoleType.StagingAreaManager), t('incident_assistant.check_staging')); + + return join([ + t('incident_assistant.checklist_header', { type: playbook.displayName, incident: incidentLabel(context, t) }), + satisfied.length > 0 ? t('incident_assistant.checklist_done', { items: satisfied.join('; ') }) : null, + outstanding.length > 0 ? t('incident_assistant.checklist_outstanding') : null, + ...outstanding.map((item) => `- ${item}`), + t('incident_assistant.checklist_confirm', { type: playbook.displayName }), + ...checklistFor(playbook) + .slice(0, MAX_LIST_ITEMS) + .map((item) => `- ${item}`), + t('incident_assistant.checklist_disclaimer'), + ]); +}; diff --git a/src/services/incident-assistant/ics-playbooks.ts b/src/services/incident-assistant/ics-playbooks.ts new file mode 100644 index 0000000..9ba6998 --- /dev/null +++ b/src/services/incident-assistant/ics-playbooks.ts @@ -0,0 +1,508 @@ +/** + * NIMS/ICS knowledge the on-device incident assistant reasons with. + * + * This is the on-device mirror of Core's `Resgrid.Chatbot/Services/IcsPlaybooks.cs` — the two must + * stay in sync so a commander gets the same guidance whether the answer came from the server or from + * the phone with no signal. It is shipped as code rather than fetched so it is available offline with + * no download, no model, and no setup. + * + * The doctrine text below is intentionally NOT run through `t()`, matching the backend's `EnOnly` + * treatment of the same content: these are dense ICS terms whose translation needs a subject-matter + * expert per locale rather than a literal one, and a mistranslated fireground benchmark is worse than + * an English one. Everything the UI itself says — headings, labels, generated answer sentences — IS + * translated (see the `incident_assistant.*` keys). + * + * Nothing here is department policy. It is the common doctrine an IC is trained against, and answers + * built from it are always framed as prompts to the commander, never as orders. + */ + +import { IncidentRoleType } from '@/models/v4/incidentCommand/incidentCommandModels'; + +/** Incident families the assistant carries guidance for. Mirrors Core `IncidentPlaybookType`. */ +export type IncidentPlaybookType = + | 'General' + | 'StructureFire' + | 'Wildland' + | 'VehicleAccident' + | 'Ems' + | 'MassCasualty' + | 'HazMat' + | 'NaturalDisaster' + | 'SearchAndRescue' + | 'TechnicalRescue' + | 'WaterRescue' + | 'ActiveThreat'; + +/** + * A one-tap prompt on the command board. `labelKey` is what the commander reads (translated); + * `question` is the canonical English text handed to the matcher — the intent patterns are + * English-only, exactly like the backend's classifier. + */ +export interface IncidentSuggestion { + labelKey: string; + question: string; +} + +export interface IncidentPlaybook { + type: IncidentPlaybookType; + /** English display name; also what the answers call this incident family. */ + displayName: string; + /** Lower-case terms matched against the call's type/name/nature to infer this playbook. */ + keywords: string[]; + /** Tactical benchmarks, matched loosely against the board's objectives to report progress. */ + benchmarks: string[]; + /** Doctrine checklist, ordered roughly by when it matters. */ + checklist: string[]; + /** ICS positions this incident type normally needs filled. */ + keyRoles: IncidentRoleType[]; + suggestions: IncidentSuggestion[]; +} + +const SUGGESTION = { + par: { labelKey: 'incident_assistant.suggestions.par', question: 'PAR' }, + status: { labelKey: 'incident_assistant.suggestions.status', question: 'Incident status' }, + openObjectives: { labelKey: 'incident_assistant.suggestions.open_objectives', question: 'What objectives are still open?' }, + span: { labelKey: 'incident_assistant.suggestions.span', question: 'Span of control' }, + missing: { labelKey: 'incident_assistant.suggestions.missing', question: 'What am I missing?' }, + rit: { labelKey: 'incident_assistant.suggestions.rit', question: 'Do I have a RIT?' }, + wind: { labelKey: 'incident_assistant.suggestions.wind', question: 'What is the wind doing?' }, + openNeeds: { labelKey: 'incident_assistant.suggestions.open_needs', question: 'What needs are still open?' }, + unfilledRoles: { labelKey: 'incident_assistant.suggestions.unfilled_roles', question: 'Which ICS positions are unfilled?' }, + onScene: { labelKey: 'incident_assistant.suggestions.on_scene', question: 'What resources do I have on scene?' }, + recent: { labelKey: 'incident_assistant.suggestions.recent', question: 'What happened in the last 30 minutes?' }, + briefing: { labelKey: 'incident_assistant.suggestions.briefing', question: 'Give me a transfer of command briefing' }, + safety: { labelKey: 'incident_assistant.suggestions.safety', question: 'Who is the safety officer?' }, + unassigned: { labelKey: 'incident_assistant.suggestions.unassigned', question: 'Who is unassigned?' }, + timers: { labelKey: 'incident_assistant.suggestions.timers', question: 'What timers are running?' }, +} as const; + +const GENERAL: IncidentPlaybook = { + type: 'General', + displayName: 'Incident', + keywords: ['incident'], + benchmarks: ['Command established', 'Initial size-up transmitted', 'Incident action plan set', 'Accountability in place', 'Incident under control'], + checklist: [ + 'Command established, announced, and passed to dispatch', + 'Command post location set and shared with incoming resources', + 'Initial size-up / CAN report (Conditions, Actions, Needs) transmitted', + 'Incident action plan recorded on the board', + 'Safety Officer assigned once the incident is working', + 'Accountability (PAR) timer running', + 'Staging designated and a Staging Area Manager assigned as resources build', + 'Span of control kept to 3-7 resources per supervisor', + 'Rehab established for extended operations', + 'Operational period and transfer-of-command plan set for a long incident', + ], + keyRoles: [IncidentRoleType.IncidentCommander, IncidentRoleType.SafetyOfficer], + suggestions: [SUGGESTION.par, SUGGESTION.status, SUGGESTION.openObjectives, SUGGESTION.span, SUGGESTION.missing], +}; + +const PLAYBOOKS: IncidentPlaybook[] = [ + { + type: 'StructureFire', + displayName: 'Structure fire', + keywords: [ + 'structure fire', + 'house fire', + 'building fire', + 'residential fire', + 'commercial fire', + 'apartment fire', + 'working fire', + 'room and contents', + 'chimney fire', + 'attic fire', + 'basement fire', + 'smoke in the structure', + 'fire alarm', + 'structure', + ], + benchmarks: ['360 complete', 'Water supply established', 'Primary search all clear', 'Fire under control', 'Secondary search all clear', 'Utilities secured', 'Loss stopped', 'Overhaul complete'], + checklist: [ + '360 size-up completed and the report transmitted', + 'Water supply established and confirmed', + 'Primary search assigned, with the all-clear reported back', + 'RIT / RIC assigned and in position before crews go interior', + 'Ventilation coordinated with the attack line, not ahead of it', + 'Utilities (gas and electric) secured', + 'Exposures checked and protected', + '20-minute PAR benchmarks running from the time of arrival', + 'Rehab established for crews rotating out', + 'Secondary search assigned once the fire is under control', + 'Fire investigator requested before overhaul destroys the origin area', + ], + keyRoles: [ + IncidentRoleType.IncidentCommander, + IncidentRoleType.SafetyOfficer, + IncidentRoleType.OperationsSectionChief, + IncidentRoleType.DivisionGroupSupervisor, + IncidentRoleType.StagingAreaManager, + IncidentRoleType.RehabOfficer, + ], + suggestions: [SUGGESTION.par, SUGGESTION.rit, SUGGESTION.openObjectives, SUGGESTION.onScene, SUGGESTION.missing], + }, + { + type: 'Wildland', + displayName: 'Wildland fire', + keywords: ['wildland', 'wild land', 'brush fire', 'brush', 'grass fire', 'vegetation fire', 'wildfire', 'forest fire', 'timber', 'red flag', 'field fire', 'woods fire'], + benchmarks: ['LCES briefed', 'Anchor point established', 'Line construction started', 'Structure triage complete', 'Containment percentage reported', 'Fire contained', 'Fire controlled'], + checklist: [ + 'LCES briefed to every division: Lookouts, Communications, Escape routes, Safety zones', + 'Anchor point established before any line construction', + 'Current and forecast wind, humidity and temperature checked', + 'Fire weather watch / red flag warning checked for the burn period', + 'Structure triage assigned for threatened structures', + 'Evacuation warnings and orders coordinated with law enforcement', + 'Air operations coordinated, with an Air Operations Branch Director once aircraft are working', + 'Acreage and containment percentage tracked for the ICS-209', + 'Divisions assigned by geography, each with a named supervisor', + 'Water tender / supply shuttle plan set', + 'Operational period and written IAP set — wildland incidents outlast the first crews', + ], + keyRoles: [ + IncidentRoleType.IncidentCommander, + IncidentRoleType.SafetyOfficer, + IncidentRoleType.OperationsSectionChief, + IncidentRoleType.DivisionGroupSupervisor, + IncidentRoleType.PlanningSectionChief, + IncidentRoleType.LogisticsSectionChief, + IncidentRoleType.AirOperationsBranchDirector, + ], + suggestions: [SUGGESTION.wind, SUGGESTION.par, SUGGESTION.span, SUGGESTION.openNeeds, SUGGESTION.missing], + }, + { + type: 'VehicleAccident', + displayName: 'Vehicle accident', + keywords: [ + 'mva', + 'mvc', + 'vehicle accident', + 'vehicle collision', + 'traffic collision', + 'car accident', + 'auto accident', + 'rollover', + 'pin in', + 'entrapment', + 'extrication', + 'vehicle vs', + 'car vs', + 'motorcycle accident', + 'vehicle fire', + 'car fire', + 'tc with', + ], + benchmarks: ['Scene stabilized', 'Traffic control established', 'Patient count confirmed', 'Extrication complete', 'All patients transported', 'Roadway released'], + checklist: [ + 'Blocking apparatus positioned upstream, wheels turned away from the work area', + 'Patient count confirmed and transmitted', + 'Vehicles stabilized before anyone works in or under them', + 'Hazards checked: fuel, battery, undeployed airbags, hybrid/EV high voltage, cargo', + 'Extrication group assigned with a stated plan and a backup plan', + 'Transport resources requested to match the confirmed patient count', + 'Air medical requested early and a landing zone secured if transport time drives it', + 'Law enforcement notified for investigation and roadway closure', + 'Fluid containment and clean-up arranged before the roadway is released', + ], + keyRoles: [IncidentRoleType.IncidentCommander, IncidentRoleType.SafetyOfficer, IncidentRoleType.OperationsSectionChief, IncidentRoleType.TriageOfficer, IncidentRoleType.TransportOfficer], + suggestions: [SUGGESTION.onScene, SUGGESTION.openObjectives, SUGGESTION.par, SUGGESTION.unfilledRoles, SUGGESTION.missing], + }, + { + type: 'Ems', + displayName: 'EMS incident', + keywords: [ + 'ems', + 'medical', + 'sick person', + 'chest pain', + 'cardiac', + 'cardiac arrest', + 'stroke', + 'overdose', + 'od', + 'fall', + 'difficulty breathing', + 'unconscious', + 'unresponsive', + 'seizure', + 'diabetic', + 'allergic reaction', + 'lift assist', + 'bleeding', + 'trauma', + ], + benchmarks: ['Patient contact made', 'ALS on scene', 'Transport decision made', 'Patient transported'], + checklist: [ + 'Scene safety confirmed; staged clear if the scene is not secured', + 'Patient count confirmed', + 'ALS resource on scene or en route when the patient’s condition needs it', + 'Receiving facility notified early for time-critical patients (STEMI, stroke, trauma)', + 'Air medical considered when ground transport time is the limiting factor', + 'Extra hands requested for lift assist, long carry-out or difficult access', + 'Law enforcement requested for violence, weapons or a crime scene', + 'Family and bystander management assigned on a working code', + ], + keyRoles: [IncidentRoleType.IncidentCommander, IncidentRoleType.MedicalUnitLeader, IncidentRoleType.TransportOfficer], + suggestions: [SUGGESTION.onScene, SUGGESTION.status, SUGGESTION.openObjectives, SUGGESTION.par, SUGGESTION.missing], + }, + { + type: 'MassCasualty', + displayName: 'Mass casualty incident', + keywords: ['mci', 'mass casualty', 'mass cas', 'multi casualty', 'multiple patients', 'bus accident', 'bus crash', 'train derailment', 'multiple victims'], + benchmarks: ['MCI declared', 'Triage complete', 'Treatment area established', 'Transport officer tracking', 'All immediate patients transported', 'All patients transported'], + checklist: [ + 'MCI declared and the level passed to dispatch', + 'Triage, Treatment and Transport Officers assigned', + 'START / SALT triage complete with counts by category (Immediate, Delayed, Minor, Deceased)', + 'Treatment area and casualty collection point established clear of the hazard', + 'Ambulance staging and a one-way transport corridor separated from the incoming route', + 'Hospital capability / bed poll requested and patients distributed across facilities', + 'Patient tracking in place — every patient’s destination recorded', + 'Additional transport, mutual aid and buses requested early rather than late', + 'Medical Branch Director assigned once triage exceeds span of control', + 'Family reunification point and a single public-information release point established', + ], + keyRoles: [ + IncidentRoleType.IncidentCommander, + IncidentRoleType.SafetyOfficer, + IncidentRoleType.MedicalBranchDirector, + IncidentRoleType.TriageOfficer, + IncidentRoleType.TreatmentOfficer, + IncidentRoleType.TransportOfficer, + IncidentRoleType.StagingAreaManager, + IncidentRoleType.PublicInformationOfficer, + ], + suggestions: [SUGGESTION.unfilledRoles, SUGGESTION.openNeeds, SUGGESTION.onScene, SUGGESTION.par, SUGGESTION.missing], + }, + { + type: 'HazMat', + displayName: 'HazMat incident', + keywords: [ + 'hazmat', + 'haz mat', + 'hazardous material', + 'chemical spill', + 'chemical leak', + 'gas leak', + 'natural gas', + 'propane leak', + 'fuel spill', + 'unknown odor', + 'odor of gas', + 'carbon monoxide', + 'co alarm', + 'radiological', + 'biological', + 'decon', + 'tanker rollover', + ], + benchmarks: ['Product identified', 'Zones established', 'Decon operational', 'Isolation distance set', 'Product controlled', 'Scene turned over'], + checklist: [ + 'Approached and staged upwind and uphill, outside the hot zone', + 'Product identified (placard, UN number, SDS) and the ERG isolation distance applied', + 'Hot, warm and cold zones established and physically marked', + 'Decon corridor operational BEFORE any entry team makes entry', + 'Entry team, backup team, entry time and air supply tracked', + 'HazMat Group Supervisor and Decon Officer assigned', + 'Downwind population identified; evacuate or shelter-in-place decision made', + 'Wind direction and forecast checked, and re-checked as the incident runs', + 'Technical reference, shipper and responsible party contacted', + 'Environmental agency and clean-up contractor notified', + ], + keyRoles: [ + IncidentRoleType.IncidentCommander, + IncidentRoleType.SafetyOfficer, + IncidentRoleType.OperationsSectionChief, + IncidentRoleType.HazMatGroupSupervisor, + IncidentRoleType.DeconOfficer, + IncidentRoleType.EntryTeamLeader, + ], + suggestions: [SUGGESTION.wind, SUGGESTION.unfilledRoles, SUGGESTION.openObjectives, SUGGESTION.par, SUGGESTION.missing], + }, + { + type: 'NaturalDisaster', + displayName: 'Natural disaster', + keywords: ['flood', 'flooding', 'tornado', 'hurricane', 'typhoon', 'earthquake', 'storm damage', 'severe weather', 'ice storm', 'blizzard', 'mudslide', 'landslide', 'wind damage', 'tsunami', 'disaster'], + benchmarks: ['Life safety sweep started', 'Damage assessment started', 'Shelters opened', 'Utilities coordinated', 'Operational period published'], + checklist: [ + 'Life-safety sweep of the affected area assigned by division / geography', + 'Damage assessment teams assigned and reporting on a schedule', + 'Shelter and mass-care coordination started with partner agencies', + 'EOC activated, or a liaison established with the jurisdiction’s EOC', + 'Utility companies engaged for downed lines, gas and water', + 'Road closures, access routes and staging mapped for incoming resources', + 'Operational periods declared — this incident will outlast the first crews', + 'Logistics plan for fuel, food, rest and relief crews', + 'Documentation Unit tracking costs and resource time for reimbursement', + 'Single public-information release point established', + ], + keyRoles: [ + IncidentRoleType.IncidentCommander, + IncidentRoleType.SafetyOfficer, + IncidentRoleType.OperationsSectionChief, + IncidentRoleType.PlanningSectionChief, + IncidentRoleType.LogisticsSectionChief, + IncidentRoleType.LiaisonOfficer, + IncidentRoleType.PublicInformationOfficer, + IncidentRoleType.ShelterMassCareCoordinator, + IncidentRoleType.DamageAssessmentLead, + ], + suggestions: [SUGGESTION.unfilledRoles, SUGGESTION.span, SUGGESTION.openNeeds, SUGGESTION.status, SUGGESTION.missing], + }, + { + type: 'SearchAndRescue', + displayName: 'Search and rescue', + keywords: ['search and rescue', 'sar', 'missing person', 'missing child', 'missing subject', 'lost hiker', 'overdue hiker', 'overdue', 'walkaway', 'despondent', 'wandering', 'lost person', 'search'], + benchmarks: ['Last known point established', 'Containment established', 'Hasty search complete', 'Segments assigned', 'Subject located'], + checklist: [ + 'Last known point / point last seen established and time-stamped', + 'Subject profile built: age, medical, clothing, experience, intent', + 'Containment set — trailheads, roads and perimeter covered before the search area grows', + 'Hasty teams pushed into the high-probability areas first', + 'Search segments defined, assigned and tracked with coverage / probability of detection', + 'Radio check schedule for every field team, with an overdue trigger', + 'Clue log maintained and every clue investigated and located', + 'Air, K9, drone and technical resources requested early', + 'Cell phone ping / forensics requested through law enforcement', + 'Operational period, night operations and relief teams planned', + ], + keyRoles: [ + IncidentRoleType.IncidentCommander, + IncidentRoleType.SafetyOfficer, + IncidentRoleType.OperationsSectionChief, + IncidentRoleType.SearchGroupSupervisor, + IncidentRoleType.PlanningSectionChief, + IncidentRoleType.LogisticsSectionChief, + ], + suggestions: [SUGGESTION.onScene, SUGGESTION.openObjectives, SUGGESTION.par, SUGGESTION.recent, SUGGESTION.missing], + }, + { + type: 'TechnicalRescue', + displayName: 'Technical rescue', + keywords: [ + 'technical rescue', + 'confined space', + 'trench', + 'trench collapse', + 'high angle', + 'rope rescue', + 'machinery entrapment', + 'structural collapse', + 'collapse rescue', + 'elevator rescue', + 'industrial accident', + 'silo', + 'grain bin', + ], + benchmarks: ['Scene secured', 'Atmosphere monitored', 'Rescue versus recovery declared', 'Patient contact made', 'Patient extricated', 'All crews out and accounted for'], + checklist: [ + 'Rescue versus recovery decision made and announced to everyone working', + 'Atmospheric monitoring and ventilation done before any confined-space entry', + 'Lock-out / tag-out of machinery and every energy source', + 'Trench: shoring in place and spoil pile set back — nobody enters an unprotected trench', + 'Technical rescue team requested; untrained crews are not committed', + 'Dedicated backup team and a safety officer assigned to the rescue itself', + 'Patient packaging plan set and a transport resource on scene', + 'Structural engineer and utility support requested for a collapse', + ], + keyRoles: [IncidentRoleType.IncidentCommander, IncidentRoleType.SafetyOfficer, IncidentRoleType.OperationsSectionChief, IncidentRoleType.EntryTeamLeader], + suggestions: [SUGGESTION.par, SUGGESTION.safety, SUGGESTION.openObjectives, SUGGESTION.onScene, SUGGESTION.missing], + }, + { + type: 'WaterRescue', + displayName: 'Water rescue', + keywords: ['water rescue', 'swift water', 'swiftwater', 'drowning', 'capsized', 'boat in distress', 'ice rescue', 'dive rescue', 'person in the water', 'flood rescue'], + benchmarks: ['Downstream containment established', 'Rescue resources deployed', 'Subject located', 'All crews accounted for'], + checklist: [ + 'Reach, throw, row, go — the lowest-risk option that works is the right one', + 'Downstream containment and backup established before any in-water attempt', + 'PFDs and throw bags on everyone working at the water’s edge', + 'Rescue versus recovery decision made, with time in the water tracked', + 'Boat, dive and helicopter resources requested early', + 'Upstream spotter posted for debris and changing flow', + ], + keyRoles: [IncidentRoleType.IncidentCommander, IncidentRoleType.SafetyOfficer, IncidentRoleType.OperationsSectionChief], + suggestions: [SUGGESTION.par, SUGGESTION.onScene, SUGGESTION.openObjectives, SUGGESTION.status, SUGGESTION.missing], + }, + { + type: 'ActiveThreat', + displayName: 'Active threat', + keywords: ['active shooter', 'active threat', 'shooting', 'shots fired', 'stabbing', 'hostile event', 'bomb threat', 'explosion', 'civil unrest', 'violent incident'], + benchmarks: ['Unified command established', 'Staging established', 'Casualty collection point established', 'Patients transported', 'Scene turned over to law enforcement'], + checklist: [ + 'Unified Command established with law enforcement', + 'Staging set well away from the scene and out of line of sight', + 'Warm and cold zones defined by law enforcement — nothing enters the hot zone', + 'Rescue Task Forces formed with force protection if the model is in use', + 'Casualty collection point and an evacuation corridor established', + 'Hemorrhage-control supplies pushed forward to the point of injury', + 'Hospitals notified of a mass-casualty penetrating-trauma event', + 'Secondary device / secondary threat considered before crews are committed', + 'Reunification, public information and behavioral health support started early', + ], + keyRoles: [ + IncidentRoleType.IncidentCommander, + IncidentRoleType.UnifiedCommandMember, + IncidentRoleType.SafetyOfficer, + IncidentRoleType.MedicalBranchDirector, + IncidentRoleType.TriageOfficer, + IncidentRoleType.TransportOfficer, + IncidentRoleType.LiaisonOfficer, + IncidentRoleType.PublicInformationOfficer, + ], + suggestions: [SUGGESTION.unfilledRoles, SUGGESTION.par, SUGGESTION.onScene, SUGGESTION.recent, SUGGESTION.missing], + }, +]; + +export const generalPlaybook = GENERAL; + +export const allPlaybooks: IncidentPlaybook[] = [GENERAL, ...PLAYBOOKS]; + +export const getPlaybook = (type: IncidentPlaybookType): IncidentPlaybook => (type === 'General' ? GENERAL : (PLAYBOOKS.find((p) => p.type === type) ?? GENERAL)); + +/** Resolves a playbook from free text the commander typed ("structure fire", "mci"). Null when nothing matches. */ +export const resolvePlaybook = (text?: string | null): IncidentPlaybook | null => { + if (!text) { + return null; + } + const needle = text.trim().toLowerCase(); + if (!needle) { + return null; + } + + return PLAYBOOKS.find((playbook) => playbook.displayName.toLowerCase() === needle || playbook.keywords.some((keyword) => needle.includes(keyword))) ?? null; +}; + +/** + * Infers the incident family from whatever the app knows about the call. Longer keyword matches win + * so "vehicle fire" beats a bare "fire"; falls back to the general playbook when nothing scores. + */ +export const inferPlaybook = (parts: (string | null | undefined)[]): IncidentPlaybook => { + const haystack = parts + .filter((part): part is string => typeof part === 'string' && part.trim().length > 0) + .join(' ') + .toLowerCase(); + + if (!haystack) { + return GENERAL; + } + + let best: IncidentPlaybook | null = null; + let bestScore = 0; + + for (const playbook of PLAYBOOKS) { + const score = playbook.keywords.reduce((max, keyword) => (haystack.includes(keyword) ? Math.max(max, keyword.length) : max), 0); + if (score > bestScore) { + bestScore = score; + best = playbook; + } + } + + return best ?? GENERAL; +}; + +/** The checklist to work from: type-specific items followed by the universal ones. */ +export const checklistFor = (playbook: IncidentPlaybook): string[] => (playbook.type === 'General' ? GENERAL.checklist : [...playbook.checklist, ...GENERAL.checklist]); + +/** Positions worth having filled: type-specific plus the universal ones, de-duplicated. */ +export const keyRolesFor = (playbook: IncidentPlaybook): IncidentRoleType[] => Array.from(new Set([...playbook.keyRoles, ...GENERAL.keyRoles])); diff --git a/src/services/incident-assistant/index.ts b/src/services/incident-assistant/index.ts new file mode 100644 index 0000000..53bba6f --- /dev/null +++ b/src/services/incident-assistant/index.ts @@ -0,0 +1,115 @@ +/** + * The on-device incident assistant. + * + * Answers an Incident Commander's command-board questions from the board already cached on the + * phone: no network, no model download, no per-device capability check. That matters because the + * moment a commander most needs "who's unaccounted for" is often the moment the scene has no signal. + * + * What the device can't do it says so about, and the caller (see `stores/command/assistant-store`) + * routes those to Resgrid Core: live weather, and any free-form question the deterministic matcher + * doesn't recognize, which the backend can answer with a department-configured LLM grounded on the + * same board. + */ + +import { type TFunction } from 'i18next'; + +import { + answerBriefing, + answerChecklist, + answerNeeds, + answerNotes, + answerObjectives, + answerPar, + answerResources, + answerRoles, + answerSpanOfControl, + answerStatus, + answerTimeline, + answerTimers, + type IncidentAnswerContext, + playbookFor, +} from './answerers'; +import { type IncidentSuggestion } from './ics-playbooks'; +import { type IncidentAssistantIntent, matchIncidentIntent } from './intent-matcher'; + +export { type IncidentAnswerContext } from './answerers'; +export { type IncidentPlaybook, type IncidentSuggestion } from './ics-playbooks'; +export { type IncidentAssistantIntent } from './intent-matcher'; + +export interface LocalAnswer { + /** Display text, or null when the device deliberately declined in favour of the server. */ + answer: string | null; + intent: IncidentAssistantIntent; + confidence: number; + /** + * True when this question needs Resgrid Core — live weather, or a free-form question only the + * server's grounded LLM can take. The caller decides what to do when there is no connection. + */ + requiresServer: boolean; +} + +/** Intents the device can never fully answer: the data simply isn't on the board. */ +const SERVER_ONLY_INTENTS: IncidentAssistantIntent[] = ['weather', 'unknown']; + +/** + * Answers a command-board question on-device. + * + * `confidence` is 1 for an anchored pattern hit and lower for the keyword fallback, so a caller with + * a connection can choose to prefer the server on a weak match while an offline caller still gets the + * device's best effort. + */ +export const answerIncidentQuestionLocally = (question: string, context: IncidentAnswerContext, t: TFunction): LocalAnswer => { + const match = matchIncidentIntent(question); + + if (SERVER_ONLY_INTENTS.includes(match.intent)) { + return { answer: null, intent: match.intent, confidence: match.confidence, requiresServer: true }; + } + + if (!context.board?.Command) { + return { answer: t('incident_assistant.no_board'), intent: match.intent, confidence: match.confidence, requiresServer: false }; + } + + switch (match.intent) { + case 'par': + return ok(answerPar(context, t), match); + case 'resources': + return ok(answerResources(context, t, match.params.laneName), match); + case 'span_of_control': + return ok(answerSpanOfControl(context, t), match); + case 'objectives': + return ok(answerObjectives(context, t), match); + case 'needs': + return ok(answerNeeds(context, t), match); + case 'roles': + return ok(answerRoles(context, t, match.params.roleQuery), match); + case 'timeline': + return ok(answerTimeline(context, t, match.params.minutes, match.params.count), match); + case 'timers': + return ok(answerTimers(context, t), match); + case 'notes': + return ok(answerNotes(context, t), match); + case 'briefing': + return ok(answerBriefing(context, t), match); + case 'checklist': + return ok(answerChecklist(context, t, match.params.incidentType), match); + case 'status': + default: + return ok(answerStatus(context, t), match); + } +}; + +const ok = (answer: string, match: { intent: IncidentAssistantIntent; confidence: number }): LocalAnswer => ({ + answer, + intent: match.intent, + confidence: match.confidence, + requiresServer: false, +}); + +/** + * The one-tap prompts to show for an incident, chosen from its inferred ICS playbook. Computed + * on-device so the chips are right even offline; Core exposes the same list for other clients. + */ +export const suggestionsForIncident = (context: IncidentAnswerContext): IncidentSuggestion[] => playbookFor(context).suggestions; + +/** Display name of the incident family the assistant inferred ("Structure fire", "Mass casualty incident"). */ +export const incidentTypeName = (context: IncidentAnswerContext): string => playbookFor(context).displayName; diff --git a/src/services/incident-assistant/intent-matcher.ts b/src/services/incident-assistant/intent-matcher.ts new file mode 100644 index 0000000..447bde0 --- /dev/null +++ b/src/services/incident-assistant/intent-matcher.ts @@ -0,0 +1,236 @@ +/** + * On-device intent matching for the command-board assistant. + * + * Mirrors the incident-command patterns in Core's `KeywordIntentClassifier` so the phone recognizes + * the same questions the server does — the point being that when the scene has no signal, the + * commander still gets an answer from the board already cached on the device. + * + * English-only by design, matching the backend classifier: commands and one-tap prompts are canonical + * English strings even when the UI is localized (the chips display a translated label but send the + * English question). See `ics-playbooks.ts`. + */ + +/** What the assistant understood the question to be. */ +export type IncidentAssistantIntent = 'status' | 'par' | 'resources' | 'span_of_control' | 'objectives' | 'needs' | 'roles' | 'timeline' | 'timers' | 'notes' | 'briefing' | 'checklist' | 'weather' | 'unknown'; + +export interface IncidentIntentParams { + /** Lane the question scoped to ("division a"), or the literal "unassigned". */ + laneName?: string; + /** ICS position asked about ("safety officer", "rit"). */ + roleQuery?: string; + /** How far back to read the incident log, in minutes. */ + minutes?: number; + /** How many log entries to read. */ + count?: number; + /** Incident family named explicitly ("structure fire"), overriding the inferred one. */ + incidentType?: string; +} + +export interface IncidentIntentMatch { + intent: IncidentAssistantIntent; + /** 1 for an anchored pattern hit, lower for the fuzzy keyword fallback, 0 for no match. */ + confidence: number; + params: IncidentIntentParams; +} + +type Extractor = (match: RegExpMatchArray) => IncidentIntentParams; + +interface Pattern { + regex: RegExp; + intent: IncidentAssistantIntent; + extract?: Extractor; +} + +/** + * ICS position vocabulary. Role lookups only fire on an actual position name so "who is Smith" stays + * an ordinary personnel question rather than being swallowed as a role query. + */ +const ROLE_WORDS = + '(ic|incident\\s+commander|deputy(\\s+incident)?(\\s+commander)?|commander|unified\\s+command|safety(\\s+officer)?|' + + 'ops(\\s+chief)?|operations(\\s+section)?(\\s+chief)?|planning(\\s+section)?(\\s+chief)?|logistics(\\s+section)?(\\s+chief)?|' + + 'finance(\\s+admin)?(\\s+section)?(\\s+chief)?|pio|public\\s+information\\s+officer|liaison(\\s+officer)?|' + + 'staging(\\s+area)?\\s+manager|resources?\\s+unit\\s+leader|situation\\s+unit\\s+leader|documentation\\s+unit\\s+leader|' + + 'communications\\s+unit\\s+leader|division\\s+supervisor|group\\s+supervisor|branch\\s+director|' + + 'strike\\s+team\\s+leader|task\\s+force\\s+leader|medical\\s+unit\\s+leader|rehab(\\s+officer)?|medical\\s+branch\\s+director|' + + 'triage(\\s+officer)?|treatment(\\s+officer)?|transport(\\s+officer)?|hazmat\\s+group\\s+supervisor|decon(\\s+officer)?|' + + 'entry\\s+team\\s+leader|search\\s+group\\s+supervisor|air\\s+operations(\\s+branch)?(\\s+director)?|' + + 'shelter(\\s+mass\\s+care)?\\s+coordinator|mass\\s+care\\s+coordinator|damage\\s+assessment\\s+lead|' + + 'rit|ric|rapid\\s+intervention(\\s+team|\\s+crew)?|accountability\\s+officer)'; + +const r = (source: string): RegExp => new RegExp(source, 'i'); + +const laneNameFrom = (nodeWord?: string, remainder?: string): string => { + const designator = (remainder ?? '').trim().replace(/[?!.,]+$/, ''); + const word = (nodeWord ?? '').trim(); + return designator ? `${word} ${designator}`.trim() : word; +}; + +const toMinutes = (amount?: string, unit?: string): number | undefined => { + const value = parseInt((amount ?? '').trim(), 10); + if (!Number.isFinite(value) || value <= 0) { + return undefined; + } + return (unit ?? '').trim().toLowerCase().startsWith('h') ? value * 60 : value; +}; + +const toCount = (raw?: string): number | undefined => { + const value = parseInt((raw ?? '').trim(), 10); + return Number.isFinite(value) && value > 0 ? value : undefined; +}; + +/** + * Ordered: the first match wins, so the sharper questions come before the broad ones. Kept in the + * same order as the backend's incident block for exactly the same reason. + */ +const PATTERNS: Pattern[] = [ + // --- PAR / accountability --- + { regex: r('^(par|par\\s+check|accountability|accountability\\s+check|personnel\\s+accountability(\\s+report)?)$'), intent: 'par' }, + { regex: r('^(give|get|run|do)\\s+(me\\s+)?(a\\s+|the\\s+)?par(\\s+check)?$'), intent: 'par' }, + { regex: r("^(who'?s|who\\s+is|who\\s+are|anyone)\\s+(overdue|unaccounted(\\s+for)?|not\\s+accounted\\s+for|missing)(\\s+.*)?$"), intent: 'par' }, + + // --- Span of control (before the generic resource questions) --- + { regex: r('^span(\\s+of\\s+control)?(\\s+check)?$'), intent: 'span_of_control' }, + { regex: r('^(what|which)\\s+(lanes?|divisions?|groups?|branches|sectors?)\\s+(are\\s+)?(over|under)\\s*-?\\s*(staffed|filled|loaded|manned|resourced)?$'), intent: 'span_of_control' }, + { regex: r('^(am\\s+i|are\\s+we)\\s+(over|under)\\s*-?\\s*(staffed|filled|loaded|manned|resourced)$'), intent: 'span_of_control' }, + + // --- Resources --- + { + regex: r("^(who'?s|who\\s+is|who\\s+are|what'?s|what\\s+is|what)\\s+(assigned\\s+to|working|in|on)\\s+(division|group|branch|sector|strike\\s+team|task\\s+force|staging|lane)\\s*(.*)$"), + intent: 'resources', + extract: (m) => ({ laneName: laneNameFrom(m[3], m[4]) }), + }, + { + regex: r('^(what|which)\\s+(resources|units?|crews?|companies|apparatus|personnel)\\s+(do\\s+i\\s+have|do\\s+we\\s+have|are|is)\\s+(on\\s*scene|assigned|working|committed|on\\s+(?:the\\s+)?incident)(\\s+.*)?$'), + intent: 'resources', + }, + { regex: r('^(incident\\s+)?(resources|assignments|resource\\s+list)$'), intent: 'resources' }, + { regex: r('^(what|who)\\s+(do\\s+i|do\\s+we)\\s+have\\s+(on\\s*scene|working|committed|assigned)(\\s+.*)?$'), intent: 'resources' }, + { regex: r("^(who'?s|who\\s+is|what'?s|what\\s+is|what)\\s+(un|not\\s+)assigned$"), intent: 'resources', extract: () => ({ laneName: 'unassigned' }) }, + + // --- Objectives / benchmarks --- + { regex: r('^(incident\\s+)?(objectives?|benchmarks?|tactical\\s+objectives?)$'), intent: 'objectives' }, + { regex: r('^(what|which)\\s+(objectives?|benchmarks?)\\s+(are\\s+)?(open|outstanding|incomplete|remaining|left|still\\s+open|not\\s+(?:done|complete))$'), intent: 'objectives' }, + { regex: r("^(what'?s|what\\s+is)\\s+(still\\s+)?(open|outstanding|left|remaining|incomplete)(\\s+on\\s+(?:the\\s+|this\\s+)?(incident|scene|board))?$"), intent: 'objectives' }, + { regex: r("^(what'?s|what\\s+is)\\s+(my|our|the)\\s+next\\s+benchmark$"), intent: 'objectives' }, + + // --- Needs / resource orders --- + { regex: r('^(incident\\s+)?(needs?|resource\\s+orders?|orders?)$'), intent: 'needs' }, + { regex: r('^(what|which)\\s+(needs?|orders?|requests?)\\s+(are\\s+)?(open|unfilled|outstanding|pending|not\\s+(?:met|filled))$'), intent: 'needs' }, + { regex: r('^what\\s+(did|have)\\s+(i|we)\\s+order(ed)?(\\s+.*)?$'), intent: 'needs' }, + { regex: r("^(what'?s|what\\s+is)\\s+(not\\s+)?(been\\s+)?(filled|met|arrived)$"), intent: 'needs' }, + { regex: r('^what\\s+(am\\s+i|are\\s+we)\\s+(waiting\\s+on|short\\s+on|short)$'), intent: 'needs' }, + + // --- ICS positions --- + { regex: r('^(ics\\s+)?(roles?|positions?|command\\s+staff|general\\s+staff)$'), intent: 'roles' }, + { regex: r(`^(who'?s|who\\s+is|who\\s+has)\\s+(my|the|our)?\\s*${ROLE_WORDS}\\s*\\??$`), intent: 'roles', extract: (m) => ({ roleQuery: (m[3] ?? '').trim() }) }, + { regex: r('^(what|which)\\s+(ics\\s+)?(roles?|positions?)\\s+(are\\s+)?(unfilled|open|vacant|empty|not\\s+assigned|missing)$'), intent: 'roles' }, + { regex: r(`^(do\\s+i|do\\s+we|have\\s+i|have\\s+we)\\s+(have|got|assigned)\\s+(an?\\s+)?${ROLE_WORDS}\\s*\\??$`), intent: 'roles', extract: (m) => ({ roleQuery: (m[4] ?? '').trim() }) }, + + // --- Incident (ICS-201) log --- + { regex: r('^(incident\\s+)?(timeline|incident\\s+log|command\\s+log|log)$'), intent: 'timeline' }, + { + regex: r('^what\\s+(has\\s+)?happened(\\s+(?:in\\s+)?(?:the\\s+)?last\\s+(\\d+)\\s*(minutes?|mins?|hours?|hrs?))?(\\s+.*)?$'), + intent: 'timeline', + extract: (m) => ({ minutes: toMinutes(m[3], m[4]) }), + }, + { regex: r('^(read|show|give|list)\\s+(me\\s+)?(the\\s+)?last\\s+(\\d+)\\s+(log\\s+)?(entries|entry|events)$'), intent: 'timeline', extract: (m) => ({ count: toCount(m[4]) }) }, + + // --- Timers --- + { regex: r('^(incident\\s+)?timers?$'), intent: 'timers' }, + { regex: r('^(what|which)\\s+timers?\\s+(are\\s+)?(running|due|up|active)$'), intent: 'timers' }, + { regex: r("^(what'?s|what\\s+is|when'?s|when\\s+is)\\s+(my|the|our)\\s+next\\s+(par|check\\s*-?\\s*in|timer)(\\s+.*)?$"), intent: 'timers' }, + + // --- Briefing / transfer of command --- + { regex: r('^(briefing|brief\\s+me|transfer\\s+of\\s+command|ics\\s*-?\\s*201|command\\s+brief(ing)?)$'), intent: 'briefing' }, + { + regex: r('^(give|draft|write|prepare|build|make)\\s+(me\\s+)?(a\\s+|the\\s+)?(briefing|brief|transfer\\s+of\\s+command(\\s+briefing)?|ics\\s*-?\\s*201|hand\\s*-?\\s*off(\\s+briefing)?)$'), + intent: 'briefing', + }, + + // --- Checklist / playbook --- + { regex: r("^(checklist|playbook|what\\s+am\\s+i\\s+missing|what\\s+are\\s+we\\s+missing|what'?s\\s+next)$"), intent: 'checklist' }, + { regex: r('^(what|anything)\\s+(am\\s+i|are\\s+we)\\s+(missing|forgetting)(\\s+.*)?$'), intent: 'checklist' }, + { regex: r('^what\\s+should\\s+(i|we)\\s+(be\\s+)?(doing|do|consider|think\\s+about)(\\s+.*)?$'), intent: 'checklist' }, + { regex: r('^(checklist|playbook)\\s+(?:for\\s+)?(?:an?\\s+)?(.+)$'), intent: 'checklist', extract: (m) => ({ incidentType: (m[2] ?? '').trim() }) }, + + // --- Weather at the incident --- + { regex: r('^(incident\\s+weather|scene\\s+weather|weather\\s+(?:at|on)\\s+(?:the\\s+)?(?:scene|incident|icp|command\\s+post))$'), intent: 'weather' }, + { regex: r("^(what'?s|what\\s+is)\\s+(the\\s+)?(wind|weather)\\s*(doing|at\\s+(?:the\\s+)?(?:scene|incident|icp))?$"), intent: 'weather' }, + { regex: r('^(wind|wind\\s+direction|wind\\s+speed)$'), intent: 'weather' }, + + // --- Status notes --- + { regex: r('^(incident\\s+)?(notes|situation\\s+updates?)$'), intent: 'notes' }, + { regex: r('^(what|any)\\s+(notes|situation\\s+updates?)(\\s+.*)?$'), intent: 'notes' }, + + // --- Overall status / size-up (last so sharper questions win) --- + { regex: r('^(incident|command|scene)\\s+(status|summary|snapshot|overview)$'), intent: 'status' }, + { regex: r('^(size\\s*-?\\s*up|sizeup|sitrep|situation\\s+report|can\\s+report|status\\s+board)$'), intent: 'status' }, + { regex: r("^(what'?s|what\\s+is)\\s+(the\\s+)?(status|situation|picture)\\s+(of|on|at)\\s+(the\\s+|this\\s+)?(incident|command|scene|call)$"), intent: 'status' }, + { regex: r('^(where\\s+(do|are)\\s+we\\s+(stand|at)|how\\s+are\\s+we\\s+doing)$'), intent: 'status' }, +]; + +/** + * Low-confidence keyword sweep for phrasings the anchored patterns miss. Scored below 1 so the caller + * can decide to prefer a server answer when it has a connection. + */ +const fuzzyMatch = (lower: string): IncidentIntentMatch | null => { + if (lower.includes('par') || lower.includes('accountab')) { + return { intent: 'par', confidence: 0.6, params: {} }; + } + if (lower.includes('objective') || lower.includes('benchmark')) { + return { intent: 'objectives', confidence: 0.6, params: {} }; + } + if (lower.includes('need') || lower.includes('order')) { + return { intent: 'needs', confidence: 0.5, params: {} }; + } + if (lower.includes('wind') || lower.includes('weather')) { + return { intent: 'weather', confidence: 0.6, params: {} }; + } + if (lower.includes('timer')) { + return { intent: 'timers', confidence: 0.6, params: {} }; + } + if (lower.includes('log') || lower.includes('timeline') || lower.includes('happened')) { + return { intent: 'timeline', confidence: 0.5, params: {} }; + } + if (lower.includes('brief') || lower.includes('201') || lower.includes('transfer of command')) { + return { intent: 'briefing', confidence: 0.6, params: {} }; + } + if (lower.includes('checklist') || lower.includes('missing')) { + return { intent: 'checklist', confidence: 0.5, params: {} }; + } + if (lower.includes('resource') || lower.includes('assigned') || lower.includes('on scene')) { + return { intent: 'resources', confidence: 0.5, params: {} }; + } + if (lower.includes('span')) { + return { intent: 'span_of_control', confidence: 0.5, params: {} }; + } + if (lower.includes('role') || lower.includes('position') || lower.includes('officer')) { + return { intent: 'roles', confidence: 0.5, params: {} }; + } + + return null; +}; + +/** Classifies a command-board question entirely on-device. */ +export const matchIncidentIntent = (question: string): IncidentIntentMatch => { + const trimmed = (question ?? '').trim(); + if (!trimmed) { + return { intent: 'unknown', confidence: 0, params: {} }; + } + + // Commanders punctuate ("PAR?"). Try the raw text first so free-form parameters keep their + // punctuation, then a stripped copy — the same two-pass the backend classifier uses. + const stripped = trimmed.replace(/[?!.,\s]+$/, ''); + const candidates = stripped.length > 0 && stripped !== trimmed ? [trimmed, stripped] : [trimmed]; + + for (const pattern of PATTERNS) { + for (const candidate of candidates) { + const match = candidate.match(pattern.regex); + if (match) { + return { intent: pattern.intent, confidence: 1, params: pattern.extract ? pattern.extract(match) : {} }; + } + } + } + + return fuzzyMatch(trimmed.toLowerCase()) ?? { intent: 'unknown', confidence: 0, params: {} }; +}; diff --git a/src/services/incident-assistant/role-vocabulary.ts b/src/services/incident-assistant/role-vocabulary.ts new file mode 100644 index 0000000..7ee292e --- /dev/null +++ b/src/services/incident-assistant/role-vocabulary.ts @@ -0,0 +1,126 @@ +/** + * Maps the words an Incident Commander actually says ("safety", "ops", "staging manager", "RIT") onto + * `IncidentRoleType`. On-device mirror of Core's `IncidentRoleVocabulary` so a role question resolves + * the same way with or without a connection. + * + * English-only, matching the intent matcher: the aliases are radio shorthand, not UI copy. + */ + +import { IncidentRoleType } from '@/models/v4/incidentCommand/incidentCommandModels'; + +/** + * Matched longest-first (see `ALIASES`) so "operations section chief" wins over "ops", "medical + * branch director" over "branch director", and "air ops" over "ops". Declaration order here is for + * readability only — the length ordering is enforced below, not assumed. + */ +const ALIAS_SOURCE: [string, IncidentRoleType][] = [ + ['deputy incident commander', IncidentRoleType.DeputyIncidentCommander], + ['deputy ic', IncidentRoleType.DeputyIncidentCommander], + ['deputy', IncidentRoleType.DeputyIncidentCommander], + ['unified command', IncidentRoleType.UnifiedCommandMember], + ['incident commander', IncidentRoleType.IncidentCommander], + ['operations section chief', IncidentRoleType.OperationsSectionChief], + ['operations chief', IncidentRoleType.OperationsSectionChief], + ['operations', IncidentRoleType.OperationsSectionChief], + ['ops chief', IncidentRoleType.OperationsSectionChief], + ['ops', IncidentRoleType.OperationsSectionChief], + ['planning section chief', IncidentRoleType.PlanningSectionChief], + ['planning chief', IncidentRoleType.PlanningSectionChief], + ['planning', IncidentRoleType.PlanningSectionChief], + ['logistics section chief', IncidentRoleType.LogisticsSectionChief], + ['logistics chief', IncidentRoleType.LogisticsSectionChief], + ['logistics', IncidentRoleType.LogisticsSectionChief], + ['finance admin section chief', IncidentRoleType.FinanceAdminSectionChief], + ['finance section chief', IncidentRoleType.FinanceAdminSectionChief], + ['finance', IncidentRoleType.FinanceAdminSectionChief], + ['safety officer', IncidentRoleType.SafetyOfficer], + ['safety', IncidentRoleType.SafetyOfficer], + ['public information officer', IncidentRoleType.PublicInformationOfficer], + ['pio', IncidentRoleType.PublicInformationOfficer], + ['liaison officer', IncidentRoleType.LiaisonOfficer], + ['liaison', IncidentRoleType.LiaisonOfficer], + ['staging area manager', IncidentRoleType.StagingAreaManager], + ['staging manager', IncidentRoleType.StagingAreaManager], + ['resources unit leader', IncidentRoleType.ResourcesUnitLeader], + ['resource unit leader', IncidentRoleType.ResourcesUnitLeader], + ['situation unit leader', IncidentRoleType.SituationUnitLeader], + ['documentation unit leader', IncidentRoleType.DocumentationUnitLeader], + ['communications unit leader', IncidentRoleType.CommunicationsUnitLeader], + ['comms unit leader', IncidentRoleType.CommunicationsUnitLeader], + ['division supervisor', IncidentRoleType.DivisionGroupSupervisor], + ['group supervisor', IncidentRoleType.DivisionGroupSupervisor], + ['branch director', IncidentRoleType.BranchDirector], + ['strike team leader', IncidentRoleType.StrikeTeamTaskForceLeader], + ['task force leader', IncidentRoleType.StrikeTeamTaskForceLeader], + ['medical branch director', IncidentRoleType.MedicalBranchDirector], + ['medical unit leader', IncidentRoleType.MedicalUnitLeader], + ['rehab officer', IncidentRoleType.RehabOfficer], + ['rehab', IncidentRoleType.RehabOfficer], + ['triage officer', IncidentRoleType.TriageOfficer], + ['triage', IncidentRoleType.TriageOfficer], + ['treatment officer', IncidentRoleType.TreatmentOfficer], + ['treatment', IncidentRoleType.TreatmentOfficer], + ['transport officer', IncidentRoleType.TransportOfficer], + ['transport', IncidentRoleType.TransportOfficer], + ['hazmat group supervisor', IncidentRoleType.HazMatGroupSupervisor], + ['hazmat supervisor', IncidentRoleType.HazMatGroupSupervisor], + ['decon officer', IncidentRoleType.DeconOfficer], + ['decon', IncidentRoleType.DeconOfficer], + ['entry team leader', IncidentRoleType.EntryTeamLeader], + ['search group supervisor', IncidentRoleType.SearchGroupSupervisor], + ['air operations branch director', IncidentRoleType.AirOperationsBranchDirector], + ['air operations', IncidentRoleType.AirOperationsBranchDirector], + ['air ops', IncidentRoleType.AirOperationsBranchDirector], + ['shelter mass care coordinator', IncidentRoleType.ShelterMassCareCoordinator], + ['mass care coordinator', IncidentRoleType.ShelterMassCareCoordinator], + ['shelter coordinator', IncidentRoleType.ShelterMassCareCoordinator], + ['damage assessment lead', IncidentRoleType.DamageAssessmentLead], + ['ic', IncidentRoleType.IncidentCommander], + ['commander', IncidentRoleType.IncidentCommander], +]; + +/** + * The alias table ordered longest-first. A shorter alias is always a substring risk for a longer one + * ("ops" inside "air ops", "branch director" inside "medical branch director"), and relying on + * hand-maintained declaration order to avoid that has already produced wrong answers — so the + * ordering is computed instead. + */ +const ALIASES: [string, IncidentRoleType][] = [...ALIAS_SOURCE].sort((a, b) => b[0].length - a[0].length); + +/** Terms naming a RIT/RIC — a lane on a Resgrid board rather than an ICS command position. */ +const RAPID_INTERVENTION_ALIASES = ['rapid intervention team', 'rapid intervention crew', 'rapid intervention', 'rit', 'ric']; + +const normalize = (text: string): string => + text + .trim() + .toLowerCase() + .replace(/[^a-z0-9 ]+/g, ' ') + .split(/\s+/) + .filter(Boolean) + .join(' '); + +/** Whole-word containment so "ic" doesn't match inside "medic" or "logistics". */ +const containsWord = (haystack: string, needle: string): boolean => haystack === needle || haystack.startsWith(`${needle} `) || haystack.endsWith(` ${needle}`) || haystack.includes(` ${needle} `); + +/** Null when the text names no known ICS position. */ +export const resolveIncidentRole = (text?: string | null): IncidentRoleType | null => { + if (!text) { + return null; + } + const needle = normalize(text); + if (!needle) { + return null; + } + + const hit = ALIASES.find(([alias]) => containsWord(needle, alias)); + return hit ? hit[1] : null; +}; + +/** True when the question was about a RIT/RIC rather than a command position. */ +export const isRapidInterventionQuery = (text?: string | null): boolean => { + if (!text) { + return false; + } + const needle = normalize(text); + return RAPID_INTERVENTION_ALIASES.some((alias) => containsWord(needle, alias)); +}; diff --git a/src/services/signalr.service.ts b/src/services/signalr.service.ts index ab6d386..47f734a 100644 --- a/src/services/signalr.service.ts +++ b/src/services/signalr.service.ts @@ -23,6 +23,9 @@ export interface SignalRMessage { data: unknown; } +/** Hub events can carry multiple positional arguments; listeners receive all of them. */ +export type SignalREventListener = (...data: unknown[]) => void; + export enum HubConnectingState { IDLE = 'idle', RECONNECTING = 'reconnecting', @@ -30,6 +33,14 @@ export enum HubConnectingState { } class SignalRService { + /** + * Per-hub transport lifecycle signals. Group membership is scoped to a connection id, so + * a subscriber that joined server-side groups has to re-announce itself after every + * reconnect — these are how it learns that happened. + */ + public static readonly HUB_DISCONNECTED_EVENT = '__hubDisconnected'; + public static readonly HUB_RECONNECTED_EVENT = '__hubReconnected'; + private connections: Map = new Map(); private reconnectAttempts: Map = new Map(); private hubConfigs: Map = new Map(); @@ -206,6 +217,7 @@ class SignalRService { // Set up event handlers connection.onclose(() => { + this.emitHubLifecycle(SignalRService.HUB_DISCONNECTED_EVENT, config.name); this.handleConnectionClose(config.name); }); @@ -222,6 +234,9 @@ class SignalRService { context: { connectionId }, }); this.reconnectAttempts.set(config.name, 0); + // A reconnect issues a new connection id, so any server-side group this connection + // belonged to is gone. Subscribers must re-announce themselves. + this.emitHubLifecycle(SignalRService.HUB_RECONNECTED_EVENT, config.name); }); // Register all methods @@ -231,12 +246,12 @@ class SignalRService { context: { method }, }); - connection.on(method, (data) => { + connection.on(method, (...args: unknown[]) => { logger.debug({ message: `Received ${method} message from hub: ${config.name}`, - context: { method, data }, + context: { method, args }, }); - this.handleMessage(config.name, method, data); + this.handleMessage(config.name, method, args); }); }); @@ -336,6 +351,7 @@ class SignalRService { // Set up event handlers connection.onclose(() => { + this.emitHubLifecycle(SignalRService.HUB_DISCONNECTED_EVENT, config.name); this.handleConnectionClose(config.name); }); @@ -352,6 +368,9 @@ class SignalRService { context: { connectionId }, }); this.reconnectAttempts.set(config.name, 0); + // A reconnect issues a new connection id, so any server-side group this connection + // belonged to is gone. Subscribers must re-announce themselves. + this.emitHubLifecycle(SignalRService.HUB_RECONNECTED_EVENT, config.name); }); // Register all methods @@ -361,12 +380,12 @@ class SignalRService { context: { method }, }); - connection.on(method, (data) => { + connection.on(method, (...args: unknown[]) => { logger.debug({ message: `Received ${method} message from hub: ${config.name}`, - context: { method, data }, + context: { method, args }, }); - this.handleMessage(config.name, method, data); + this.handleMessage(config.name, method, args); }); }); @@ -511,9 +530,11 @@ class SignalRService { } } - private handleMessage(_hubName: string, method: string, data: unknown): void { - // Emit event for subscribers using the method name as the event name - this.emit(method, data); + private handleMessage(_hubName: string, method: string, args: unknown[]): void { + // 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. + this.emit(method, ...args); } public async disconnectFromHub(hubName: string): Promise { @@ -621,16 +642,16 @@ class SignalRService { } // Event emitter methods - private eventListeners: Map void>> = new Map(); + private eventListeners: Map> = new Map(); - public on(event: string, callback: (data: unknown) => void): void { + public on(event: string, callback: SignalREventListener): void { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event)?.add(callback); } - public off(event: string, callback: (data: unknown) => void): void { + public off(event: string, callback: SignalREventListener): void { this.eventListeners.get(event)?.delete(callback); } @@ -638,10 +659,16 @@ class SignalRService { this.eventListeners.delete(event); } - private emit(event: string, data: unknown): void { + /** Raises a lifecycle signal both unqualified and scoped to the hub that produced it. */ + private emitHubLifecycle(event: string, hubName: string): void { + this.emit(event, hubName); + this.emit(`${event}:${hubName}`, hubName); + } + + private emit(event: string, ...data: unknown[]): void { this.eventListeners.get(event)?.forEach((callback) => { try { - callback(data); + callback(...data); } catch (error) { logger.error({ message: `Error in SignalR event listener for event: ${event}`, diff --git a/src/stores/chat/__tests__/hub-invoke-args.test.ts b/src/stores/chat/__tests__/hub-invoke-args.test.ts new file mode 100644 index 0000000..b7a8bc3 --- /dev/null +++ b/src/stores/chat/__tests__/hub-invoke-args.test.ts @@ -0,0 +1,172 @@ +/** + * SignalR binds hub arguments positionally and rejects an invocation that supplies + * fewer arguments than the hub method declares — C# default values do not make a + * parameter optional on the wire. These tests pin the argument counts against the + * ChatHub signatures so a short invoke can never silently strand the client outside + * its channel groups again: + * + * JoinChannel(string channelId, int? asUnitId) + * Typing(string channelId, string displayName, bool isTyping, int? asUnitId) + * MarkRead(string channelId, long seq, int? asUnitId) + */ +const mockInvoke = jest.fn().mockResolvedValue(undefined); + +jest.mock('@/services/signalr.service', () => ({ + signalRService: { invoke: mockInvoke }, +})); + +jest.mock('@/lib/env', () => ({ + Env: { CHAT_HUB_NAME: 'chatHub' }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), fatal: jest.fn() }, +})); + +jest.mock('@/lib/i18n/utils', () => ({ translate: (key: string) => key })); + +jest.mock('@/lib/storage', () => ({ zustandStorage: { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn() } })); + +jest.mock('@/api/chat/chat', () => ({ + getChannels: jest.fn().mockResolvedValue({ Data: [] }), + getMessages: jest.fn().mockResolvedValue({ Data: [] }), + getMembers: jest.fn().mockResolvedValue({ Data: [] }), + getMyPendingAcks: jest.fn().mockResolvedValue({ Data: [] }), + markRead: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@/api/chat/chatbot', () => ({ + getChatbotChannel: jest.fn(), + sendChatbotMessage: jest.fn(), + newChatbotSession: jest.fn(), +})); + +jest.mock('@/stores/auth/store', () => ({ + __esModule: true, + default: { getState: () => ({ userId: 'user-1', profile: { name: 'Test User' } }) }, +})); + +jest.mock('@/stores/toast/store', () => ({ + useToastStore: { getState: () => ({ showToast: jest.fn() }) }, +})); + +// Loaded lazily so the mock factories above run after their `mock*` consts exist. +type ChatStoreApi = typeof import('../store').useChatStore; +let useChatStore: ChatStoreApi; + +beforeAll(() => { + useChatStore = require('../store').useChatStore as ChatStoreApi; +}); + +describe('chat hub invocations', () => { + beforeEach(() => { + mockInvoke.mockClear(); + mockInvoke.mockResolvedValue(undefined); + useChatStore.setState({ messagesByChannel: {}, channels: [] }); + }); + + it('sends both JoinChannel arguments', async () => { + await useChatStore.getState().joinChannel('channel-1'); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'JoinChannel', 'channel-1', null); + }); + + it('sends all four Typing arguments in hub order', () => { + useChatStore.getState().sendTyping('channel-1', true); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'Typing', 'channel-1', 'Test User', true, null); + }); + + it('sends all three MarkRead arguments', async () => { + useChatStore.setState({ + messagesByChannel: { + 'channel-1': [ + { + ChatMessageId: 'm1', + ChatChannelId: 'channel-1', + MessageSeq: 42, + SenderParticipantType: 0, + SenderUserId: 'user-2', + SenderDisplayName: 'Other', + Body: 'hi', + MessageType: 0, + Priority: 0, + ThreadRootMessageId: null, + ThreadReplyCount: 0, + AlsoSendToChannel: false, + MetadataJson: null, + ClientMessageId: 'c1', + SentOn: new Date(0).toISOString(), + Reactions: [], + Attachments: [], + }, + ], + }, + } as unknown as Parameters[0]); + + await useChatStore.getState().markChannelRead('channel-1'); + + expect(mockInvoke).toHaveBeenCalledWith('chatHub', 'MarkRead', 'channel-1', 42, null); + }); +}); + +describe('incoming message normalization', () => { + beforeEach(() => { + useChatStore.setState({ messagesByChannel: {}, channels: [] }); + }); + + it('fills in collections the hub payload omits', () => { + // The hub sends the message DTO as a JSON string and drops empty collections. + useChatStore.getState().handleMessageReceived(JSON.stringify({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi', SentOn: new Date(0).toISOString() })); + + const stored = useChatStore.getState().messagesByChannel['channel-1']?.[0]; + expect(stored?.Reactions).toEqual([]); + expect(stored?.Attachments).toEqual([]); + }); + + it('keeps existing reactions when a later payload omits them', () => { + useChatStore.getState().handleMessageReceived({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi', SentOn: new Date(0).toISOString(), Reactions: [{ Emoji: '\u{1F44D}', UserId: 'user-2' }] }); + useChatStore.getState().handleMessageEdited({ ChatMessageId: 'm1', ChatChannelId: 'channel-1', MessageSeq: 10, Body: 'hi (edited)', SentOn: new Date(0).toISOString() }); + + const stored = useChatStore.getState().messagesByChannel['channel-1']?.[0]; + expect(stored?.Body).toBe('hi (edited)'); + expect(stored?.Reactions).toHaveLength(1); + }); +}); + +describe('chat presence events', () => { + beforeEach(() => { + useChatStore.setState({ presence: new Set() }); + }); + + it('accepts the hub positional (userId, isOnline) form', () => { + useChatStore.getState().handlePresenceChanged('user-2', true); + expect(useChatStore.getState().presence.has('user-2')).toBe(true); + + useChatStore.getState().handlePresenceChanged('user-2', false); + expect(useChatStore.getState().presence.has('user-2')).toBe(false); + }); + + it('still accepts an object payload', () => { + useChatStore.getState().handlePresenceChanged({ UserId: 'user-3', IsOnline: true }); + expect(useChatStore.getState().presence.has('user-3')).toBe(true); + }); +}); + +describe('chat typing events', () => { + beforeEach(() => { + useChatStore.setState({ typingByChannel: {} }); + }); + + it('reads the hub payload ChannelId field', () => { + useChatStore.getState().handleTyping({ ChannelId: 'channel-1', UserId: 'user-2', DisplayName: 'Other', IsTyping: true }); + + expect(useChatStore.getState().typingByChannel['channel-1']?.[0]?.displayName).toBe('Other'); + }); + + it('reads a camelCase hub payload', () => { + useChatStore.getState().handleTyping({ channelId: 'channel-1', userId: 'user-2', displayName: 'Other', isTyping: true }); + + expect(useChatStore.getState().typingByChannel['channel-1']?.[0]?.userId).toBe('user-2'); + }); +}); diff --git a/src/stores/chat/store.ts b/src/stores/chat/store.ts index 0631815..aec4897 100644 --- a/src/stores/chat/store.ts +++ b/src/stores/chat/store.ts @@ -119,7 +119,7 @@ interface ChatState { handleChatbotMessageReceived: (raw: unknown) => void; handleChatbotTyping: (raw: unknown) => void; handleTyping: (raw: unknown) => void; - handlePresenceChanged: (raw: unknown) => void; + handlePresenceChanged: (raw: unknown, isOnlineArg?: unknown) => void; handleChatConnected: () => void; reset: () => void; @@ -145,6 +145,11 @@ function currentUserId(): string | null { return useAuthStore.getState().userId; } +/** Name broadcast with typing signals; the hub echoes it to the other participants. */ +function currentDisplayName(): string | null { + return useAuthStore.getState().profile?.name ?? null; +} + function parseEventData(raw: unknown): T | null { if (raw == null) return null; if (typeof raw === 'string') { @@ -168,6 +173,18 @@ function compareMessages(a: ChatMessageResultData, b: ChatMessageResultData): nu return new Date(a.SentOn).getTime() - new Date(b.SentOn).getTime(); } +/** The realtime payloads omit empty collections even though the DTO types them as + * required, so every stored message is normalized on the way in — the UI iterates + * Reactions/Attachments directly. An existing value always wins over a missing one + * so a partial hub update can never drop reactions already on screen. */ +function withCollections(incoming: ChatMessageResultData, existing?: ChatMessageResultData): ChatMessageResultData { + return { + ...incoming, + Reactions: incoming.Reactions ?? existing?.Reactions ?? [], + Attachments: incoming.Attachments ?? existing?.Attachments ?? [], + }; +} + /** Insert or replace a message in an ascending-by-sequence list, de-duplicated * by ChatMessageId and ClientMessageId (so optimistic sends reconcile). */ function upsertMessage(list: ChatMessageResultData[], incoming: ChatMessageResultData): ChatMessageResultData[] { @@ -175,9 +192,9 @@ function upsertMessage(list: ChatMessageResultData[], incoming: ChatMessageResul const idx = next.findIndex((m) => m.ChatMessageId === incoming.ChatMessageId || (!!incoming.ClientMessageId && !!m.ClientMessageId && m.ClientMessageId === incoming.ClientMessageId)); if (idx >= 0) { const existing = next[idx]; - next[idx] = { ...existing, ...incoming }; + next[idx] = withCollections({ ...existing, ...incoming }, existing); } else { - next.push(incoming); + next.push(withCollections(incoming)); } next.sort(compareMessages); return next; @@ -529,7 +546,8 @@ export const useChatStore = create()( channels: s.channels.map((c) => (c.ChatChannelId === channelId ? { ...c, UnreadCount: 0, MyLastReadSeq: seq } : c)), })); - void safeInvoke('MarkRead', channelId, seq); + // Hub signature: MarkRead(channelId, seq, asUnitId). + void safeInvoke('MarkRead', channelId, seq, null); try { await chatApi.markRead(channelId, { Seq: seq }); } catch (error) { @@ -644,7 +662,11 @@ export const useChatStore = create()( // Realtime send helpers // ------------------------------------------------------------------ joinChannel: async (channelId: string) => { - await safeInvoke('JoinChannel', channelId); + // Hub signature: JoinChannel(channelId, asUnitId). SignalR binds hub arguments + // positionally and rejects an invocation that supplies fewer than the method + // declares, so omitting the optional argument left the connection outside the + // channel group and the channel permanently silent. + await safeInvoke('JoinChannel', channelId, null); }, sendTyping: (channelId: string, isTyping: boolean) => { @@ -656,7 +678,8 @@ export const useChatStore = create()( } else { lastTypingSentAt.delete(channelId); } - void safeInvoke('Typing', channelId, isTyping); + // Hub signature: Typing(channelId, displayName, isTyping, asUnitId). + void safeInvoke('Typing', channelId, currentDisplayName(), isTyping, null); }, // ------------------------------------------------------------------ @@ -761,8 +784,10 @@ export const useChatStore = create()( }, handleTyping: (raw: unknown) => { - const obj = (typeof raw === 'object' && raw !== null ? (raw as Record) : {}) as Record; - const channelId = (obj.ChatChannelId ?? obj.chatChannelId ?? obj.ChannelId) as string | undefined; + // The hub payload uses ChannelId (not ChatChannelId) and its casing depends on the + // server's JSON naming policy, so accept both spellings of every field. + const obj = (parseEventData>(raw) ?? {}) as Record; + const channelId = (obj.ChatChannelId ?? obj.chatChannelId ?? obj.ChannelId ?? obj.channelId) as string | undefined; const userId = (obj.UserId ?? obj.userId) as string | undefined; const displayName = (obj.DisplayName ?? obj.displayName) as string | undefined; const isTyping = (obj.IsTyping ?? obj.isTyping) as boolean | undefined; @@ -776,10 +801,12 @@ export const useChatStore = create()( addTyping(set, channelId, { userId, displayName, expiresAt: Date.now() + TYPING_EXPIRY_MS }); }, - handlePresenceChanged: (raw: unknown) => { + handlePresenceChanged: (raw: unknown, isOnlineArg?: unknown) => { + // The hub sends `chatPresenceChanged` as two positional args (userId, isOnline); + // keep the object form working in case a future producer sends a DTO. const obj = (typeof raw === 'object' && raw !== null ? (raw as Record) : {}) as Record; - const userId = (obj.UserId ?? obj.userId) as string | undefined; - const isOnline = (obj.IsOnline ?? obj.isOnline) as boolean | undefined; + const userId = typeof raw === 'string' ? raw : ((obj.UserId ?? obj.userId) as string | undefined); + const isOnline = typeof raw === 'string' ? Boolean(isOnlineArg) : ((obj.IsOnline ?? obj.isOnline) as boolean | undefined); if (!userId) return; set((s) => { const presence = new Set(s.presence); diff --git a/src/stores/command/__tests__/assistant-store.test.ts b/src/stores/command/__tests__/assistant-store.test.ts new file mode 100644 index 0000000..fdf7b66 --- /dev/null +++ b/src/stores/command/__tests__/assistant-store.test.ts @@ -0,0 +1,171 @@ +import { act } from '@testing-library/react-native'; +import { type TFunction } from 'i18next'; + +import en from '@/translations/en.json'; + +let mockOnline = true; +const mockAskIncidentAssistant = jest.fn(); + +jest.mock('@/lib/storage', () => ({ + zustandStorage: { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn() }, +})); + +jest.mock('@/lib/logging', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }, +})); + +jest.mock('@/api/chat/chatbot', () => ({ + askIncidentAssistant: (...args: unknown[]) => mockAskIncidentAssistant(...args), +})); + +jest.mock('@/stores/offline-queue/store', () => ({ + useOfflineQueueStore: { + getState: jest.fn(() => ({ isConnected: mockOnline, isNetworkReachable: mockOnline })), + }, +})); + +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60_000).toISOString(); + +const mockBoard = { + Command: { + IncidentCommandId: 'cmd-1', + DepartmentId: 1, + CallId: 42, + EstablishedByUserId: 'user-1', + EstablishedOn: minutesAgo(15), + CurrentCommanderUserId: 'user-1', + IcsLevel: 1, + Status: 0, + }, + Nodes: [], + Assignments: [], + Objectives: [], + Needs: [], + Timers: [], + Annotations: [], + Accountability: [{ UserId: 'user-2', FullName: 'Dana Cross', NeedsCheckIn: true, MinutesRemaining: -4, Status: 'Critical', DurationMinutes: 20, WarningThresholdMinutes: 5 }], + Roles: [], + Notes: [], +}; + +jest.mock('@/stores/command/store', () => ({ + useCommandStore: { + getState: jest.fn(() => ({ boards: { '42': { callId: '42', board: mockBoard, adHocUnits: [], adHocPersonnel: [], isProvisional: false, lastRefreshed: null, timeline: [] } } })), + }, +})); + +jest.mock('@/stores/calls/store', () => ({ + useCallsStore: { + getState: jest.fn(() => ({ calls: [{ CallId: '42', Name: 'Structure fire', Number: '26-1', Address: '123 Main St', Type: 'Structure Fire', Nature: 'Smoke showing' }] })), + }, +})); + +jest.mock('@/stores/app/core-store', () => ({ + useCoreStore: { getState: jest.fn(() => ({ activeCall: null })) }, +})); + +jest.mock('@/stores/roles/store', () => ({ + useRolesStore: { getState: jest.fn(() => ({ users: [{ UserId: 'user-1', FirstName: 'Alex', LastName: 'Reed' }] })) }, +})); + +jest.mock('@/stores/units/store', () => ({ + useUnitsStore: { getState: jest.fn(() => ({ units: [] })) }, +})); + +// eslint-disable-next-line import/first +import { useIncidentAssistantStore } from '../assistant-store'; + +const t = ((key: string, options?: Record): string => { + const value = key.split('.').reduce((node, part) => (node && typeof node === 'object' ? (node as Record)[part] : undefined), en); + if (typeof value !== 'string') { + return key; + } + return value.replace(/{{(\w+)}}/g, (_match, name: string) => String(options?.[name] ?? '')); +}) as unknown as TFunction; + +const messages = () => useIncidentAssistantStore.getState().messagesByCallId['42'] ?? []; + +describe('incident assistant store', () => { + beforeEach(() => { + mockOnline = true; + mockAskIncidentAssistant.mockReset(); + useIncidentAssistantStore.setState({ messagesByCallId: {}, askingCallId: null }); + }); + + it('answers a recognized question on-device and never calls the server', async () => { + await act(async () => { + await useIncidentAssistantStore.getState().ask('42', 'PAR', t); + }); + + const conversation = messages(); + expect(conversation).toHaveLength(2); + expect(conversation[0]).toMatchObject({ role: 'user', text: 'PAR' }); + expect(conversation[1]).toMatchObject({ role: 'assistant', source: 'device' }); + expect(conversation[1].text).toContain('Dana Cross'); + expect(mockAskIncidentAssistant).not.toHaveBeenCalled(); + }); + + it('sends a free-form question to the server with the incident scoped to the open board', async () => { + mockAskIncidentAssistant.mockResolvedValue({ Answer: 'Winds are out of the northwest at 12 mph.', Processed: true }); + + await act(async () => { + await useIncidentAssistantStore.getState().ask('42', 'should I move staging because of the wind shift?', t); + }); + + expect(mockAskIncidentAssistant).toHaveBeenCalledWith(42, 'should I move staging because of the wind shift?'); + expect(messages()[1]).toMatchObject({ source: 'server', text: 'Winds are out of the northwest at 12 mph.' }); + }); + + it('answers from the device instead of failing when the server call throws', async () => { + mockAskIncidentAssistant.mockRejectedValue(new Error('network down')); + + await act(async () => { + await useIncidentAssistantStore.getState().ask('42', 'can you get me an accountability rundown', t); + }); + + expect(messages()[1]).toMatchObject({ source: 'device' }); + expect(messages()[1].text).toContain('Dana Cross'); + }); + + it('says plainly what it cannot answer offline rather than pretending', async () => { + mockOnline = false; + + await act(async () => { + await useIncidentAssistantStore.getState().ask('42', 'what is the wind doing', t); + }); + + expect(mockAskIncidentAssistant).not.toHaveBeenCalled(); + expect(messages()[1]).toMatchObject({ isError: true }); + expect(messages()[1].text).toContain("can't answer that without a connection"); + }); + + it('still answers board questions offline', async () => { + mockOnline = false; + + await act(async () => { + await useIncidentAssistantStore.getState().ask('42', 'incident status', t); + }); + + expect(mockAskIncidentAssistant).not.toHaveBeenCalled(); + expect(messages()[1]).toMatchObject({ source: 'device' }); + expect(messages()[1].text).toContain('Command running 15m'); + }); + + it('keeps a separate conversation per incident and clears only the one asked for', async () => { + await act(async () => { + await useIncidentAssistantStore.getState().ask('42', 'PAR', t); + }); + + expect(messages()).toHaveLength(2); + + act(() => useIncidentAssistantStore.getState().clear('42')); + expect(messages()).toHaveLength(0); + }); + + it('suggests the questions matching the incident type inferred from the call', () => { + const suggestions = useIncidentAssistantStore.getState().suggestions('42'); + + // The call is a structure fire, so the RIT prompt is offered. + expect(suggestions.map((s) => s.question)).toContain('Do I have a RIT?'); + }); +}); diff --git a/src/stores/command/assistant-store.ts b/src/stores/command/assistant-store.ts new file mode 100644 index 0000000..b02bce4 --- /dev/null +++ b/src/stores/command/assistant-store.ts @@ -0,0 +1,177 @@ +import { type TFunction } from 'i18next'; +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { askIncidentAssistant } from '@/api/chat/chatbot'; +import { logger } from '@/lib/logging'; +import { zustandStorage } from '@/lib/storage'; +import { uuidv4 } from '@/lib/utils'; +import { answerIncidentQuestionLocally, type IncidentAnswerContext, type IncidentSuggestion, suggestionsForIncident } from '@/services/incident-assistant'; +import { useCoreStore } from '@/stores/app/core-store'; +import { useCallsStore } from '@/stores/calls/store'; +import { useCommandStore } from '@/stores/command/store'; +import { useOfflineQueueStore } from '@/stores/offline-queue/store'; +import { useRolesStore } from '@/stores/roles/store'; +import { useUnitsStore } from '@/stores/units/store'; + +/** Where an answer came from — surfaced in the UI so the commander knows what they're reading. */ +export type AssistantAnswerSource = 'device' | 'server'; + +export interface AssistantMessage { + id: string; + role: 'user' | 'assistant'; + text: string; + createdOn: string; + source?: AssistantAnswerSource; + /** True when the assistant couldn't answer, so the UI can style it as a failure rather than a fact. */ + isError?: boolean; +} + +interface IncidentAssistantState { + /** Conversation per incident — an IC running several boards keeps a separate thread on each. */ + messagesByCallId: Record; + /** Call id currently awaiting an answer, or null. */ + askingCallId: string | null; + + /** + * Ask a question about one incident. Answers on-device when the deterministic matcher recognizes + * it (instant, works with no signal), and falls back to Resgrid Core for live weather and + * free-form questions the matcher doesn't cover. + */ + ask: (callId: string, question: string, t: TFunction) => Promise; + /** One-tap prompts for the incident, from its inferred ICS playbook. */ + suggestions: (callId: string) => IncidentSuggestion[]; + clear: (callId: string) => void; +} + +const isOffline = () => { + const queue = useOfflineQueueStore.getState(); + return !queue.isConnected || !queue.isNetworkReachable; +}; + +/** + * Assembles everything the on-device answers read. Every source here is MMKV-persisted, so this + * works unchanged with no connection — the board is simply as fresh as the last sync. + */ +export const buildAnswerContext = (callId: string): IncidentAnswerContext => { + const boardState = useCommandStore.getState().boards[callId]; + const calls = useCallsStore.getState().calls; + const activeCall = useCoreStore.getState().activeCall; + const call = calls.find((c) => c.CallId === callId) ?? (activeCall?.CallId === callId ? activeCall : null); + const users = useRolesStore.getState().users; + const units = useUnitsStore.getState().units; + + return { + board: boardState?.board ?? null, + adHocUnits: boardState?.adHocUnits ?? [], + adHocPersonnel: boardState?.adHocPersonnel ?? [], + timeline: boardState?.timeline ?? [], + callName: call?.Name ?? null, + callNumber: call?.Number ?? null, + callAddress: call?.Address ?? null, + callType: call?.Type ?? null, + callNature: call?.Nature ?? null, + resolveUserName: (userId: string) => { + const user = users.find((u) => u.UserId === userId); + return user ? `${user.FirstName} ${user.LastName}`.trim() : userId; + }, + resolveUnitName: (unitId: string) => units.find((u) => u.UnitId === unitId)?.Name ?? unitId, + }; +}; + +const toNumericCallId = (callId: string): number => { + const parsed = parseInt(callId, 10); + return Number.isNaN(parsed) ? 0 : parsed; +}; + +const message = (role: AssistantMessage['role'], text: string, extra?: Partial): AssistantMessage => ({ + id: uuidv4(), + role, + text, + createdOn: new Date().toISOString(), + ...extra, +}); + +export const useIncidentAssistantStore = create()( + persist( + (set, get) => ({ + messagesByCallId: {}, + askingCallId: null, + + ask: async (callId, question, t) => { + const trimmed = question.trim(); + if (!trimmed) { + return; + } + + const append = (...entries: AssistantMessage[]) => + set((state) => ({ + messagesByCallId: { ...state.messagesByCallId, [callId]: [...(state.messagesByCallId[callId] ?? []), ...entries] }, + })); + + append(message('user', trimmed)); + set({ askingCallId: callId }); + + try { + const context = buildAnswerContext(callId); + const local = answerIncidentQuestionLocally(trimmed, context, t); + + // A confident on-device match is answered on-device even with a connection: it is instant, + // costs nothing, and says exactly what the server would say. + if (!local.requiresServer && local.confidence >= 1 && local.answer) { + append(message('assistant', local.answer, { source: 'device' })); + return; + } + + if (isOffline()) { + // No signal: the device's best effort, or an honest "not without a connection". + append(local.answer ? message('assistant', local.answer, { source: 'device' }) : message('assistant', t('incident_assistant.offline_cannot_answer'), { source: 'device', isError: true })); + return; + } + + const numericCallId = toNumericCallId(callId); + const result = await askIncidentAssistant(numericCallId, trimmed); + const answer = result?.Answer?.trim(); + + if (answer) { + append(message('assistant', answer, { source: 'server', isError: result?.Processed === false })); + return; + } + + append(local.answer ? message('assistant', local.answer, { source: 'device' }) : message('assistant', t('incident_assistant.no_answer'), { source: 'server', isError: true })); + } catch (error) { + logger.error({ message: 'Incident assistant question failed', context: { error, callId } }); + + // The server round-trip is the failure-prone half; fall back to whatever the device knows + // rather than leaving the commander with nothing. + let fallback: string | null = null; + try { + fallback = answerIncidentQuestionLocally(trimmed, buildAnswerContext(callId), t).answer; + } catch (localError) { + logger.warn({ message: 'Incident assistant local fallback failed', context: { error: localError, callId } }); + } + + append(fallback ? message('assistant', fallback, { source: 'device' }) : message('assistant', t('incident_assistant.error'), { isError: true })); + } finally { + set({ askingCallId: null }); + } + }, + + suggestions: (callId) => suggestionsForIncident(buildAnswerContext(callId)), + + clear: (callId) => + set((state) => { + const next = { ...state.messagesByCallId }; + delete next[callId]; + return { messagesByCallId: next }; + }), + }), + { + name: 'incident-assistant-storage', + storage: createJSONStorage(() => zustandStorage), + // Conversations persist so an answer read before losing signal is still there; the in-flight + // flag is transient and must not survive a restart as a stuck spinner. + partialize: (state) => ({ messagesByCallId: state.messagesByCallId }), + } + ) +); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index c5f2795..f7149dd 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -3,7 +3,7 @@ import { create } from 'zustand'; import { useAuthStore } from '@/lib'; import { Env } from '@/lib/env'; import { logger } from '@/lib/logging'; -import { signalRService } from '@/services/signalr.service'; +import { SignalRService, signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; import { useChatStore } from '../chat/store'; @@ -32,7 +32,28 @@ const CHAT_HUB_METHODS = [ ]; // Track registered chat handlers for cleanup and the heartbeat timer. -const chatHubHandlers: Record void) | null> = {}; +// Hub methods can send several positional arguments, so handlers are variadic. +const chatHubHandlers: Record void) | null> = {}; +const CHAT_ARM_RETRY_MS = 5000; +const CHAT_ARM_MAX_ATTEMPTS = 3; +// The hub replays a full resync on arm; collapse the duplicate that arrives when the +// server echoes its own onChatConnected right after ours. Scoped to a single connection — +// a disconnect clears the marker so the next one resyncs immediately. +const CHAT_RESYNC_DEBOUNCE_MS = 2000; + +let chatArmRetryTimer: ReturnType | null = null; +let chatArmAttempts = 0; +// The arm in flight, shared by the reconnect handler and the connectChatHub fallback so a +// fresh connection announces itself exactly once. +let chatArmOperation: Promise | null = null; +let lastChatResyncAt = 0; + +function stopChatArmRetry(): void { + if (chatArmRetryTimer) { + clearTimeout(chatArmRetryTimer); + chatArmRetryTimer = null; + } +} let chatHeartbeatTimer: ReturnType | null = null; const CHAT_HEARTBEAT_INTERVAL_MS = 45000; @@ -53,6 +74,77 @@ function stopChatHeartbeat(): void { } } +function resyncChat(): void { + const now = Date.now(); + if (now - lastChatResyncAt < CHAT_RESYNC_DEBOUNCE_MS) return; + lastChatResyncAt = now; + useChatStore.getState().handleChatConnected(); +} + +/** + * Announce this connection to the chat hub and restart the heartbeat. + * + * The hub only places a connection into its channel groups in response to `Connect`, and + * every reconnect issues a fresh connection id. Without re-arming, the websocket stays + * open but the client receives nothing. + */ +async function runChatArm(): Promise { + stopChatArmRetry(); + + try { + await signalRService.invoke(Env.CHAT_HUB_NAME, 'Connect'); + } catch (error) { + chatArmAttempts += 1; + logger.warn({ + message: 'Failed to announce presence to chat hub', + context: { error, attempt: chatArmAttempts, maxAttempts: CHAT_ARM_MAX_ATTEMPTS }, + }); + if (chatArmAttempts < CHAT_ARM_MAX_ATTEMPTS) { + chatArmRetryTimer = setTimeout(() => { + void armChatSession(); + }, CHAT_ARM_RETRY_MS); + } + throw error; + } + + chatArmAttempts = 0; + + stopChatHeartbeat(); + chatHeartbeatTimer = setInterval(() => { + signalRService.invoke(Env.CHAT_HUB_NAME, 'Heartbeat').catch(() => { + // Heartbeat is best-effort; ignore transient failures. + }); + }, CHAT_HEARTBEAT_INTERVAL_MS); + + resyncChat(); +} + +/** + * Serializes arming per connection: the reconnect handler and connectChatHub both reach + * for an arm on a fresh socket, and the reconnect one parks on the connection lock, so + * without sharing the operation the second issues a duplicate `Connect` and the two runs + * race each other's retry timer. + * + * `resetAttempts` accompanies a new connection id, which always deserves a full budget. + */ +function armChatSession(options?: { resetAttempts?: boolean }): Promise { + if (options?.resetAttempts) { + chatArmAttempts = 0; + } + + if (chatArmOperation) { + return chatArmOperation; + } + + const operation = runChatArm().finally(() => { + if (chatArmOperation === operation) { + chatArmOperation = null; + } + }); + chatArmOperation = operation; + return operation; +} + /** Minimal shape of the SignalR weather alert payload. The server sends * WeatherAlertId as the primary identifier, matching WeatherAlertResultData. */ interface WeatherAlertSignalRMessage { @@ -448,7 +540,7 @@ export const useSignalRStore = create((set, get) => ({ }); const chat = useChatStore.getState(); - const handlerMap: Record void> = { + const handlerMap: Record void> = { chatMessageReceived: chat.handleMessageReceived, chatMessageEdited: chat.handleMessageEdited, chatMessageDeleted: chat.handleMessageDeleted, @@ -466,7 +558,7 @@ export const useSignalRStore = create((set, get) => ({ }; Object.entries(handlerMap).forEach(([event, handler]) => { - const wrapped = (data: unknown) => handler(data); + const wrapped = (...args: unknown[]) => handler(...args); chatHubHandlers[event] = wrapped; signalRService.on(event, wrapped); }); @@ -474,21 +566,41 @@ export const useSignalRStore = create((set, get) => ({ const onChatConnected = () => { logger.info({ message: 'Connected to chat SignalR hub' }); set({ isChatHubConnected: true, error: null }); - useChatStore.getState().handleChatConnected(); + resyncChat(); }; chatHubHandlers.onChatConnected = onChatConnected; signalRService.on('onChatConnected', onChatConnected); - // Announce chat presence to the hub, then begin the periodic heartbeat. - await signalRService.invoke(Env.CHAT_HUB_NAME, 'Connect'); - set({ isChatHubConnected: true }); + // A dropped transport reconnects with a fresh connection id that belongs to no + // channel groups, so it has to announce itself again or the socket stays open and + // silent. + const chatReconnected = `${SignalRService.HUB_RECONNECTED_EVENT}:${Env.CHAT_HUB_NAME}`; + const chatDisconnected = `${SignalRService.HUB_DISCONNECTED_EVENT}:${Env.CHAT_HUB_NAME}`; - stopChatHeartbeat(); - chatHeartbeatTimer = setInterval(() => { - signalRService.invoke(Env.CHAT_HUB_NAME, 'Heartbeat').catch(() => { - // Heartbeat is best-effort; ignore transient failures. + const onChatReconnected = () => { + void armChatSession({ resetAttempts: true }).catch(() => { + // runChatArm already logged and scheduled its retry. }); - }, CHAT_HEARTBEAT_INTERVAL_MS); + }; + chatHubHandlers[chatReconnected] = onChatReconnected; + signalRService.on(chatReconnected, onChatReconnected); + + const onChatDisconnected = () => { + stopChatHeartbeat(); + stopChatArmRetry(); + // The debounce only guards duplicates within one connection; carrying the marker + // across the gap would swallow the resync that backfills the outage. + lastChatResyncAt = 0; + // Clearing the flag is what lets connectChatHub repair the session later; while it + // stayed true the hub could never be re-announced. + set({ isChatHubConnected: false }); + }; + chatHubHandlers[chatDisconnected] = onChatDisconnected; + signalRService.on(chatDisconnected, onChatDisconnected); + + // Announce chat presence to the hub, then begin the periodic heartbeat. + await armChatSession({ resetAttempts: true }); + set({ isChatHubConnected: true }); logger.info({ message: 'Chat hub handlers registered successfully' }); } catch (error) { @@ -500,6 +612,9 @@ export const useSignalRStore = create((set, get) => ({ disconnectChatHub: async () => { try { stopChatHeartbeat(); + stopChatArmRetry(); + chatArmAttempts = 0; + lastChatResyncAt = 0; unregisterChatHubHandlers(); await signalRService.disconnectFromHub(Env.CHAT_HUB_NAME); set({ isChatHubConnected: false }); diff --git a/src/translations/ar.json b/src/translations/ar.json index 5cdc820..7f68037 100644 --- a/src/translations/ar.json +++ b/src/translations/ar.json @@ -951,6 +951,146 @@ "invalid_url": "يرجى إدخال عنوان URL صالح يبدأ بـ http:// أو https://", "required": "هذا الحقل مطلوب" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "متابعة الأفراد", "active_badge": "نشط", diff --git a/src/translations/de.json b/src/translations/de.json index 3dad91b..39bc394 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -951,6 +951,146 @@ "invalid_url": "Bitte eine gültige URL eingeben, die mit http:// oder https:// beginnt", "required": "Dieses Feld ist erforderlich" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "Anwesenheitskontrolle", "active_badge": "Aktiv", diff --git a/src/translations/en.json b/src/translations/en.json index 52d727c..b1b0952 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -951,6 +951,146 @@ "invalid_url": "Please enter a valid URL starting with http:// or https://", "required": "This field is required" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "Accountability", "active_badge": "Active", diff --git a/src/translations/es.json b/src/translations/es.json index e25687a..85d3bc2 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -951,6 +951,146 @@ "invalid_url": "Por favor, introduce una URL válida que comience con http:// o https://", "required": "Este campo es obligatorio" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "Control de personal", "active_badge": "Activo", diff --git a/src/translations/fr.json b/src/translations/fr.json index 473cf62..80e8d4b 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -951,6 +951,146 @@ "invalid_url": "Veuillez saisir une URL valide commençant par http:// ou https://", "required": "Ce champ est obligatoire" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "Contrôle du personnel", "active_badge": "Actif", diff --git a/src/translations/it.json b/src/translations/it.json index fae4100..ba08c20 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -951,6 +951,146 @@ "invalid_url": "Inserisci un URL valido che inizia con http:// o https://", "required": "Questo campo è obbligatorio" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "Controllo del personale", "active_badge": "Attivo", diff --git a/src/translations/pl.json b/src/translations/pl.json index e8918e3..4d03412 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -951,6 +951,146 @@ "invalid_url": "Wpisz prawidłowy URL zaczynający się od http:// lub https://", "required": "To pole jest wymagane" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "Kontrola personelu", "active_badge": "Aktywny", diff --git a/src/translations/sv.json b/src/translations/sv.json index 50d1b8a..aa6e8c4 100644 --- a/src/translations/sv.json +++ b/src/translations/sv.json @@ -951,6 +951,146 @@ "invalid_url": "Ange en giltig URL som börjar med http:// eller https://", "required": "Detta fält är obligatoriskt" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "Personalkontroll", "active_badge": "Aktiv", diff --git a/src/translations/uk.json b/src/translations/uk.json index 0379f39..a03a91c 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -951,6 +951,146 @@ "invalid_url": "Введіть правильний URL, що починається з http:// або https://", "required": "Це поле обов'язкове" }, + "incident_assistant": { + "briefing_accountability": "ACCOUNTABILITY", + "briefing_action_plan": "Action plan: {{text}}", + "briefing_address": "Location: {{address}}", + "briefing_command": "COMMAND", + "briefing_commander": "Incident Commander: {{name}}", + "briefing_established": "Command established: {{time}} (running {{duration}})", + "briefing_header": "TRANSFER OF COMMAND BRIEFING — {{incident}}", + "briefing_icp": "Command post: {{location}}", + "briefing_important": "Important information: {{text}}", + "briefing_lane_row": "{{lane}} (lead: {{lead}}) — {{count}} resources: {{names}}", + "briefing_needs": "OUTSTANDING NEEDS", + "briefing_no_lanes": "- No lanes established", + "briefing_no_needs": "- None", + "briefing_no_objectives": "- No objectives recorded", + "briefing_no_par": "No personnel accountability is being tracked.", + "briefing_objectives": "OBJECTIVES", + "briefing_organization": "ORGANIZATION AND RESOURCES", + "briefing_rehab": "Rehab: {{location}}", + "briefing_situation": "SITUATION", + "briefing_staging": "Staging: {{location}}", + "briefing_type": "Incident type: {{type}}", + "check_action_plan": "Action plan or objectives recorded", + "check_command": "Command established with a named IC", + "check_icp": "Command post location set", + "check_par": "Accountability / check-in running", + "check_safety": "Safety Officer assigned", + "check_staging": "Staging designated", + "checklist_confirm": "Standard {{type}} items to confirm:", + "checklist_disclaimer": "This is general ICS guidance, not your department's policy — your SOGs and your judgement come first.", + "checklist_done": "Already done on the board: {{items}}.", + "checklist_header": "{{type}} checklist for {{incident}}.", + "checklist_outstanding": "Not showing on the board yet:", + "clear": "Clear conversation", + "command_post": "ICP: {{location}}.", + "commander": "IC: {{name}}.", + "elapsed": "Command running {{duration}}.", + "empty": "Ask about this incident — accountability, resources, objectives, needs, positions, or what you might be missing.", + "error": "Something went wrong answering that. Try again.", + "estimated_end": "Estimated end: {{time}}.", + "external_resources": "- External / mutual aid resources tracked: {{count}}", + "important": "Important: {{text}}", + "lane_empty": "Nothing is assigned to this lane.", + "lane_header": "{{lane}} ({{type}}): {{count}} resources.", + "lane_lead": "Lead: {{name}}.", + "lane_line": "- {{lane}}: {{count}} — {{names}}", + "lane_not_found": "I don't see a lane called \"{{lane}}\". Lanes on the board: {{lanes}}", + "lane_objective": "Primary objective: {{name}} ({{progress}}%).", + "missing_benchmarks": "Common {{type}} benchmarks not on the board yet: {{benchmarks}}", + "need_row": "- {{name}} [{{category}}] {{quantity}} — open {{age}}", + "needs_all_met": "Everything ordered has been filled.", + "needs_header": "{{incident}}: {{outstanding}} needs outstanding ({{met}} met, {{cancelled}} cancelled).", + "no_answer": "I couldn't answer that one.", + "no_board": "No command board is loaded for this incident yet.", + "no_lanes": "none yet", + "no_lanes_yet": "No lanes have been created on {{incident}} yet, so there's no span of control to check.", + "no_lead": "no lead", + "no_needs": "No needs have been recorded on {{incident}}.", + "no_notes": "No status notes have been recorded on {{incident}}.", + "no_objectives": "No tactical objectives have been set on {{incident}} yet.", + "no_resources": "Nothing is assigned to {{incident}} yet.", + "no_rit": "I don't see a RIT/RIC lane on {{incident}}. On a working incident that's worth assigning before crews go interior.", + "no_timers": "No timers are running on {{incident}}.", + "no_unassigned": "Everything tracked on {{incident}} is placed in a lane.", + "note_row": "- {{time}}: {{body}} ({{who}})", + "notes_header": "{{incident}} — {{count}} status note(s):", + "objective_complete": "complete", + "objective_in_progress": "in progress", + "objective_overdue": " [past target]", + "objective_pending": "pending", + "objective_row": "{{name}} — {{status}} ({{progress}}%){{overdue}}", + "objective_summary": "Objectives: {{complete}} of {{total}} complete.", + "objectives_all_complete": "Every objective on the board is complete.", + "objectives_header": "{{incident}}: {{complete}} of {{total}} objectives complete. Still open:", + "offline_badge": "Offline", + "offline_cannot_answer": "I can't answer that without a connection. Offline I can still give you PAR, resources, objectives, needs, ICS positions, timers, the incident log, a briefing, and the checklist for this incident type.", + "offline_hint": "No connection — answers come from the board cached on this device.", + "open_needs": "{{count}} needs still open.", + "par_all_good": "Everyone is accounted for.", + "par_counts": "{{green}} green, {{warning}} approaching, {{critical}} overdue.", + "par_critical_header": "Overdue — not accounted for:", + "par_due_row": "- {{name}}: due in {{minutes}} min", + "par_header": "PAR for {{incident}}: {{count}} personnel tracked.", + "par_none": "No personnel accountability is being tracked on {{incident}} — nobody has checked in and no check-in timer is running.", + "par_overdue_row": "- {{name}}: {{minutes}} min overdue", + "par_summary": "PAR: {{total}} tracked, {{critical}} overdue, {{warning}} approaching.", + "par_warning_header": "Approaching check-in:", + "placeholder": "Ask about this incident", + "resource_counts": "{{units}} units and {{personnel}} personnel across {{lanes}} lanes ({{unassigned}} unassigned).", + "resources_header": "{{incident}}: {{units}} units and {{personnel}} personnel working.", + "rit_found": "{{lane}} is standing by with {{count}} resource(s).", + "role_filled": "{{role}}: {{name}}", + "role_row": "{{role}}: {{name}}", + "role_unfilled": "No {{role}} is assigned on {{incident}}.", + "role_unknown": "I don't recognize the position \"{{role}}\". Ask for ICS roles to see everything assigned.", + "roles_header": "{{incident}}: {{count}} ICS position(s) assigned.", + "roles_unfilled": "Unfilled positions a {{type}} usually needs: {{roles}}", + "send": "Send", + "source_device": "Answered on this device", + "source_server": "Answered by Resgrid", + "span_all_good": "Span of control looks reasonable — every lane is within its limits and no lane is over {{ceiling}} resources.", + "span_header": "{{incident}}: {{lanes}} lanes, {{resources}} resources assigned.", + "span_no_lead": "- No lead assigned: {{lanes}}", + "span_over_row": "- {{lane}} is carrying {{count}} resources (limit {{limit}}) — consider splitting it or adding a supervisor.", + "span_under_row": "- {{lane}} has {{count}} resources against a minimum of {{minimum}}.", + "staging": "Staging: {{location}}.", + "suggestions": { + "briefing": "Transfer of command", + "missing": "What am I missing?", + "on_scene": "What's on scene?", + "open_needs": "Open needs", + "open_objectives": "Open objectives", + "par": "PAR", + "recent": "Last 30 minutes", + "rit": "Do I have a RIT?", + "safety": "Safety Officer", + "span": "Span of control", + "status": "Incident status", + "timers": "Timers", + "unassigned": "Unassigned", + "unfilled_roles": "Unfilled ICS positions", + "wind": "Wind and weather" + }, + "thinking": "Checking the board…", + "this_incident": "this incident", + "time_in_lane": "({{duration}} in lane)", + "timeline_empty": "Nothing has been logged on {{incident}} yet.", + "timeline_empty_window": "Nothing has been logged in the last {{minutes}} minutes on {{incident}}.", + "timeline_header": "{{incident}} — last {{count}} log entries:", + "timeline_row": "- {{time}}: {{description}}{{who}}", + "timeline_window_header": "{{incident}} — last {{minutes}} minutes ({{count}} entries):", + "timer_due_row": "- {{name}}: DUE NOW", + "timer_no_due_row": "- {{name}}: running", + "timer_running_row": "- {{name}}: due in {{remaining}}", + "timers_header": "{{incident}}: {{count}} timer(s).", + "title": "Incident Assistant", + "unassigned_header": "Unassigned on {{incident}} ({{count}}):", + "unassigned_line": "- Unassigned pool: {{count}}", + "unknown": "unknown" + }, "incidents": { "accountability": "Облік персоналу", "active_badge": "Активний",