Compare commits

...

11 Commits

Author SHA1 Message Date
Claude
428dea9823 [AI] getCategories: query categories table directly when hidden=true
Replace the second \`getCategoryGroups()\` call (which loaded every group
plus its nested categories just to be flattened and filtered) with a
direct \`q('categories').filter({ hidden: true })\` AQL query. Same
result, one targeted query instead of fetching all groups.

The non-hidden=true paths are unchanged.

https://claude.ai/code/session_01DhYiicACsWb5NGHX71Wv4F
2026-05-09 20:53:56 +00:00
Claude
984702dd6c [AI] getCategories: include hidden categories from visible groups in list
When \`hidden: true\` was requested, the flat list only contained hidden
categories that lived inside hidden groups, because it was derived from
the same already-filtered groups used for the grouped view. A hidden
category sitting in a visible group was silently dropped.

Fetch the unfiltered groups for the list view and filter by
\`category.hidden\` so the list reflects every hidden category regardless
of its parent group's hidden status. The grouped view is unchanged.

https://claude.ai/code/session_01DhYiicACsWb5NGHX71Wv4F
2026-05-09 20:45:10 +00:00
Claude
2fd88c2d71 [AI] Document new \hidden\ option on getCategories and getCategoryGroups
https://claude.ai/code/session_01DhYiicACsWb5NGHX71Wv4F
2026-05-09 20:29:37 +00:00
Claude
fdf1196f8a [AI] Push hidden-category filtering down to the API/query layer
Add an optional \`hidden\` filter to \`api.getCategories\` and
\`api.getCategoryGroups\`. When set, the AQL query filters category groups
by hidden status and nested categories are filtered to match. Internal
callers (no options) keep the existing "return everything" behavior.

The CLI \`categories list\` and \`category-groups list\` commands now pass
\`{ hidden: false }\` instead of filtering client-side after fetching.

https://claude.ai/code/session_01DhYiicACsWb5NGHX71Wv4F
2026-05-09 20:26:47 +00:00
Claude
89dfd7b6d8 [AI] Rename release note to 7786.md to match PR number
https://claude.ai/code/session_01DhYiicACsWb5NGHX71Wv4F
2026-05-09 20:13:23 +00:00
Claude
e5997f97a8 [AI] CLI: simplify category-groups list and consolidate test setup
- Flatten the include-hidden ternary on category-groups list into a
  single filter chain, mirroring categories list.
- Consolidate duplicated stderr/stdout spy setup into one outer
  describe in categories.test.ts.

https://claude.ai/code/session_01DhYiicACsWb5NGHX71Wv4F
2026-05-09 20:10:50 +00:00
Claude
07b9dcacad [AI] Rename release note to 7785.md and update author
https://claude.ai/code/session_01DhYiicACsWb5NGHX71Wv4F
2026-05-09 20:05:47 +00:00
Claude
2a3f943660 Merge remote-tracking branch 'origin/master' into claude/hide-default-categories-1cwBZ 2026-05-09 20:05:27 +00:00
Matiss Janis Aboltins
b9ab3e7bc6 [AI] Fix /update-vrt build step after lage browser-build refactor (#7781)
The build-web job in vrt-update-generate.yml invoked
`yarn workspace @actual-app/core build:browser`, but #7602 removed that
script when it routed the browser pipeline through
`lage build:browser --to=@actual-app/web` (orchestrated by
bin/package-browser). The recent /update-vrt parallelization (#7641)
preserved the now-stale per-workspace invocations, so every comment
trigger fails with "Couldn't find a script named build:browser".

Match the working e2e-test.yml build-web step exactly:
`yarn build:browser --skip-translations`. lage's `^build` edge handles
the upstream graph (crdt, plugins-service, loot-core) automatically, and
`--skip-translations` keeps the captured snapshots aligned with regular
VRT runs (which also strip Weblate locale chunks for determinism).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 18:16:42 +00:00
Matiss Janis Aboltins
4f40defe9e [AI] Mobile: live value tracking (#7774)
* [AI] Update mobile budget value color live as user types

The mobile FocusableAmountInput's color was computed from the saved
`value` prop, so it stayed in the gray "zero" state until blur. Track
the in-progress edited value via the existing `onChangeValue` callback
and feed it to `makeAmountFullStyle` so the color reflects what the
user is currently typing.

* Add release notes for PR #7774

* Change category from Features to Bugfix

* [AI] Reapply sign when computing live amount color

liveValue holds the absolute value (the input field has no sign — the
+/- toggle controls it separately), so passing it directly to
makeAmountFullStyle picked positiveColor for amounts the user intends
as negative. Pass maybeApplyNegative(liveValue, isNegative) so the
color matches the signed value.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-09 18:08:58 +00:00
Claude
7fe4a2f573 [AI] CLI: hide hidden categories by default in list commands
The `categories list` and `category-groups list` commands now exclude
hidden entries by default. Pass `--include-hidden` to include them, mirroring
the existing `--include-closed` flag for `accounts list`.

https://claude.ai/code/session_01DhYiicACsWb5NGHX71Wv4F
2026-05-05 22:27:50 +00:00
13 changed files with 236 additions and 34 deletions

View File

@@ -82,16 +82,17 @@ jobs:
with:
download-translations: 'false'
- name: Build browser bundle
# REACT_APP_NETLIFY=true keeps the "Create test file" button in the
# production bundle — every VRT test's beforeEach relies on it via
# ConfigurationPage.createTestFile().
# REACT_APP_NETLIFY=true flips isNonProductionEnvironment() on in the
# bundle so the "Create test file" button (used by every e2e beforeEach
# via ConfigurationPage.createTestFile()) is still rendered in a
# production build. Without it, e2e tests would time out waiting for
# a button that was tree-shaken out.
# --skip-translations keeps VRT screenshots deterministic by rendering
# source-code English instead of upstream Weblate en.json (which can
# drift between snapshot capture and test runs).
env:
REACT_APP_NETLIFY: 'true'
run: |
yarn workspace plugins-service build
yarn workspace @actual-app/crdt build
yarn workspace @actual-app/core build:browser
yarn workspace @actual-app/web build:browser
run: yarn build:browser --skip-translations
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:

View File

@@ -204,8 +204,8 @@ export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
return send('api/account-balance', { id, cutoff });
}
export function getCategoryGroups() {
return send('api/category-groups-get');
export function getCategoryGroups(options: { hidden?: boolean } = {}) {
return send('api/category-groups-get', options);
}
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
@@ -226,8 +226,8 @@ export function deleteCategoryGroup(
return send('api/category-group-delete', { id, transferCategoryId });
}
export function getCategories() {
return send('api/categories-get', { grouped: false });
export function getCategories(options: { hidden?: boolean } = {}) {
return send('api/categories-get', { grouped: false, ...options });
}
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {

View File

@@ -0,0 +1,131 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '#output';
import { registerCategoriesCommand } from './categories';
import { registerCategoryGroupsCommand } from './category-groups';
vi.mock('@actual-app/api', () => ({
getCategories: vi.fn().mockResolvedValue([]),
createCategory: vi.fn().mockResolvedValue('new-id'),
updateCategory: vi.fn().mockResolvedValue(undefined),
deleteCategory: vi.fn().mockResolvedValue(undefined),
getCategoryGroups: vi.fn().mockResolvedValue([]),
createCategoryGroup: vi.fn().mockResolvedValue('new-group-id'),
updateCategoryGroup: vi.fn().mockResolvedValue(undefined),
deleteCategoryGroup: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('#connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('#output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerCategoriesCommand(program);
registerCategoryGroupsCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
describe('categories commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
describe('categories list', () => {
it('asks the API to exclude hidden categories by default', async () => {
await run(['categories', 'list']);
expect(api.getCategories).toHaveBeenCalledWith({ hidden: false });
});
it('asks the API for all categories when --include-hidden is passed', async () => {
await run(['categories', 'list', '--include-hidden']);
expect(api.getCategories).toHaveBeenCalledWith({});
});
it('prints whatever the API returns', async () => {
const visible = {
id: '1',
name: 'Visible',
group_id: 'g1',
hidden: false,
};
vi.mocked(api.getCategories).mockResolvedValue([visible]);
await run(['categories', 'list']);
expect(printOutput).toHaveBeenCalledWith([visible], undefined);
});
it('passes format option to printOutput', async () => {
vi.mocked(api.getCategories).mockResolvedValue([]);
await run(['--format', 'csv', 'categories', 'list']);
expect(printOutput).toHaveBeenCalledWith([], 'csv');
});
});
describe('category-groups list', () => {
it('asks the API to exclude hidden groups by default', async () => {
await run(['category-groups', 'list']);
expect(api.getCategoryGroups).toHaveBeenCalledWith({ hidden: false });
});
it('asks the API for all groups when --include-hidden is passed', async () => {
await run(['category-groups', 'list', '--include-hidden']);
expect(api.getCategoryGroups).toHaveBeenCalledWith({});
});
it('prints whatever the API returns', async () => {
const group = {
id: 'g1',
name: 'Group',
is_income: false,
hidden: false,
categories: [{ id: 'c1', name: 'Cat', group_id: 'g1', hidden: false }],
};
vi.mocked(api.getCategoryGroups).mockResolvedValue([group]);
await run(['category-groups', 'list']);
expect(printOutput).toHaveBeenCalledWith([group], undefined);
});
});
});

View File

@@ -12,13 +12,16 @@ export function registerCategoriesCommand(program: Command) {
categories
.command('list')
.description('List all categories')
.action(async () => {
.description('List categories (excludes hidden by default)')
.option('--include-hidden', 'Include hidden categories', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const result = await api.getCategories();
const result = await api.getCategories(
cmdOpts.includeHidden ? {} : { hidden: false },
);
printOutput(result, opts.format);
},
{ mutates: false },

View File

@@ -12,13 +12,16 @@ export function registerCategoryGroupsCommand(program: Command) {
groups
.command('list')
.description('List all category groups')
.action(async () => {
.description('List category groups (excludes hidden by default)')
.option('--include-hidden', 'Include hidden groups and categories', false)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(
opts,
async () => {
const result = await api.getCategoryGroups();
const result = await api.getCategoryGroups(
cmdOpts.includeHidden ? {} : { hidden: false },
);
printOutput(result, opts.format);
},
{ mutates: false },

View File

@@ -190,9 +190,11 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
buttonProps,
onFocus,
onBlur,
onChangeValue,
...props
}: FocusableAmountInputProps) {
const [isNegative, setIsNegative] = useState(true);
const [liveValue, setLiveValue] = useState(Math.abs(value));
const maybeApplyNegative = (amount: number, negative: boolean) => {
const absValue = Math.abs(amount);
@@ -203,6 +205,15 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
props.onUpdateAmount?.(maybeApplyNegative(amount, negative));
};
const handleChangeValue = (text: string) => {
setLiveValue(currencyToAmount(text) || 0);
onChangeValue?.(text);
};
useEffect(() => {
setLiveValue(Math.abs(value));
}, [value]);
useEffect(() => {
if (sign) {
setIsNegative(sign === '-');
@@ -227,10 +238,11 @@ export const FocusableAmountInput = memo(function FocusableAmountInput({
value={value}
onFocus={onFocus}
onBlur={onBlur}
onChangeValue={handleChangeValue}
onUpdateAmount={amount => onUpdateAmount(amount, isNegative)}
focused={focused && !disabled}
style={{
...makeAmountFullStyle(value, {
...makeAmountFullStyle(maybeApplyNegative(liveValue, isNegative), {
zeroColor: isNegative ? theme.numberNegative : theme.numberNeutral,
positiveColor: theme.numberPositive,
negativeColor: theme.numberNegative,

View File

@@ -445,9 +445,13 @@ let accounts = await getAccounts();
#### `getCategories`
<Method name="getCategories" args={[]} returns="Promise<Category[]>" />
<Method name="getCategories" args={[{ name: 'options = {}', type: 'object?' }]} returns="Promise<Category[]>" />
Get all categories.
Get categories. By default, returns every category.
The `options` object supports:
- `hidden`: filter by hidden status. Pass `false` to return only visible categories, or `true` to return only hidden ones. Omit to return both.
#### `createCategory`
@@ -500,9 +504,13 @@ There should only ever be one income category group,
#### `getCategoryGroups`
<Method name="getCategoryGroups" args={[]} returns="Promise<CategoryGroup[]>" />
<Method name="getCategoryGroups" args={[{ name: 'options = {}', type: 'object?' }]} returns="Promise<CategoryGroup[]>" />
Get all category groups.
Get category groups. By default, returns every group with all of its categories nested under it.
The `options` object supports:
- `hidden`: filter by hidden status, applied to both groups and their nested categories. Pass `false` to return only visible groups and categories, or `true` to return only hidden ones. Omit to return both.
#### `createCategoryGroup`

View File

@@ -630,17 +630,20 @@ handlers['api/account-balance'] = withMutation(async function ({
handlers['api/categories-get'] = async function ({
grouped,
}: { grouped? } = {}) {
hidden,
}: { grouped?: boolean; hidden?: boolean } = {}) {
checkFileOpen();
const result = await handlers['get-categories']();
const result = await handlers['get-categories']({ hidden });
return grouped
? result.grouped.map(group => categoryGroupModel.toExternal(group))
: result.list.map(category => categoryModel.toExternal(category));
};
handlers['api/category-groups-get'] = async function () {
handlers['api/category-groups-get'] = async function ({
hidden,
}: { hidden?: boolean } = {}) {
checkFileOpen();
const groups = await handlers['get-category-groups']();
const groups = await handlers['get-category-groups']({ hidden });
return groups.map(group => categoryGroupModel.toExternal(group));
};

View File

@@ -170,11 +170,23 @@ app.method(
app.method('budget/render-note-templates', goalNoteActions.unparse);
// Server must return AQL entities not the raw DB data
async function getCategories() {
const categoryGroups = await getCategoryGroups();
async function getCategories({ hidden }: { hidden?: boolean } = {}) {
const categoryGroups = await getCategoryGroups({ hidden });
let list: CategoryEntity[];
if (hidden === true) {
// A hidden category can live in a visible group, so when the caller
// explicitly asks for hidden categories the flat list must look beyond
// the (already hidden-filtered) groups returned above.
const { data }: { data: CategoryEntity[] } = await aqlQuery(
q('categories').filter({ hidden: true }).select('*'),
);
list = data;
} else {
list = categoryGroups.flatMap(g => g.categories ?? []);
}
return {
grouped: categoryGroups,
list: categoryGroups.flatMap(g => g.categories ?? []),
list,
};
}
@@ -388,10 +400,18 @@ async function deleteCategory({
}
// Server must return AQL entities not the raw DB data
async function getCategoryGroups() {
async function getCategoryGroups({ hidden }: { hidden?: boolean } = {}) {
const baseQuery = q('category_groups').select('*');
const query = hidden === undefined ? baseQuery : baseQuery.filter({ hidden });
const { data: categoryGroups }: { data: CategoryGroupEntity[] } =
await aqlQuery(q('category_groups').select('*'));
return categoryGroups;
await aqlQuery(query);
if (hidden === undefined) {
return categoryGroups;
}
return categoryGroups.map(g => ({
...g,
categories: g.categories?.filter(c => Boolean(c.hidden) === hidden),
}));
}
async function createCategoryGroup({

View File

@@ -164,9 +164,12 @@ export type ApiHandlers = {
'api/categories-get': (arg: {
grouped?: boolean;
hidden?: boolean;
}) => Promise<Array<APICategoryGroupEntity | APICategoryEntity>>;
'api/category-groups-get': () => Promise<APICategoryGroupEntity[]>;
'api/category-groups-get': (arg?: {
hidden?: boolean;
}) => Promise<APICategoryGroupEntity[]>;
'api/category-group-create': (arg: {
group: Omit<APICategoryGroupEntity, 'id'>;

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [MatissJanis]
---
Mobile: add live value tracking for user input in mobile transactions.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Fix update-vrt workflow

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [Matissjanis]
---
CLI: hide hidden categories by default in list commands.