Skip to content
Open
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} />;
}
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 @@ -149,6 +149,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 @@ -162,6 +163,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 @@ -431,6 +433,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 @@ -512,6 +591,7 @@ describe('NotificationsScreen category availability', () => {
sessionStatus: true,
kiloclawActivity: true,
balanceAlerts: true,
spendAlerts: true,
securityFindings: true,
agentPushEnabled: true,
notificationPreviews: 'generic',
Expand All @@ -521,6 +601,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
26 changes: 20 additions & 6 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 @@ -147,6 +147,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 Down Expand Up @@ -200,6 +210,10 @@ function CategoryRow({
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);
const subtitle = unavailable
? (capability.unavailableReason ?? t(meta.subtitleKey))
: t(meta.subtitleKey);
Expand All @@ -221,7 +235,7 @@ function CategoryRow({
<Switch
value={displayedValue}
disabled={isDisabled}
accessibilityLabel={title}
accessibilityLabel={accessibilityLabel}
accessibilityState={{ disabled: isDisabled, busy: isPending }}
onValueChange={value => {
if (isDisabled) {
Expand All @@ -245,8 +259,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 @@ -584,7 +598,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
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 @@ -201,11 +201,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