Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
730 changes: 12 additions & 718 deletions global.css

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions global.web.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/preflight.css' layer(base);
@import 'tailwindcss/utilities.css';
@import 'nativewind/theme';

@import './theme-tokens.css';

/* Web dark mode: the .dark class GluestackUIProvider puts on <html> (see index.web.tsx). It is
always present — explicit modes set .dark/.light directly, and system mode sets one from the
media query — so the class alone is authoritative. Matching prefers-color-scheme here as well
would make an explicit Light choice render dark utilities on a dark-themed OS. */
@custom-variant dark (&:where(.dark, .dark *));
1 change: 0 additions & 1 deletion gluestack-ui.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
{
"tailwind": {
"config": "tailwind.config.js",
"css": "global.css"
},
"app": {
Expand Down
7 changes: 6 additions & 1 deletion src/app/(app)/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPr
</Avatar>
);
}
const isIncident = channel.ChannelType === ChatChannelType.Incident || channel.ChannelType === ChatChannelType.IncidentLane || channel.ChannelType === ChatChannelType.IncidentCommand;
const isIncident =
channel.ChannelType === ChatChannelType.Incident ||
channel.ChannelType === ChatChannelType.IncidentLane ||
channel.ChannelType === ChatChannelType.IncidentCommand ||
channel.ChannelType === ChatChannelType.IncidentLeads ||
channel.ChannelType === ChatChannelType.IncidentDispatch;
const Icon = channel.ChannelType === ChatChannelType.Chatbot ? Sparkles : isIncident ? Network : Users;
return (
<Box className="size-10 items-center justify-center rounded-full bg-primary-100">
Expand Down
4 changes: 2 additions & 2 deletions src/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Import global CSS file
import '../../global.css';
// Import global CSS (platform-specific entry: global.css on native, global.web.css on web)
import '../lib/theme-styles';
import '../lib/i18n';
// Side-effect import: registers the full app-data wipe as the session-cleanup
// handler for every logout path (manual, forced 401, refresh rejection).
Expand Down
27 changes: 26 additions & 1 deletion src/components/chat/__tests__/chat-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type TFunction } from 'i18next';

import { ChatChannelType, type ChatChannelResultData } from '@/models/v4/chat';

import { copyToClipboard, getChannelDisplayName, getImageMimeType, hasLink, linkifySegments } from '../chat-utils';
import { copyToClipboard, getChannelDisplayName, getImageMimeType, groupChannels, hasLink, linkifySegments } from '../chat-utils';

jest.mock('expo-clipboard', () => ({ setStringAsync: jest.fn() }));

Expand Down Expand Up @@ -34,6 +34,31 @@ describe('chat-utils', () => {
});
});

describe('groupChannels', () => {
it('buckets every incident-scoped channel type into the incidents section', () => {
const grouped = groupChannels([
buildChannel({ ChatChannelId: 'a', ChannelType: ChatChannelType.Incident }),
buildChannel({ ChatChannelId: 'b', ChannelType: ChatChannelType.IncidentLane }),
buildChannel({ ChatChannelId: 'c', ChannelType: ChatChannelType.IncidentCommand }),
buildChannel({ ChatChannelId: 'd', ChannelType: ChatChannelType.IncidentLeads }),
buildChannel({ ChatChannelId: 'e', ChannelType: ChatChannelType.IncidentDispatch }),
]);
expect(grouped.incidents.map((c) => c.ChatChannelId).sort()).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(grouped.channels).toHaveLength(0);
});

it('buckets the unit dispatch line into the channels section', () => {
const grouped = groupChannels([buildChannel({ ChatChannelId: 'ud', ChannelType: ChatChannelType.UnitDispatch })]);
expect(grouped.channels.map((c) => c.ChatChannelId)).toEqual(['ud']);
expect(grouped.incidents).toHaveLength(0);
});

it('skips archived channels', () => {
const grouped = groupChannels([buildChannel({ ChatChannelId: 'x', ChannelType: ChatChannelType.Incident, IsArchived: true })]);
expect(grouped.incidents).toHaveLength(0);
});
});

