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
8 changes: 8 additions & 0 deletions apps/mobile/src/app/(app)/(tabs)/(3_profile)/spend-alerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useLocalSearchParams } from 'expo-router';

import { SpendAlertsScreen } from '@/components/organization/spend-alerts-screen';

export default function SpendAlertsRoute() {
const { org } = useLocalSearchParams<{ org?: string }>();
return <SpendAlertsScreen organizationId={org} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const keyboardSubscribers = vi.hoisted(() => ({
}));

vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' }));
vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' }));
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Keyboard: {
Expand Down
81 changes: 81 additions & 0 deletions apps/mobile/src/components/notifications-screen.mounted.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ function fullCapabilities(overrides: Record<string, unknown> = {}): Record<strin
sessionStatus: { available: true, unavailableReason: null },
kiloclawActivity: { available: true, unavailableReason: null },
balanceAlerts: { available: true, unavailableReason: null },
spendAlerts: { available: true, unavailableReason: null },
securityFindings: { available: true, unavailableReason: null },
...overrides,
};
Expand All @@ -165,6 +166,7 @@ function fullPrefs(overrides: Record<string, unknown> = {}): Record<string, unkn
sessionStatus: true,
kiloclawActivity: true,
balanceAlerts: true,
spendAlerts: true,
securityFindings: true,
agentPushEnabled: true,
notificationPreviews: 'generic',
Expand Down Expand Up @@ -434,6 +436,83 @@ describe('NotificationsScreen mutation serialization (scope.id + generation guar
});
});

describe('NotificationsScreen spend alerts row', () => {
beforeEach(() => {
vi.clearAllMocks();
getNotificationPermissionStatus.mockResolvedValue('granted');
getDevicePushToken.mockResolvedValue('device-token');
pushTokensQueryFn.mockResolvedValue([{ token: 'device-token', platform: 'android' }]);
setPreferenceMutationFn.mockResolvedValue({});
registerTokenMutationFn.mockResolvedValue({ success: true });
});

it('happy: renders the row with its capability and flipping it sends exactly spendAlerts', async () => {
prefsQueryFn.mockResolvedValue(fullPrefs());
const { renderer } = await renderScreen();
await waitForEnabledSwitch(renderer, 'Spend alerts category');

// The row renders beside balance alerts, enabled because the server
// reported the spend capability available.
expect(textWithChildren(renderer.root, 'Spend alerts').length).toBe(1);
expect(switchesByLabel(renderer.root, 'Spend alerts category')[0]?.props.value).toBe(true);
expect(switchesByLabel(renderer.root, 'Spend alerts category')[0]?.props.disabled).toBe(false);

// Exactly one category key per call: the payload is what the spend view's
// own column write must agree with.
prefsQueryFn.mockResolvedValue(fullPrefs({ spendAlerts: false }));
act(() => {
switchOnValueChange(renderer.root, 'Spend alerts category')?.(false);
});
await waitFor(() => setPreferenceMutationFn.mock.calls.length === 1);
expect(setPreferenceMutationFn.mock.calls[0]?.[0]).toEqual({ spendAlerts: false });
await waitFor(
() => switchesByLabel(renderer.root, 'Spend alerts category')[0]?.props.value === false
);
expect(toastError).not.toHaveBeenCalled();
});

it('non-retryable unhappy: an unavailable spend capability disables the row and shows the server reason', async () => {
prefsQueryFn.mockResolvedValue(
fullPrefs({
capabilities: fullCapabilities({
spendAlerts: {
available: false,
unavailableReason: 'Spend alerts are unavailable.',
},
}),
})
);
const { renderer } = await renderScreen();

// Waiting on an available sibling proves the master gate settled, so the
// spend row's disabled state below is the server capability, not the gate.
await waitForEnabledSwitch(renderer, 'Chat messages');

expect(switchesByLabel(renderer.root, 'Spend alerts category')[0]?.props.disabled).toBe(true);
expect(textWithChildren(renderer.root, 'Spend alerts are unavailable.').length).toBe(1);
});

it('retryable unhappy: a spend toggle failure rolls back the optimistic flip', async () => {
prefsQueryFn.mockResolvedValue(fullPrefs());
setPreferenceMutationFn.mockRejectedValue({
data: { code: 'INTERNAL_SERVER_ERROR' },
message: 'boom',
});
const { renderer } = await renderScreen();
await waitForEnabledSwitch(renderer, 'Spend alerts category');

act(() => {
switchOnValueChange(renderer.root, 'Spend alerts category')?.(false);
});
await waitFor(() => setPreferenceMutationFn.mock.calls.length === 1);
await waitFor(() => activityIndicators(renderer.root).length === 0);

// The switch returns to the unchanged server value and the error surfaces.
expect(switchesByLabel(renderer.root, 'Spend alerts category')[0]?.props.value).toBe(true);
expect(toastError).toHaveBeenCalledWith('boom');
});
});