describe('getImageMimeType', () => {
it('prefers the picker asset mimeType when available', () => {
expect(getImageMimeType('file:///photos/photo.jpg', 'image/png')).toBe('image/png');
Expand Down
2 changes: 2 additions & 0 deletions src/components/chat/chat-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export function groupChannels(channels: ChatChannelResultData[]): GroupedChannel
case ChatChannelType.Incident:
case ChatChannelType.IncidentLane:
case ChatChannelType.IncidentCommand:
case ChatChannelType.IncidentLeads:
case ChatChannelType.IncidentDispatch:
grouped.incidents.push(channel);
break;
case ChatChannelType.Chatbot:
Expand Down
20 changes: 10 additions & 10 deletions src/components/ui/gluestack-ui-provider/index.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
'use client';
import { OverlayProvider } from '@gluestack-ui/core/overlay/creator';
import { ToastProvider } from '@gluestack-ui/core/toast/creator';
import React, { useEffect } from 'react';
import { Appearance, useColorScheme, View, type ViewProps } from 'react-native';
import React, { useLayoutEffect } from 'react';
import { Appearance, View, type ViewProps } from 'react-native';

export type ModeType = 'light' | 'dark' | 'system';

export function GluestackUIProvider({ mode = 'light', ...props }: { mode?: ModeType; children?: React.ReactNode; style?: ViewProps['style'] }) {
// Tokens (--color-*) flip through the prefers-color-scheme media query,
// which react-native-css drives from Appearance. The className wrapper
// drives the class-based `dark:` variant (see @custom-variant in global.css).
const osScheme = useColorScheme();
const resolvedScheme: 'light' | 'dark' = mode === 'system' ? (osScheme === 'dark' ? 'dark' : 'light') : mode;

useEffect(() => {
// Both the tokens (--color-*) and the `dark:` variant flip through the prefers-color-scheme media
// query, which react-native-css drives from Appearance — hence the override below.
useLayoutEffect(() => {
Appearance.setColorScheme(mode === 'system' ? 'unspecified' : mode);
}, [mode]);

// This View deliberately carries NO className. It wraps the entire app, and react-native-css wraps
// any classed component in an element of its own; with a class here every native ScrollView beneath
// it stopped responding to a plain drag app-wide — only a Pressable taking the JS responder could
// scroll anything. Style it inline if it ever needs styling, and see the dark variant in global.css.
return (
<View className={resolvedScheme} style={[{ flex: 1, height: '100%', width: '100%' }, props.style]}>
<View style={[{ flex: 1, height: '100%', width: '100%' }, props.style]}>
<OverlayProvider>
<ToastProvider>{props.children}</ToastProvider>
</OverlayProvider>
Expand Down
8 changes: 6 additions & 2 deletions src/components/ui/input/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { styled } from 'nativewind';
import React from 'react';
import { Pressable, TextInput, View } from 'react-native';

import { useTextFieldVerticalFix } from '../text-field-metrics';

const SCOPE = 'INPUT';

const StyledUIIcon = styled(UIIcon, { className: 'style' });
Expand Down Expand Up @@ -60,7 +62,7 @@ const inputSlotStyle = tva({
});

const inputFieldStyle = tva({
base: 'flex-1 text-typography-900 py-0 px-3 placeholder:text-typography-500 h-full ios:leading-[0px] web:cursor-text web:data-[disabled=true]:cursor-not-allowed',
base: 'flex-1 text-typography-900 py-0 px-3 placeholder:text-typography-500 h-full web:cursor-text web:data-[disabled=true]:cursor-not-allowed',

parentVariants: {
variant: {
Expand Down Expand Up @@ -136,8 +138,9 @@ const InputSlot = React.forwardRef<React.ComponentRef<typeof UIInput.Slot>, IInp

type IInputFieldProps = React.ComponentProps<typeof UIInput.Input> & VariantProps<typeof inputFieldStyle> & { className?: string };

const InputField = React.forwardRef<React.ComponentRef<typeof UIInput.Input>, IInputFieldProps>(function InputField({ className, ...props }, ref) {
const InputField = React.forwardRef<React.ComponentRef<typeof UIInput.Input>, IInputFieldProps>(function InputField({ className, style, ...props }, ref) {
const { variant: parentVariant, size: parentSize } = useStyleContext(SCOPE);
const verticalFix = useTextFieldVerticalFix(parentSize);

return (
<UIInput.Input
Expand All @@ -150,6 +153,7 @@ const InputField = React.forwardRef<React.ComponentRef<typeof UIInput.Input>, II
},
class: className,
})}
style={[verticalFix, style]}
/>
);
});
Expand Down
8 changes: 6 additions & 2 deletions src/components/ui/select/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { styled } from 'nativewind';
import React from 'react';
import { Pressable, TextInput, View } from 'react-native';

// Mirrors the Input component: the trigger's fixed height clips `h-full` text on Android the same way.
import { useTextFieldVerticalFix } from '../text-field-metrics';
import {
Actionsheet,
ActionsheetBackdrop,
Expand Down Expand Up @@ -67,7 +69,7 @@ const selectTriggerStyle = tva({
});

const selectInputStyle = tva({
base: 'py-auto px-3 placeholder:text-typography-500 web:w-full h-full text-typography-900 pointer-events-none web:outline-none ios:leading-[0px]',
base: 'py-auto px-3 placeholder:text-typography-500 web:w-full h-full text-typography-900 pointer-events-none web:outline-none',
parentVariants: {
size: {
xl: 'text-xl',
Expand Down Expand Up @@ -146,8 +148,9 @@ const SelectTrigger = React.forwardRef<React.ComponentRef<typeof UISelect.Trigge

type ISelectInputProps = VariantProps<typeof selectInputStyle> & React.ComponentProps<typeof UISelect.Input> & { className?: string };

const SelectInput = React.forwardRef<React.ComponentRef<typeof UISelect.Input>, ISelectInputProps>(function SelectInput({ className, ...props }, ref) {
const SelectInput = React.forwardRef<React.ComponentRef<typeof UISelect.Input>, ISelectInputProps>(function SelectInput({ className, style, ...props }, ref) {
const { size: parentSize, variant: parentVariant } = useStyleContext();
const verticalFix = useTextFieldVerticalFix(parentSize);
return (
<UISelect.Input
className={selectInputStyle({
Expand All @@ -159,6 +162,7 @@ const SelectInput = React.forwardRef<React.ComponentRef<typeof UISelect.Input>,
})}
ref={ref}
{...props}
style={[verticalFix, style]}
/>
);
});
Expand Down
36 changes: 36 additions & 0 deletions src/components/ui/text-field-metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { Platform } from 'react-native';

/**
* Android field metrics, applied from JS because the class layer cannot express them correctly.
*
* Measured on device, each case a real Input/InputField:
* - the class `h-full` (height: 100%) resolves taller than the fixed-height parent on Android, and
* the parent's overflow-hidden then clips the top of the glyphs;
* - an explicit pixel height matching the parent renders correctly;
* - overriding only the lineHeight, at either the class or the style layer, does not help;
* - lineHeight 0 (what iOS uses) hides Android text completely, and a later `undefined` does not
* clear the value the size class sets.
*
* So Android gets a concrete height plus a lineHeight near the font size, and drops the extra font
* padding. iOS keeps the zero lineHeight that upstream applied through `ios:leading-[0px]`; that class
* is gone from the base style so the value can be chosen per platform here.
*/
const ANDROID_FIELD_METRICS: Record<string, { height: number; lineHeight: number }> = {
sm: { height: 36, lineHeight: 18 },
md: { height: 40, lineHeight: 20 },
lg: { height: 44, lineHeight: 22 },
xl: { height: 48, lineHeight: 25 },
};

export const useTextFieldVerticalFix = (size: string | undefined) =>
React.useMemo(() => {
if (Platform.OS === 'ios') {
return { lineHeight: 0 } as const;
}
if (Platform.OS === 'android') {
const metrics = ANDROID_FIELD_METRICS[size ?? 'md'] ?? ANDROID_FIELD_METRICS.md;
return { height: metrics.height, lineHeight: metrics.lineHeight, includeFontPadding: false, textAlignVertical: 'center' } as const;
}
return undefined;
}, [size]);
12 changes: 12 additions & 0 deletions src/hooks/use-signalr-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';

import { logger } from '@/lib/logging';
import { useIncidentCommandStore } from '@/stores/calls/incident-command-store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';

import { useAppLifecycle } from './use-app-lifecycle';
Expand Down Expand Up @@ -134,6 +135,17 @@ export function useSignalRLifecycle({ isSignedIn, hasInitialized }: UseSignalRLi
});
}
});

// The hubs replay nothing pushed while the app was away, so incident command changes made
// during the gap would stay invisible. Reconnecting restores the feed; this backfills it.
// connectUpdateHub swallows its own errors, so a fulfilled result alone does not mean the
// hub is up — the store's connected flag is the real signal.
if (results[0]?.status === 'fulfilled' && useSignalRStore.getState().isUpdateHubConnected) {
const openCallId = useIncidentCommandStore.getState().callId;
if (openCallId) {
useIncidentCommandStore.getState().handleIncidentCommandUpdated(openCallId);
}
}
} catch (error) {
logger.error({
message: 'Unexpected error during SignalR reconnect on app resume',
Expand Down
9 changes: 9 additions & 0 deletions src/lib/theme-styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Native stylesheet entry. Pairs with theme-styles.web.ts, which loads global.web.css instead.
*
* The two entries exist because the `dark:` variant cannot be shared: native drives it from the
* colour-scheme media query (it has no theme class, and adding one to the app-wide wrapper View
* breaks native scrolling), while web drives it from the .dark class on <html>. Tokens are shared
* via theme-tokens.css.
*/
import '../../global.css';
7 changes: 7 additions & 0 deletions src/lib/theme-styles.web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Web stylesheet entry. Pairs with theme-styles.ts, which loads global.css on native.
*
* Web keys the `dark:` variant off the .dark class GluestackUIProvider maintains on <html>, so an
* explicit Light choice stays light on a dark-themed OS.
*/
import '../../global.web.css';
2 changes: 2 additions & 0 deletions src/models/v4/chat/chatEnums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export enum ChatChannelType {
IncidentLeads = 9,
/** The incident's line to the dispatch desk: everyone on the incident, plus every authorized dispatcher. */
IncidentDispatch = 10,
/** A unit's standing line to the dispatch desk: the unit identity plus every authorized dispatcher. Department-wide, not call-scoped. */
UnitDispatch = 11,
}

/** Message type (ChatMessageResultData.MessageType). */
Expand Down
78 changes: 78 additions & 0 deletions src/stores/calls/__tests__/incident-command-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,4 +326,82 @@ describe('useIncidentCommandStore', () => {
unmount();
});
});

describe('handleIncidentCommandUpdated (realtime)', () => {
/**
* The IC moving resources pushes incidentCommandUpdated for the call; the crew's panel has to
* follow along without blanking, so this refetches in place rather than via fetchIncidentView.
*/
it('refreshes the view in place for the call being viewed', async () => {
const mockView = createMockView();
mockGetResourceIncidentView.mockResolvedValue({ Data: mockView, Status: 'Ok' } as any);

const { result, unmount } = renderHook(() => useIncidentCommandStore());

await act(async () => {
await result.current.fetchIncidentView('call123');
});

const updated = { ...mockView, ImportantInformation: 'Structure now unstable' };
mockGetResourceIncidentView.mockResolvedValue({ Data: updated, Status: 'Ok' } as any);
Comment on lines +337 to +346

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

test_file='src/stores/calls/__tests__/incident-command-store.test.ts'

printf '%s\n' '--- relevant test lines ---'
sed -n '1,430p' "$test_file" | nl -ba | sed -n '300,410p'

printf '%s\n' '--- incident-view symbols and result types ---'
rg -n -C 3 'getResourceIncidentView|ResourceIncidentView(Result)?|mockGetResourceIncidentView' src

printf '%s\n' '--- project TypeScript/test configuration ---'
rg -n -C 2 'strict|noImplicitAny|jest-expo|ts-jest|isolatedModules' tsconfig*.json package.json

Repository: Resgrid/Unit

Length of output: 26294


🏁 Script executed:

#!/bin/bash
set -eu

test_file='src/stores/calls/__tests__/incident-command-store.test.ts'

printf '%s\n' '--- test imports and fixtures ---'
sed -n '1,75p' "$test_file"
printf '%s\n' '--- relevant refresh tests ---'
sed -n '325,410p' "$test_file"
printf '%s\n' '--- model declarations ---'
sed -n '1,35p' src/models/v4/baseV4Request.ts
sed -n '145,230p' src/models/v4/incidentCommand/resourceIncidentView.ts
sed -n '1,25p' src/models/v4/incidentCommand/resourceIncidentViewResult.ts
printf '%s\n' '--- available TypeScript tooling ---'
command -v tsc || true
find . -maxdepth 3 -type f \( -name 'typescript.js' -o -name 'tsc' \) -print

Repository: Resgrid/Unit

Length of output: 7121


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- nested model declarations used by createMockView ---'
rg -n -A 18 -B 3 'interface (IncidentContactInfo|TacticalObjective|IncidentNeed|IncidentNote|IncidentAttachment)|class (TacticalObjective|IncidentNeed|IncidentNote|IncidentAttachment)' src/models/v4/incidentCommand/resourceIncidentView.ts

repo_root="$PWD"
cat > /tmp/incident-view-type-check.ts <<TS
import { ResourceIncidentViewResult } from '${repo_root}/src/models/v4/incidentCommand/resourceIncidentViewResult';

const view = {
  IncidentCommandId: 'ic-1',
  CallId: 123,
  Status: 0,
  EstablishedOn: '2026-07-01T10:00:00Z',
  EstimatedEndOn: null,
  ClosedOn: null,
  ImportantInformation: 'Watch for downed lines',
  IncidentActionPlan: 'Attack from the north side',
  Commander: { UserId: 'user-1', Name: 'Chief Smith', Phone: '555-1234', Email: 'chief@example.com' },
  Objectives: [],
  Needs: [],
  Notes: [],
  Attachments: [],
  MyAssignment: null,
};

const directResult = { Data: view, Status: 'Ok' };
// `@ts-expect-error`: the direct partial object must not satisfy the class result contract.
const invalidResult: ResourceIncidentViewResult = directResult;

const typedResult = Object.assign(new ResourceIncidentViewResult(), directResult);
const validResult: ResourceIncidentViewResult = typedResult;
void invalidResult;
void validResult;
TS

printf '%s\n' '--- temporary type-check ---'
tsc --noEmit --strict --skipLibCheck --target es2022 --module commonjs /tmp/incident-view-type-check.ts
rm -f /tmp/incident-view-type-check.ts

Repository: Resgrid/Unit

Length of output: 3320


🏁 Script executed:

#!/bin/bash
set -eu

repo_root="$PWD"
cat > /tmp/incident-view-type-check.ts <<TS
import { ResourceIncidentViewResult } from '${repo_root}/src/models/v4/incidentCommand/resourceIncidentViewResult';

const view = {
  IncidentCommandId: 'ic-1',
  CallId: 123,
  Status: 0,
  EstablishedOn: '2026-07-01T10:00:00Z',
  EstimatedEndOn: null,
  ClosedOn: null,
  ImportantInformation: 'Watch for downed lines',
  IncidentActionPlan: 'Attack from the north side',
  Commander: { UserId: 'user-1', Name: 'Chief Smith', Phone: '555-1234', Email: 'chief@example.com' },
  Objectives: [],
  Needs: [],
  Notes: [],
  Attachments: [],
  MyAssignment: null,
};

const directResult = { Data: view, Status: 'Ok' };
// `@ts-expect-error`: the direct partial object must not satisfy the class result contract.
const invalidResult: ResourceIncidentViewResult = directResult;

const typedResult = Object.assign(new ResourceIncidentViewResult(), directResult);
const validResult: ResourceIncidentViewResult = typedResult;
void invalidResult;
void validResult;
TS

tsc --ignoreConfig --noEmit --strict --skipLibCheck --target es2022 --module commonjs /tmp/incident-view-type-check.ts
rm -f /tmp/incident-view-type-check.ts

Repository: Resgrid/Unit

Length of output: 150


Use a typed ResourceIncidentViewResult fixture for these mocked responses.

Construct the result with new ResourceIncidentViewResult() and assign Data and Status. This preserves validation of the inherited BaseV4Request fields and the Data type instead of bypassing them with as any. Apply this at lines 337, 346, 363, and 383.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/calls/__tests__/incident-command-store.test.ts` around lines 337 -
346, Replace the mocked response objects in the incident view tests around
fetchIncidentView with typed ResourceIncidentViewResult fixtures, creating each
via new ResourceIncidentViewResult() and assigning Data and Status. Apply this
consistently to the responses at the four referenced setup points and remove the
as any casts.

Source: Coding guidelines


act(() => {
result.current.handleIncidentCommandUpdated('call123');
});

await waitFor(() => {
expect(result.current.view).toEqual(updated);
});
// The view must never blank while refreshing.
expect(result.current.isLoading).toBe(false);

unmount();
});

it('ignores an update for a different call', async () => {
const mockView = createMockView();
mockGetResourceIncidentView.mockResolvedValue({ Data: mockView, Status: 'Ok' } as any);

const { result, unmount } = renderHook(() => useIncidentCommandStore());

await act(async () => {
await result.current.fetchIncidentView('call123');
});
mockGetResourceIncidentView.mockClear();

act(() => {
result.current.handleIncidentCommandUpdated('other-call');
});

expect(mockGetResourceIncidentView).not.toHaveBeenCalled();

unmount();
});

it('keeps the current view when the refresh fails', async () => {
const mockView = createMockView();
mockGetResourceIncidentView.mockResolvedValue({ Data: mockView, Status: 'Ok' } as any);

const { result, unmount } = renderHook(() => useIncidentCommandStore());

await act(async () => {
await result.current.fetchIncidentView('call123');
});

mockGetResourceIncidentView.mockRejectedValue(new Error('network down'));

act(() => {
result.current.handleIncidentCommandUpdated('call123');
});

await waitFor(() => {
expect(mockGetResourceIncidentView).toHaveBeenCalledTimes(2);
});
// A failed background refresh must not wipe what the crew is reading.
expect(result.current.view).toEqual(mockView);

unmount();
});
});

});
Loading
Loading