describe('NotificationsScreen category availability', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -598,6 +677,7 @@ describe('NotificationsScreen category availability', () => {
sessionStatus: true,
kiloclawActivity: true,
balanceAlerts: true,
spendAlerts: true,
securityFindings: true,
agentPushEnabled: true,
notificationPreviews: 'generic',
Expand All @@ -607,6 +687,7 @@ describe('NotificationsScreen category availability', () => {

expect(switchesByLabel(renderer.root, 'Chat messages')[0]?.props.disabled).toBe(false);
expect(switchesByLabel(renderer.root, 'Balance alerts')[0]?.props.disabled).toBe(false);
expect(switchesByLabel(renderer.root, 'Spend alerts category')[0]?.props.disabled).toBe(false);
expect(switchesByLabel(renderer.root, 'KiloClaw activity')[0]?.props.disabled).toBe(false);
});

Expand Down
62 changes: 44 additions & 18 deletions apps/mobile/src/components/notifications-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable max-lines -- The dedicated Notifications screen composes the master
* OS-permission gate, the push-token registration flow, and 7 per-category toggles
* OS-permission gate, the push-token registration flow, and 8 per-category toggles
* with their optimistic-mutation + retry + loading patterns. CATEGORY_META still
* has seven keys; the KiloClaw row is hidden when useKiloClawTabVisible is false.
* has eight keys; the KiloClaw row is hidden when useKiloClawTabVisible is false.
* Extracting subcomponents would re-encode the same hooks. The screen stays a
* single rendered surface. */
import { hashKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
Expand Down Expand Up @@ -148,6 +148,16 @@ const CATEGORY_META = [
subtitleKey: 'notifications.category.balanceAlertsSubtitle',
icon: Wallet,
},
{
key: 'spendAlerts',
titleKey: 'notifications.channel.spend',
subtitleKey: 'notifications.category.spendAlertsSubtitle',
icon: Wallet,
// A dedicated label for the device scene: the spend view owns a switch with
// the same visible wording, so this row's control must be addressable on
// its own. The low-balance sheet uses the same `*A11y` pattern.
accessibilityLabelKey: 'notifications.category.spendAlertsToggle',
},
{
key: 'securityFindings',
titleKey: 'notifications.channel.security',
Expand All @@ -174,11 +184,16 @@ type CategoryMeta = (typeof CATEGORY_META)[number];
/**
* Per-category availability from the preferences response `capabilities` map.
* The same entry carries the server's `unavailableReason`: an English-only
* sentence that is deliberately never rendered, because the screen is localized
* and the server cannot translate it. `CategoryRow` reads the reason from
* `CATEGORY_UNAVAILABLE_SUBTITLE_KEYS` instead.
* sentence that must never render where a catalog key exists, because the
* screen is localized and the server cannot translate it. `CategoryRow` reads
* the reason from `CATEGORY_UNAVAILABLE_SUBTITLE_KEYS` for those categories,
* and falls back to this field only for one that has no key yet (the spend
* row).
*/
type NotificationCategoryCapability = Readonly<{ available: boolean }>;
type NotificationCategoryCapability = Readonly<{
available: boolean;
unavailableReason: string | null;
}>;

type CategoryRowProps = Readonly<{
meta: CategoryMeta;
Expand Down Expand Up @@ -213,17 +228,25 @@ function CategoryRow({
const editable = deriveAgentPushEditable({ hasData: preferences != null, isPending });
// An unavailable category is a terminal, non-retryable state: the switch is
// disabled and the row renders its own catalog copy for the reason. The
// server's `unavailableReason` is English prose and must never render — it
// cannot be translated — so the unavailable state is carried by the muted
// title and the disabled switch, and the reason comes from
// `CATEGORY_UNAVAILABLE_SUBTITLE_KEYS`. A missing entry (the
// `noUncheckedIndexedAccess` widening) defaults to available.
// server's `unavailableReason` is English prose and cannot be translated, so
// the unavailable state is carried by the muted title and the disabled
// switch, and the reason comes from `CATEGORY_UNAVAILABLE_SUBTITLE_KEYS`. A
// category without a catalog key (the spend row) falls back to the reason the
// server sent. A missing entry (the `noUncheckedIndexedAccess` widening)
// defaults to available.
const unavailable = capability?.available === false;
const isDisabled = disabled || !editable || unavailable;
const title = t(meta.titleKey);
// Most rows take the visible title as their control label; the spend row
// carries its own so the device scene can address it unambiguously.
const accessibilityLabel =
'accessibilityLabelKey' in meta ? t(meta.accessibilityLabelKey) : t(meta.titleKey);
// A category with catalog copy renders that copy; the spend row has none yet,
// so it falls back to the reason the server sent.
const unavailableSubtitleKey = CATEGORY_UNAVAILABLE_SUBTITLE_KEYS.get(meta.key);
const subtitle =
unavailable && unavailableSubtitleKey != null ? t(unavailableSubtitleKey) : t(meta.subtitleKey);
const unavailableReason =
unavailableSubtitleKey != null ? t(unavailableSubtitleKey) : capability?.unavailableReason;
const subtitle = unavailable ? (unavailableReason ?? t(meta.subtitleKey)) : t(meta.subtitleKey);
return (
<View className="min-h-11 flex-row items-center gap-3 rounded-lg bg-secondary p-3">
<Icon size={18} color={colors.secondaryForeground} />
Expand All @@ -242,7 +265,7 @@ function CategoryRow({
<Switch
value={displayedValue}
disabled={isDisabled}
accessibilityLabel={title}
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled: isDisabled, busy: isPending }}
onValueChange={value => {
if (isDisabled) {
Expand All @@ -266,8 +289,8 @@ export function NotificationsScreen() {

const [isTogglingPermission, setIsTogglingPermission] = useState(false);
const [isRegisteringToken, setIsRegisteringToken] = useState(false);
// The `setNotificationPreferences` mutation object is shared across all five
// rows, and its `isPending` is a single flag for the whole procedure. Two
// The `setNotificationPreferences` mutation object is shared across every
// category row, and its `isPending` is a single flag for the whole procedure. Two
// category flips can therefore be in flight at once, so we track the set of
// in-flight categories explicitly and scope each row's busy state to its own
// key. Each mutation callback resolves its own category from `variables`
Expand Down Expand Up @@ -605,7 +628,7 @@ export function NotificationsScreen() {
showsVerticalScrollIndicator={false}
>
{/* The glanceable surface. First on the screen because it is what the
user sees without opening the app, and it must not sit below seven
user sees without opening the app, and it must not sit below eight
category rows. Each platform names it the way its own OS does:
a Live Activity on iOS, a Live Update on Android. */}
<View className="gap-3">
Expand Down Expand Up @@ -795,7 +818,10 @@ export function NotificationsScreen() {
// backend that predates the field returns none. The guard
// keeps the old response on the always-available path.
// eslint-disable-next-line typescript-eslint/no-unnecessary-condition
preferences.capabilities?.[meta.key] ?? { available: true }
preferences.capabilities?.[meta.key] ?? {
available: true,
unavailableReason: null,
}
}
disabled={!notificationsEnabled}
isPending={pendingCategories.has(meta.key)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ vi.mock('@/components/ui/icons', () => ({
Pencil: 'Pencil',
Receipt: 'Receipt',
Users: 'Users',
Wallet: 'Wallet',
}));

vi.mock('@/components/ui/kv-row', () => ({ KvRow: 'KvRow' }));
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/src/components/organization/hub-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { fromMicrodollars } from '@kilocode/app-shared/utils';
import * as Haptics from 'expo-haptics';
import { type Href, useRouter } from 'expo-router';
import { Bell, FileText, Pencil, Receipt, Users } from '@/components/ui/icons';
import { Bell, FileText, Pencil, Receipt, Users, Wallet } from '@/components/ui/icons';
import { DirectionalChevronRight } from '@/components/ui/directional-icons';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -199,11 +199,20 @@ export function OrganizationHubScreen({ organizationIdOverride }: OrganizationHu
icon={Bell}
title={t('organization.lowBalanceAlert.title')}
subtitle={lowBalanceSubtitle}
last
onPress={() => {
router.push('/(app)/(tabs)/(3_profile)/organization/low-balance-alert' as Href);
}}
/>
<ConfigureRow
icon={Wallet}
title={t('notifications.channel.spend')}
last
onPress={() => {
router.push(
`/(app)/(tabs)/(3_profile)/spend-alerts?org=${organizationId}` as Href
);
}}
/>
</>
)}
</View>
Expand Down
101 changes: 101 additions & 0 deletions apps/mobile/src/components/organization/spend-alert-validators.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';

import {
DEFAULT_MULTIPLIER,
DEFAULT_WINDOW_HOURS,
DISABLED_MULTIPLIER,
DISABLED_THRESHOLD_USD,
multiplierBasisPoints,
multiplierError,
parseMultiplier,
parseThreshold,
thresholdError,
toWindowHours,
} from '@/components/organization/spend-alert-validators';

// `pnpm check:i18n` owns the copy; these assert the bounds, not the wording.
const THRESHOLD_CASES: [string, number | null][] = [
['', null],
[' ', null],
['0', null],
['-5', null],
['not a number', null],
['25', 25],
['25.50', 25.5],
['1,000', 1000],
['1000000', 1_000_000],
['1000000.01', null],
// Below one microdollar the wire's `round(usd * 1_000_000)` stores a zero
// threshold, which fires on any spend and never clears.
['0.000001', 0.000_001],
['0.0000001', null],
];

const MULTIPLIER_CASES: [string, number | null][] = [
['', null],
['0.9', null],
['0', null],
['-2', null],
['not a number', null],
['1', 1],
['2.5', 2.5],
['50', 50],
['50.01', null],
];

describe('parseThreshold', () => {
it.each(THRESHOLD_CASES)('parses %s to %s', (value, expected) => {
expect(parseThreshold(value)).toBe(expected);
});

it('reports an inline error only while the limit is out of range', () => {
expect(thresholdError('25')).toBeNull();
expect(thresholdError('0')).not.toBeNull();
expect(thresholdError('1000000.01')).not.toBeNull();
});
});

describe('parseMultiplier', () => {
it.each(MULTIPLIER_CASES)('parses %s to %s', (value, expected) => {
expect(parseMultiplier(value)).toBe(expected);
});

it('reports an inline error only while the multiplier is below 1x or above the cap', () => {
expect(multiplierError('2')).toBeNull();
expect(multiplierError('0.5')).not.toBeNull();
expect(multiplierError('51')).not.toBeNull();
});
});

describe('disabled-kind stand-ins', () => {
it('are the smallest values the wire accepts', () => {
// A switched-off kind submits these instead of gating Save on a field the
// owner deliberately left empty.
expect(parseThreshold(String(DISABLED_THRESHOLD_USD))).toBe(DISABLED_THRESHOLD_USD);
expect(parseMultiplier(String(DISABLED_MULTIPLIER))).toBe(DISABLED_MULTIPLIER);
expect(multiplierBasisPoints(DISABLED_MULTIPLIER)).toBe(100);
});
});

describe('multiplierBasisPoints', () => {
it('converts a multiplier to the wire basis points', () => {
expect(multiplierBasisPoints(1)).toBe(100);
expect(multiplierBasisPoints(DEFAULT_MULTIPLIER)).toBe(200);
expect(multiplierBasisPoints(1.5)).toBe(150);
});
});

describe('toWindowHours', () => {
it('defaults an unsaved or unknown window to the 24-hour default', () => {
expect(DEFAULT_WINDOW_HOURS).toBe(24);
expect(toWindowHours(null)).toBe(24);
expect(toWindowHours(undefined)).toBe(24);
expect(toWindowHours(999)).toBe(24);
});

it('keeps the windows the wire accepts', () => {
expect(toWindowHours(24)).toBe(24);
expect(toWindowHours(168)).toBe(168);
expect(toWindowHours(720)).toBe(720);
});
});
Loading
Loading