mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 04:32:39 -05:00
Compare commits
5 Commits
v25.10.0
...
coderabbit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74c27ea698 | ||
|
|
71d8b0be45 | ||
|
|
0c070e5703 | ||
|
|
c6c1fa686e | ||
|
|
791a2e63f8 |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description:
|
||||
description:
|
||||
globs: *.ts,*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -21,7 +21,7 @@ Naming Conventions
|
||||
|
||||
TypeScript Usage
|
||||
|
||||
- Use TypeScript for all code; prefer types over interfaces.
|
||||
- Use TypeScript for all code; prefer interfaces over types.
|
||||
- Avoid enums; use objects or maps instead.
|
||||
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
|
||||
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
|
||||
|
||||
6
.github/scripts/count-points.mjs
vendored
6
.github/scripts/count-points.mjs
vendored
@@ -2,7 +2,7 @@ import { Octokit } from '@octokit/rest';
|
||||
import { minimatch } from 'minimatch';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
const limit = pLimit(50);
|
||||
const limit = pLimit(30);
|
||||
|
||||
/** Repository-specific configuration for points calculation */
|
||||
const REPOSITORY_CONFIG = new Map([
|
||||
@@ -129,13 +129,13 @@ async function countContributorPoints(repo) {
|
||||
// Get all PRs using search
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
|
||||
const recentPRs = await octokit.paginate(
|
||||
'GET /search/issues',
|
||||
octokit.search.issuesAndPullRequests,
|
||||
{
|
||||
q: searchQuery,
|
||||
per_page: 100,
|
||||
advanced_search: true,
|
||||
},
|
||||
response => response.data.filter(pr => pr.number),
|
||||
response => response.data,
|
||||
);
|
||||
|
||||
// Get reviews and PR details for each PR
|
||||
|
||||
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -50,6 +50,22 @@ jobs:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
|
||||
plugins-core:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Plugins Core
|
||||
run: yarn workspace @actual-app/plugins-core build
|
||||
- name: Create package tgz
|
||||
run: cd packages/plugins-core && yarn pack && mv package.tgz actual-plugins-core.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-plugins-core
|
||||
path: packages/plugins-core/actual-plugins-core.tgz
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
2
.github/workflows/electron-master.yml
vendored
2
.github/workflows/electron-master.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
2
.github/workflows/electron-pr.yml
vendored
2
.github/workflows/electron-pr.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -98,6 +98,8 @@ export default pluginTypescript.config(
|
||||
'packages/loot-core/**/lib-dist/*',
|
||||
'packages/loot-core/**/proto/*',
|
||||
'packages/sync-server/build/',
|
||||
'packages/plugins-core/build/',
|
||||
'packages/plugins-core/node_modules/',
|
||||
'.yarn/*',
|
||||
'.github/*',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "25.10.0",
|
||||
"version": "25.9.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
"./toggle": "./src/Toggle.tsx",
|
||||
"./tooltip": "./src/Tooltip.tsx",
|
||||
"./view": "./src/View.tsx",
|
||||
"./color-picker": "./src/ColorPicker.tsx"
|
||||
"./color-picker": "./src/ColorPicker.tsx",
|
||||
"./props/*": "./src/props/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
|
||||
|
||||
11
packages/component-library/src/props/modalProps.ts
Normal file
11
packages/component-library/src/props/modalProps.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CSSProperties } from '../styles';
|
||||
|
||||
export type BasicModalProps = {
|
||||
isLoading?: boolean;
|
||||
noAnimation?: boolean;
|
||||
style?: CSSProperties;
|
||||
onClose?: () => void;
|
||||
containerProps?: {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
};
|
||||
@@ -154,10 +154,4 @@ export const styles: Record<string, any> = {
|
||||
borderRadius: 4,
|
||||
padding: '3px 5px',
|
||||
},
|
||||
mobileListItem: {
|
||||
borderBottom: `1px solid ${theme.tableBorder}`,
|
||||
backgroundColor: theme.tableBackground,
|
||||
padding: 16,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "25.10.0",
|
||||
"version": "25.9.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
|
||||
@@ -22,7 +22,7 @@ import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
|
||||
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import * as query from '@desktop-client/queries';
|
||||
import { liveQuery } from '@desktop-client/queries/liveQuery';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
|
||||
const LABEL_WIDTH = 70;
|
||||
|
||||
@@ -32,6 +32,11 @@ type BalanceHistoryGraphProps = {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
};
|
||||
|
||||
type Balance = {
|
||||
date: string;
|
||||
balance: number;
|
||||
};
|
||||
|
||||
export function BalanceHistoryGraph({
|
||||
accountId,
|
||||
style,
|
||||
@@ -46,11 +51,6 @@ export function BalanceHistoryGraph({
|
||||
date: string;
|
||||
balance: number;
|
||||
} | null>(null);
|
||||
const [startingBalance, setStartingBalance] = useState<number | null>(null);
|
||||
const [monthlyTotals, setMonthlyTotals] = useState<Array<{
|
||||
date: string;
|
||||
balance: number;
|
||||
}> | null>(null);
|
||||
|
||||
const percentageChange = useMemo(() => {
|
||||
if (balanceData.length < 2) return 0;
|
||||
@@ -65,70 +65,7 @@ export function BalanceHistoryGraph({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset state when accountId changes
|
||||
setStartingBalance(null);
|
||||
setMonthlyTotals(null);
|
||||
setLoading(true);
|
||||
|
||||
const endDate = new Date();
|
||||
const startDate = subMonths(endDate, 12);
|
||||
|
||||
const startingBalanceQuery = query
|
||||
.transactions(accountId)
|
||||
.filter({
|
||||
date: { $lt: monthUtils.firstDayOfMonth(startDate) },
|
||||
})
|
||||
.calculate({ $sum: '$amount' });
|
||||
const monthlyTotalsQuery = query
|
||||
.transactions(accountId)
|
||||
.filter({
|
||||
$and: [
|
||||
{ date: { $gte: monthUtils.firstDayOfMonth(startDate) } },
|
||||
{ date: { $lte: monthUtils.lastDayOfMonth(endDate) } },
|
||||
],
|
||||
})
|
||||
.groupBy({ $month: '$date' })
|
||||
.select([{ date: { $month: '$date' } }, { amount: { $sum: '$amount' } }]);
|
||||
|
||||
const startingBalanceLive: ReturnType<typeof liveQuery<number>> = liveQuery(
|
||||
startingBalanceQuery,
|
||||
{
|
||||
onData: (data: number[]) => {
|
||||
setStartingBalance(data[0] || 0);
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error fetching starting balance:', error);
|
||||
setLoading(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const monthlyTotalsLive: ReturnType<
|
||||
typeof liveQuery<{ date: string; amount: number }>
|
||||
> = liveQuery(monthlyTotalsQuery, {
|
||||
onData: (data: Array<{ date: string; amount: number }>) => {
|
||||
setMonthlyTotals(
|
||||
data.map(d => ({
|
||||
date: d.date,
|
||||
balance: d.amount,
|
||||
})),
|
||||
);
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error fetching monthly totals:', error);
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
startingBalanceLive?.unsubscribe();
|
||||
monthlyTotalsLive?.unsubscribe();
|
||||
};
|
||||
}, [accountId, locale]);
|
||||
|
||||
// Process data when both startingBalance and monthlyTotals are available
|
||||
useEffect(() => {
|
||||
if (startingBalance !== null && monthlyTotals !== null) {
|
||||
async function fetchBalanceHistory() {
|
||||
const endDate = new Date();
|
||||
const startDate = subMonths(endDate, 12);
|
||||
const months = eachMonthOfInterval({
|
||||
@@ -136,70 +73,99 @@ export function BalanceHistoryGraph({
|
||||
end: endDate,
|
||||
}).map(m => format(m, 'yyyy-MM'));
|
||||
|
||||
function processData(
|
||||
startingBalanceValue: number,
|
||||
monthlyTotalsValue: Array<{ date: string; balance: number }>,
|
||||
) {
|
||||
let currentBalance = startingBalanceValue;
|
||||
const totals = [...monthlyTotalsValue];
|
||||
totals.reverse().forEach(month => {
|
||||
currentBalance = currentBalance + month.balance;
|
||||
month.balance = currentBalance;
|
||||
});
|
||||
const [starting, totals]: [number, Balance[]] = await Promise.all([
|
||||
aqlQuery(
|
||||
query
|
||||
.transactions(accountId)
|
||||
.filter({
|
||||
date: { $lt: monthUtils.firstDayOfMonth(startDate) },
|
||||
})
|
||||
.calculate({ $sum: '$amount' }),
|
||||
).then(({ data }) => data),
|
||||
|
||||
// if the account doesn't have recent transactions
|
||||
// then the empty months will be missing from our data
|
||||
// so add in entries for those here
|
||||
if (totals.length === 0) {
|
||||
//handle case of no transactions in the last year
|
||||
months.forEach(expectedMonth =>
|
||||
aqlQuery(
|
||||
query
|
||||
.transactions(accountId)
|
||||
.filter({
|
||||
$and: [
|
||||
{ date: { $gte: monthUtils.firstDayOfMonth(startDate) } },
|
||||
{ date: { $lte: monthUtils.lastDayOfMonth(endDate) } },
|
||||
],
|
||||
})
|
||||
.groupBy({ $month: '$date' })
|
||||
.select([
|
||||
{ date: { $month: '$date' } },
|
||||
{ amount: { $sum: '$amount' } },
|
||||
]),
|
||||
).then(({ data }) =>
|
||||
data.map((d: { date: string; amount: number }) => {
|
||||
return {
|
||||
date: d.date,
|
||||
balance: d.amount,
|
||||
};
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
// calculate balances from sum of transactions
|
||||
let currentBalance = starting;
|
||||
totals.reverse().forEach(month => {
|
||||
currentBalance = currentBalance + month.balance;
|
||||
month.balance = currentBalance;
|
||||
});
|
||||
|
||||
// if the account doesn't have recent transactions
|
||||
// then the empty months will be missing from our data
|
||||
// so add in entries for those here
|
||||
if (totals.length === 0) {
|
||||
//handle case of no transactions in the last year
|
||||
months.forEach(expectedMonth =>
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: starting,
|
||||
}),
|
||||
);
|
||||
} else if (totals.length < months.length) {
|
||||
// iterate through each array together and add in missing data
|
||||
let totalsIndex = 0;
|
||||
let mostRecent = starting;
|
||||
months.forEach(expectedMonth => {
|
||||
if (totalsIndex > totals.length - 1) {
|
||||
// fill in the data at the end of the window
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: startingBalanceValue,
|
||||
}),
|
||||
);
|
||||
} else if (totals.length < months.length) {
|
||||
// iterate through each array together and add in missing data
|
||||
let totalsIndex = 0;
|
||||
let mostRecent = startingBalanceValue;
|
||||
months.forEach(expectedMonth => {
|
||||
if (totalsIndex > totals.length - 1) {
|
||||
// fill in the data at the end of the window
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: mostRecent,
|
||||
});
|
||||
} else if (totals[totalsIndex].date === expectedMonth) {
|
||||
// a matched month
|
||||
mostRecent = totals[totalsIndex].balance;
|
||||
totalsIndex += 1;
|
||||
} else {
|
||||
// a missing month in the middle
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: mostRecent,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const balances = totals
|
||||
.sort((a, b) => monthUtils.differenceInCalendarMonths(a.date, b.date))
|
||||
.map(t => {
|
||||
return {
|
||||
balance: t.balance,
|
||||
date: monthUtils.format(t.date, 'MMM yyyy', locale),
|
||||
};
|
||||
});
|
||||
|
||||
setBalanceData(balances);
|
||||
setHoveredValue(balances[balances.length - 1]);
|
||||
setLoading(false);
|
||||
balance: mostRecent,
|
||||
});
|
||||
} else if (totals[totalsIndex].date === expectedMonth) {
|
||||
// a matched month
|
||||
mostRecent = totals[totalsIndex].balance;
|
||||
totalsIndex += 1;
|
||||
} else {
|
||||
// a missing month in the middle
|
||||
totals.push({
|
||||
date: expectedMonth,
|
||||
balance: mostRecent,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
processData(startingBalance, monthlyTotals);
|
||||
const balances = totals
|
||||
.sort((a, b) => monthUtils.differenceInCalendarMonths(a.date, b.date))
|
||||
.map(t => {
|
||||
return {
|
||||
balance: t.balance,
|
||||
date: monthUtils.format(t.date, 'MMM yyyy', locale),
|
||||
};
|
||||
});
|
||||
|
||||
setBalanceData(balances);
|
||||
setHoveredValue(balances[balances.length - 1]);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [startingBalance, monthlyTotals, locale]);
|
||||
|
||||
fetchBalanceHistory();
|
||||
}, [accountId, locale]);
|
||||
|
||||
// State to track if the chart is hovered (used to conditionally render PrivacyFilter)
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -203,7 +204,7 @@ export function AccountHeader({
|
||||
const isUsingServer = syncServerStatus !== 'no-server';
|
||||
const isServerOffline = syncServerStatus === 'offline';
|
||||
const [_, setExpandSplitsPref] = useLocalPref('expand-splits');
|
||||
const [showNetWorthChartPref, _setShowNetWorthChartPref] = useSyncedPref(
|
||||
const [showNetWorthChartPref, setShowNetWorthChartPref] = useSyncedPref(
|
||||
`show-account-${accountId}-net-worth-chart`,
|
||||
);
|
||||
const showNetWorthChart = showNetWorthChartPref === 'true';
|
||||
@@ -232,6 +233,30 @@ export function AccountHeader({
|
||||
}
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const ele = graphRef.current;
|
||||
if (!ele) return;
|
||||
const clone = ele.cloneNode(true) as HTMLDivElement;
|
||||
Object.assign(clone.style, {
|
||||
visibility: 'hidden',
|
||||
display: 'flex',
|
||||
});
|
||||
ele.after(clone);
|
||||
if (clone.clientHeight < window.innerHeight * 0.15) {
|
||||
setShowNetWorthChartPref('true');
|
||||
} else {
|
||||
setShowNetWorthChartPref('false');
|
||||
}
|
||||
clone.remove();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [setShowNetWorthChartPref]);
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+f, cmd+f, meta+f',
|
||||
|
||||
@@ -171,54 +171,34 @@ function PayeeList({
|
||||
// entered
|
||||
|
||||
const { newPayee, suggestedPayees, payees, transferPayees } = useMemo(() => {
|
||||
let currentIndex = 0;
|
||||
const result = items.reduce(
|
||||
(acc, item) => {
|
||||
return items.reduce(
|
||||
(acc, item, index) => {
|
||||
if (item.id === 'new') {
|
||||
acc.newPayee = { ...item };
|
||||
acc.newPayee = { ...item, highlightedIndex: index };
|
||||
} else if (item.itemType === 'common_payee') {
|
||||
acc.suggestedPayees.push({ ...item });
|
||||
acc.suggestedPayees.push({ ...item, highlightedIndex: index });
|
||||
} else if (item.itemType === 'payee') {
|
||||
acc.payees.push({ ...item });
|
||||
acc.payees.push({ ...item, highlightedIndex: index });
|
||||
} else if (item.itemType === 'account') {
|
||||
acc.transferPayees.push({ ...item });
|
||||
acc.transferPayees.push({ ...item, highlightedIndex: index });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
newPayee: null as PayeeAutocompleteItem | null,
|
||||
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
payees: [] as Array<PayeeAutocompleteItem>,
|
||||
transferPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
newPayee: null as PayeeAutocompleteItem & {
|
||||
highlightedIndex: number;
|
||||
},
|
||||
suggestedPayees: [] as Array<
|
||||
PayeeAutocompleteItem & { highlightedIndex: number }
|
||||
>,
|
||||
payees: [] as Array<
|
||||
PayeeAutocompleteItem & { highlightedIndex: number }
|
||||
>,
|
||||
transferPayees: [] as Array<
|
||||
PayeeAutocompleteItem & { highlightedIndex: number }
|
||||
>,
|
||||
},
|
||||
);
|
||||
|
||||
// assign indexes in render order
|
||||
const newPayeeWithIndex = result.newPayee
|
||||
? { ...result.newPayee, highlightedIndex: currentIndex++ }
|
||||
: null;
|
||||
|
||||
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
const payeesWithIndex = result.payees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
const transferPayeesWithIndex = result.transferPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
return {
|
||||
newPayee: newPayeeWithIndex,
|
||||
suggestedPayees: suggestedPayeesWithIndex,
|
||||
payees: payeesWithIndex,
|
||||
transferPayees: transferPayeesWithIndex,
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
// We limit the number of payees shown to 100.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type ComponentProps } from 'react';
|
||||
import React, { Fragment, type ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
@@ -27,7 +27,7 @@ export function ReportList<T extends { id: string; name: string }>({
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
>
|
||||
<ItemHeader title={t('Saved Reports')} />
|
||||
<Fragment>{ItemHeader({ title: t('Saved Reports') })}</Fragment>
|
||||
{items.map((item, idx) => {
|
||||
return [
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GridList } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
@@ -25,8 +24,6 @@ export function PayeesList({
|
||||
isLoading = false,
|
||||
onPayeePress,
|
||||
}: PayeesListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isLoading && payees.length === 0) {
|
||||
return (
|
||||
<View
|
||||
@@ -66,30 +63,22 @@ export function PayeesList({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<GridList
|
||||
aria-label={t('Payees')}
|
||||
aria-busy={isLoading || undefined}
|
||||
items={payees}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{payee => (
|
||||
<PayeesListItem
|
||||
value={payee}
|
||||
ruleCount={ruleCounts.get(payee.id) ?? 0}
|
||||
onAction={() => onPayeePress(payee)}
|
||||
/>
|
||||
)}
|
||||
</GridList>
|
||||
<View
|
||||
style={{ flex: 1, paddingBottom: MOBILE_NAV_HEIGHT, overflow: 'auto' }}
|
||||
>
|
||||
{payees.map(payee => (
|
||||
<PayeesListItem
|
||||
key={payee.id}
|
||||
payee={payee}
|
||||
ruleCount={ruleCounts.get(payee.id) ?? 0}
|
||||
onPress={() => onPayeePress(payee)}
|
||||
/>
|
||||
))}
|
||||
{isLoading && (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
paddingTop: 20,
|
||||
paddingVertical: 20,
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading style={{ width: 20, height: 20 }} />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { memo } from 'react';
|
||||
import { GridListItem, type GridListItemProps } from 'react-aria-components';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgBookmark } from '@actual-app/components/icons/v1';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
|
||||
import { type PayeeEntity } from 'loot-core/types/models';
|
||||
@@ -12,82 +11,84 @@ import { type PayeeEntity } from 'loot-core/types/models';
|
||||
import { PayeeRuleCountLabel } from '@desktop-client/components/payees/PayeeRuleCountLabel';
|
||||
|
||||
type PayeesListItemProps = {
|
||||
value: PayeeEntity;
|
||||
payee: PayeeEntity;
|
||||
ruleCount: number;
|
||||
} & Omit<GridListItemProps<PayeeEntity>, 'value'>;
|
||||
onPress: () => void;
|
||||
};
|
||||
|
||||
export const PayeesListItem = memo(function PayeeListItem({
|
||||
value: payee,
|
||||
export function PayeesListItem({
|
||||
payee,
|
||||
ruleCount,
|
||||
...props
|
||||
onPress,
|
||||
}: PayeesListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const label = payee.transfer_acct
|
||||
? t('Transfer: {{name}}', { name: payee.name })
|
||||
: payee.name;
|
||||
|
||||
return (
|
||||
<GridListItem
|
||||
id={payee.id}
|
||||
value={payee}
|
||||
textValue={label}
|
||||
style={styles.mobileListItem}
|
||||
{...props}
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{
|
||||
minHeight: 56,
|
||||
width: '100%',
|
||||
borderRadius: 0,
|
||||
borderWidth: '0 0 1px 0',
|
||||
borderColor: theme.tableBorder,
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: theme.tableBackground,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
padding: '12px 16px',
|
||||
gap: 5,
|
||||
}}
|
||||
onPress={onPress}
|
||||
>
|
||||
<SpaceBetween gap={5}>
|
||||
{payee.favorite && (
|
||||
<SvgBookmark
|
||||
aria-hidden
|
||||
focusable={false}
|
||||
width={15}
|
||||
height={15}
|
||||
style={{
|
||||
color: theme.pageText,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SpaceBetween
|
||||
{payee.favorite && (
|
||||
<SvgBookmark
|
||||
width={15}
|
||||
height={15}
|
||||
style={{
|
||||
justifyContent: 'space-between',
|
||||
color: theme.pageText,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SpaceBetween
|
||||
style={{
|
||||
justifyContent: 'space-between',
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: payee.transfer_acct ? theme.pageTextSubdued : theme.pageText,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
title={payee.name}
|
||||
>
|
||||
{(payee.transfer_acct ? t('Transfer: ') : '') + payee.name}
|
||||
</span>
|
||||
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
padding: '3px 6px',
|
||||
backgroundColor: theme.noticeBackground,
|
||||
border: '1px solid ' + theme.noticeBackground,
|
||||
color: theme.noticeTextDark,
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: payee.transfer_acct
|
||||
? theme.pageTextSubdued
|
||||
: theme.pageText,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
padding: '3px 6px',
|
||||
backgroundColor: theme.noticeBackground,
|
||||
border: '1px solid ' + theme.noticeBackground,
|
||||
color: theme.noticeTextDark,
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<PayeeRuleCountLabel count={ruleCount} style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
</SpaceBetween>
|
||||
<PayeeRuleCountLabel count={ruleCount} style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
</SpaceBetween>
|
||||
</GridListItem>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -175,7 +175,6 @@ export function getLatestRange(offset: number) {
|
||||
export function calculateTimeRange(
|
||||
timeFrame?: Partial<TimeFrame>,
|
||||
defaultTimeFrame?: TimeFrame,
|
||||
latestTransaction?: string,
|
||||
) {
|
||||
const start =
|
||||
timeFrame?.start ??
|
||||
@@ -186,16 +185,7 @@ export function calculateTimeRange(
|
||||
const mode = timeFrame?.mode ?? defaultTimeFrame?.mode ?? 'sliding-window';
|
||||
|
||||
if (mode === 'full') {
|
||||
const latestTransactionMonth = latestTransaction
|
||||
? monthUtils.monthFromDate(latestTransaction)
|
||||
: null;
|
||||
const currentMonth = monthUtils.currentMonth();
|
||||
const fullEnd =
|
||||
latestTransactionMonth &&
|
||||
monthUtils.isAfter(latestTransactionMonth, currentMonth)
|
||||
? latestTransactionMonth
|
||||
: currentMonth;
|
||||
return getFullRange(start, fullEnd);
|
||||
return getFullRange(start, end);
|
||||
}
|
||||
if (mode === 'sliding-window') {
|
||||
const offset = monthUtils.differenceInCalendarMonths(end, start);
|
||||
|
||||
@@ -106,14 +106,19 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
|
||||
const [start, setStart] = useState(
|
||||
monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
{
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
},
|
||||
);
|
||||
const [end, setEnd] = useState(monthUtils.currentDay());
|
||||
const [mode, setMode] = useState<TimeFrame['mode']>('full');
|
||||
const [start, setStart] = useState(initialStart);
|
||||
const [end, setEnd] = useState(initialEnd);
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [query, setQuery] = useState<Query | undefined>(undefined);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [latestTransaction, setLatestTransaction] = useState('');
|
||||
|
||||
const { transactions: transactionsGrouped, loadMore: loadMoreTransactions } =
|
||||
useTransactions({ query });
|
||||
@@ -300,23 +305,6 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
run();
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestTransaction) {
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
{
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
},
|
||||
latestTransaction,
|
||||
);
|
||||
setStart(initialStart);
|
||||
setEnd(initialEnd);
|
||||
setMode(initialMode);
|
||||
}
|
||||
}, [latestTransaction, widget?.meta?.timeFrame]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
@@ -504,6 +492,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
);
|
||||
|
||||
const [earliestTransaction, setEarliestTransaction] = useState('');
|
||||
const [latestTransaction, setLatestTransaction] = useState('');
|
||||
|
||||
return (
|
||||
<Page
|
||||
|
||||
@@ -24,7 +24,6 @@ import { View } from '@actual-app/components/view';
|
||||
import { format as formatDate } from 'date-fns';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { type CalendarWidget } from 'loot-core/types/models';
|
||||
import { type SyncedPrefs } from 'loot-core/types/prefs';
|
||||
@@ -67,27 +66,11 @@ export function CalendarCard({
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
|
||||
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestTransaction() {
|
||||
const latestTrans = await send('get-latest-transaction');
|
||||
setLatestTransaction(
|
||||
latestTrans ? latestTrans.date : monthUtils.currentDay(),
|
||||
);
|
||||
}
|
||||
fetchLatestTransaction();
|
||||
}, []);
|
||||
|
||||
const [start, end] = calculateTimeRange(
|
||||
meta?.timeFrame,
|
||||
{
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
},
|
||||
latestTransaction,
|
||||
);
|
||||
const [start, end] = calculateTimeRange(meta?.timeFrame, {
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
});
|
||||
const params = useMemo(
|
||||
() =>
|
||||
calendarSpreadsheet(
|
||||
|
||||
@@ -91,13 +91,16 @@ function CashFlowInner({ widget }: CashFlowInnerProps) {
|
||||
pretty: string;
|
||||
}>>(null);
|
||||
|
||||
const [start, setStart] = useState(monthUtils.currentMonth());
|
||||
const [end, setEnd] = useState(monthUtils.currentMonth());
|
||||
const [mode, setMode] = useState<TimeFrame['mode']>('sliding-window');
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
defaultTimeFrame,
|
||||
);
|
||||
const [start, setStart] = useState(initialStart);
|
||||
const [end, setEnd] = useState(initialEnd);
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [showBalance, setShowBalance] = useState(
|
||||
widget?.meta?.showBalance ?? true,
|
||||
);
|
||||
const [latestTransaction, setLatestTransaction] = useState('');
|
||||
|
||||
const [isConcise, setIsConcise] = useState(() => {
|
||||
const numDays = d.differenceInCalendarDays(
|
||||
@@ -156,19 +159,6 @@ function CashFlowInner({ widget }: CashFlowInnerProps) {
|
||||
run();
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestTransaction) {
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
defaultTimeFrame,
|
||||
latestTransaction,
|
||||
);
|
||||
setStart(initialStart);
|
||||
setEnd(initialEnd);
|
||||
setMode(initialMode);
|
||||
}
|
||||
}, [latestTransaction, widget?.meta?.timeFrame]);
|
||||
|
||||
function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) {
|
||||
const numDays = d.differenceInCalendarDays(
|
||||
d.parseISO(end),
|
||||
@@ -231,6 +221,7 @@ function CashFlowInner({ widget }: CashFlowInnerProps) {
|
||||
};
|
||||
|
||||
const [earliestTransaction, setEarliestTransaction] = useState('');
|
||||
const [latestTransaction, setLatestTransaction] = useState('');
|
||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
type SVGAttributes,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -11,8 +10,6 @@ import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { Bar, BarChart, LabelList, ResponsiveContainer } from 'recharts';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { type CashFlowWidget } from 'loot-core/types/models';
|
||||
|
||||
import { defaultTimeFrame } from './CashFlow';
|
||||
@@ -111,25 +108,10 @@ export function CashFlowCard({
|
||||
onRemove,
|
||||
}: CashFlowCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
||||
|
||||
const [start, end] = calculateTimeRange(meta?.timeFrame, defaultTimeFrame);
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestTransaction() {
|
||||
const latestTrans = await send('get-latest-transaction');
|
||||
setLatestTransaction(
|
||||
latestTrans ? latestTrans.date : monthUtils.currentDay(),
|
||||
);
|
||||
}
|
||||
fetchLatestTransaction();
|
||||
}, []);
|
||||
|
||||
const [start, end] = calculateTimeRange(
|
||||
meta?.timeFrame,
|
||||
defaultTimeFrame,
|
||||
latestTransaction,
|
||||
);
|
||||
|
||||
const params = useMemo(
|
||||
() => simpleCashFlow(start, end, meta?.conditions, meta?.conditionsOp),
|
||||
[start, end, meta?.conditions, meta?.conditionsOp],
|
||||
|
||||
@@ -86,11 +86,13 @@ function NetWorthInner({ widget }: NetWorthInnerProps) {
|
||||
pretty: string;
|
||||
}> | null>(null);
|
||||
|
||||
const [start, setStart] = useState(monthUtils.currentMonth());
|
||||
const [end, setEnd] = useState(monthUtils.currentMonth());
|
||||
const [mode, setMode] = useState<TimeFrame['mode']>('sliding-window');
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
);
|
||||
const [start, setStart] = useState(initialStart);
|
||||
const [end, setEnd] = useState(initialEnd);
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [interval, setInterval] = useState(widget?.meta?.interval || 'Monthly');
|
||||
const [latestTransaction, setLatestTransaction] = useState('');
|
||||
|
||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
@@ -168,19 +170,6 @@ function NetWorthInner({ widget }: NetWorthInnerProps) {
|
||||
run();
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestTransaction) {
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
undefined,
|
||||
latestTransaction,
|
||||
);
|
||||
setStart(initialStart);
|
||||
setEnd(initialEnd);
|
||||
setMode(initialMode);
|
||||
}
|
||||
}, [latestTransaction, widget?.meta?.timeFrame]);
|
||||
|
||||
function onChangeDates(start: string, end: string, mode: TimeFrame['mode']) {
|
||||
setStart(start);
|
||||
setEnd(end);
|
||||
@@ -236,6 +225,7 @@ function NetWorthInner({ widget }: NetWorthInnerProps) {
|
||||
};
|
||||
|
||||
const [earliestTransaction, setEarliestTransaction] = useState('');
|
||||
const [latestTransaction, setLatestTransaction] = useState('');
|
||||
|
||||
if (!allMonths || !data) {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Block } from '@actual-app/components/block';
|
||||
@@ -6,8 +6,6 @@ import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type NetWorthWidget,
|
||||
@@ -49,27 +47,13 @@ export function NetWorthCard({
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
|
||||
const format = useFormat();
|
||||
|
||||
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
const [start, end] = calculateTimeRange(meta?.timeFrame);
|
||||
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestTransaction() {
|
||||
const latestTrans = await send('get-latest-transaction');
|
||||
setLatestTransaction(
|
||||
latestTrans ? latestTrans.date : monthUtils.currentDay(),
|
||||
);
|
||||
}
|
||||
fetchLatestTransaction();
|
||||
}, []);
|
||||
|
||||
const [start, end] = calculateTimeRange(
|
||||
meta?.timeFrame,
|
||||
undefined,
|
||||
latestTransaction,
|
||||
);
|
||||
const onCardHover = useCallback(() => setIsCardHovered(true), []);
|
||||
const onCardHoverEnd = useCallback(() => setIsCardHovered(false), []);
|
||||
|
||||
|
||||
@@ -38,11 +38,12 @@ export function SpendingCard({
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
|
||||
|
||||
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
const spendingReportMode = meta?.mode ?? 'single-month';
|
||||
|
||||
const [compare, compareTo] = calculateSpendingReportTimeRange(meta ?? {});
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
const selection =
|
||||
spendingReportMode === 'single-month' ? 'compareTo' : spendingReportMode;
|
||||
|
||||
@@ -76,11 +76,17 @@ function SummaryInner({ widget }: SummaryInnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
|
||||
const [start, setStart] = useState(
|
||||
monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
{
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
},
|
||||
);
|
||||
const [end, setEnd] = useState(monthUtils.currentDay());
|
||||
const [mode, setMode] = useState<TimeFrame['mode']>('full');
|
||||
const [start, setStart] = useState(initialStart);
|
||||
const [end, setEnd] = useState(initialEnd);
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
|
||||
const dividendFilters: FilterObject = useRuleConditionFilters(
|
||||
widget?.meta?.conditions ?? [],
|
||||
@@ -206,23 +212,6 @@ function SummaryInner({ widget }: SummaryInnerProps) {
|
||||
run();
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestTransaction) {
|
||||
const [initialStart, initialEnd, initialMode] = calculateTimeRange(
|
||||
widget?.meta?.timeFrame,
|
||||
{
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
},
|
||||
latestTransaction,
|
||||
);
|
||||
setStart(initialStart);
|
||||
setEnd(initialEnd);
|
||||
setMode(initialMode);
|
||||
}
|
||||
}, [latestTransaction, widget?.meta?.timeFrame]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import {
|
||||
type SummaryContent,
|
||||
@@ -37,28 +36,12 @@ export function SummaryCard({
|
||||
}: SummaryCardProps) {
|
||||
const locale = useLocale();
|
||||
const { t } = useTranslation();
|
||||
const [latestTransaction, setLatestTransaction] = useState<string>('');
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLatestTransaction() {
|
||||
const latestTrans = await send('get-latest-transaction');
|
||||
setLatestTransaction(
|
||||
latestTrans ? latestTrans.date : monthUtils.currentDay(),
|
||||
);
|
||||
}
|
||||
fetchLatestTransaction();
|
||||
}, []);
|
||||
|
||||
const [start, end] = calculateTimeRange(
|
||||
meta?.timeFrame,
|
||||
{
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
},
|
||||
latestTransaction,
|
||||
);
|
||||
const [start, end] = calculateTimeRange(meta?.timeFrame, {
|
||||
start: monthUtils.dayFromDate(monthUtils.currentMonth()),
|
||||
end: monthUtils.currentDay(),
|
||||
mode: 'full',
|
||||
});
|
||||
|
||||
const content = useMemo(
|
||||
() =>
|
||||
@@ -90,6 +73,8 @@ export function SummaryCard({
|
||||
|
||||
const data = useReport('summary', params);
|
||||
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ReportCard
|
||||
isEditing={isEditing}
|
||||
|
||||
@@ -319,8 +319,6 @@ type InputValueProps = Omit<
|
||||
> & {
|
||||
value?: string;
|
||||
onUpdate?: (newValue: string) => void;
|
||||
} & {
|
||||
[key: `data-${string}`]: unknown;
|
||||
};
|
||||
|
||||
function InputValue({
|
||||
|
||||
@@ -1603,7 +1603,6 @@ const Transaction = memo(function Transaction({
|
||||
inputProps={{
|
||||
value: debit === '' && credit === '' ? amountToCurrency(0) : debit,
|
||||
onUpdate: onUpdate.bind(null, 'debit'),
|
||||
'data-1p-ignore': true,
|
||||
}}
|
||||
privacyFilter={{
|
||||
activationFilters: [!isTemporaryId(transaction.id)],
|
||||
@@ -1630,7 +1629,6 @@ const Transaction = memo(function Transaction({
|
||||
inputProps={{
|
||||
value: credit,
|
||||
onUpdate: onUpdate.bind(null, 'credit'),
|
||||
'data-1p-ignore': true,
|
||||
}}
|
||||
privacyFilter={{
|
||||
activationFilters: [!isTemporaryId(transaction.id)],
|
||||
|
||||
@@ -180,7 +180,7 @@ export class LiveQuery<TResponse = unknown> {
|
||||
|
||||
protected fetchData = async (
|
||||
runQuery: () => Promise<{
|
||||
data: Data<TResponse> | TResponse;
|
||||
data: Data<TResponse>;
|
||||
dependencies: string[];
|
||||
}>,
|
||||
) => {
|
||||
@@ -205,15 +205,7 @@ export class LiveQuery<TResponse = unknown> {
|
||||
// still subscribed (`this.unsubscribeSyncEvent` will exist)
|
||||
if (this.inflightRequestId === reqId && this._unsubscribeSyncEvent) {
|
||||
const previousData = this.data;
|
||||
|
||||
// For calculate queries, data is a raw value, not an array
|
||||
// Convert it to an array format to maintain consistency
|
||||
if (this._query.state.calculation) {
|
||||
this.data = [data as TResponse];
|
||||
} else {
|
||||
this.data = data as Data<TResponse>;
|
||||
}
|
||||
|
||||
this.data = data;
|
||||
this.onData(this.data, previousData);
|
||||
this.inflightRequestId = null;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type { AccountEntity, CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
parametrizedField,
|
||||
type SheetFields,
|
||||
type Binding,
|
||||
type SheetNames,
|
||||
} from '.';
|
||||
} from '@actual-app/plugins-core';
|
||||
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type { AccountEntity, CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import { uncategorizedTransactions } from '@desktop-client/queries';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"author": "Actual",
|
||||
"productName": "Actual",
|
||||
"description": "A simple and powerful personal finance system",
|
||||
"version": "25.10.0",
|
||||
"version": "25.9.0",
|
||||
"scripts": {
|
||||
"clean": "rm -rf dist",
|
||||
"update-client": "bin/update-client",
|
||||
|
||||
@@ -434,6 +434,10 @@ export class CategoryTemplateContext {
|
||||
t.type === 'limit' ||
|
||||
t.type === 'remainder',
|
||||
)) {
|
||||
if (this.limitCheck) {
|
||||
throw new Error('Only one `up to` allowed per category');
|
||||
}
|
||||
|
||||
let limitDef;
|
||||
if (template.type === 'limit') {
|
||||
limitDef = template;
|
||||
@@ -445,10 +449,6 @@ export class CategoryTemplateContext {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.limitCheck) {
|
||||
throw new Error('Only one `up to` allowed per category');
|
||||
}
|
||||
|
||||
if (limitDef.period === 'daily') {
|
||||
const numDays = monthUtils.differenceInCalendarDays(
|
||||
monthUtils.addMonths(this.month, 1),
|
||||
|
||||
@@ -5,7 +5,6 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { captureBreadcrumb } from '../../platform/exceptions';
|
||||
import * as connection from '../../platform/server/connection';
|
||||
import { logger } from '../../platform/server/log';
|
||||
import { currentDay, dayFromDate, parseDate } from '../../shared/months';
|
||||
import { q } from '../../shared/query';
|
||||
import {
|
||||
@@ -560,12 +559,8 @@ app.events.on('sync', ({ type }) => {
|
||||
type === 'success' || type === 'error' || type === 'unauthorized';
|
||||
|
||||
if (completeEvent && prefs.getPrefs()) {
|
||||
if (!db.getDatabase()) {
|
||||
logger.info('database is not available, skipping schedule service');
|
||||
return;
|
||||
}
|
||||
|
||||
const { lastScheduleRun } = prefs.getPrefs();
|
||||
|
||||
if (lastScheduleRun !== currentDay()) {
|
||||
runMutator(() => advanceSchedulesService(type === 'success'));
|
||||
|
||||
|
||||
@@ -3,12 +3,7 @@ import { t } from 'i18next';
|
||||
|
||||
import { FieldValueTypes, RuleConditionOp } from '../types/models';
|
||||
|
||||
import {
|
||||
integerToAmount,
|
||||
amountToInteger,
|
||||
currencyToAmount,
|
||||
getNumberFormat,
|
||||
} from './util';
|
||||
import { integerToAmount, amountToInteger, currencyToAmount } from './util';
|
||||
|
||||
// For now, this info is duplicated from the backend. Figure out how
|
||||
// to share it later.
|
||||
@@ -379,22 +374,10 @@ export function makeValue(value, cond) {
|
||||
switch (cond.type) {
|
||||
case 'number': {
|
||||
if (cond.op !== 'isbetween') {
|
||||
const stringValue = String(value);
|
||||
const { decimalSeparator } = getNumberFormat();
|
||||
|
||||
// preserve trailing decimal separator to allow decimal input during typing
|
||||
if (stringValue && stringValue.endsWith(decimalSeparator)) {
|
||||
return {
|
||||
...cond,
|
||||
error: null,
|
||||
value: stringValue,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...cond,
|
||||
error: null,
|
||||
value: value ? currencyToAmount(stringValue) || 0 : 0,
|
||||
value: value ? currencyToAmount(String(value)) || 0 : 0,
|
||||
};
|
||||
}
|
||||
break;
|
||||
|
||||
67
packages/plugins-core/package.json
Normal file
67
packages/plugins-core/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@actual-app/plugins-core",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Core plugin system for Actual Budget",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/ tests/ --ext .ts,.tsx",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actual-app/components": "workspace:^",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.4",
|
||||
"i18next": "^25.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-aria-components": "^1.7.1",
|
||||
"react-dom": "19.1.0",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-dts": "^4.5.3"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"import": "./build/index.js",
|
||||
"require": "./build/index.cjs"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./src/server.ts",
|
||||
"development": "./src/server.ts",
|
||||
"import": "./build/server.js",
|
||||
"require": "./build/server.cjs"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./src/client.ts",
|
||||
"development": "./src/client.ts",
|
||||
"import": "./build/client.js",
|
||||
"require": "./build/client.cjs"
|
||||
},
|
||||
"./BasicModalComponents": {
|
||||
"types": "./src/BasicModalComponents.tsx",
|
||||
"import": "./build/BasicModalComponents.js",
|
||||
"development": "./src/BasicModalComponents.tsx"
|
||||
},
|
||||
"./types/*": {
|
||||
"types": "./src/types/*.ts",
|
||||
"development": "./src/types/*.ts"
|
||||
},
|
||||
"./query": {
|
||||
"types": "./src/query/index.ts",
|
||||
"development": "./src/query/index.ts"
|
||||
},
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
257
packages/plugins-core/src/BasicModalComponents.tsx
Normal file
257
packages/plugins-core/src/BasicModalComponents.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgLogo } from '@actual-app/components/icons/logo';
|
||||
import { SvgDelete } from '@actual-app/components/icons/v0';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { CSSProperties, styles } from '@actual-app/components/styles';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
type ModalButtonsProps = {
|
||||
style?: CSSProperties;
|
||||
leftContent?: ReactNode;
|
||||
focusButton?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ModalButtons = ({
|
||||
style,
|
||||
leftContent,
|
||||
focusButton = false,
|
||||
children,
|
||||
}: ModalButtonsProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusButton && containerRef.current) {
|
||||
const button = containerRef.current.querySelector<HTMLButtonElement>(
|
||||
'button:not([data-hidden])',
|
||||
);
|
||||
|
||||
if (button) {
|
||||
button.focus();
|
||||
}
|
||||
}
|
||||
}, [focusButton]);
|
||||
|
||||
return (
|
||||
<View
|
||||
innerRef={containerRef}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
marginTop: 30,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{leftContent}
|
||||
<View style={{ flex: 1 }} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
type ModalHeaderProps = {
|
||||
leftContent?: ReactNode;
|
||||
showLogo?: boolean;
|
||||
title?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Header used inside modals that centers an optional title and/or logo with
|
||||
* optional left and right content pinned to the edges.
|
||||
*
|
||||
* The center area shows either a logo (when `showLogo` is true), a title (string/number
|
||||
* rendered via ModalTitle), or a custom React node. `leftContent` and `rightContent`
|
||||
* are rendered in absolutely-positioned regions at the left and right edges respectively.
|
||||
*
|
||||
* @param leftContent - Content rendered at the left edge of the header (optional).
|
||||
* @param showLogo - When true, renders the app logo in the centered area.
|
||||
* @param title - Title to display in the center. If a string or number, it's rendered with ModalTitle; otherwise the node is rendered as-is.
|
||||
* @param rightContent - Content rendered at the right edge of the header (optional).
|
||||
* @returns A JSX element representing the modal header.
|
||||
*/
|
||||
export function ModalHeader({
|
||||
leftContent,
|
||||
showLogo,
|
||||
title,
|
||||
rightContent,
|
||||
}: ModalHeaderProps) {
|
||||
return (
|
||||
<View
|
||||
role="heading"
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
height: 60,
|
||||
flex: 'none',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
{leftContent}
|
||||
</View>
|
||||
|
||||
{(title || showLogo) && (
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
// We need to force a width for the text-overflow
|
||||
// ellipses to work because we are aligning center.
|
||||
width: 'calc(100% - 60px)',
|
||||
}}
|
||||
>
|
||||
{showLogo && (
|
||||
<SvgLogo
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ justifyContent: 'center', alignSelf: 'center' }}
|
||||
/>
|
||||
)}
|
||||
{title &&
|
||||
(typeof title === 'string' || typeof title === 'number' ? (
|
||||
<ModalTitle title={`${title}`} />
|
||||
) : (
|
||||
title
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rightContent && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
{rightContent}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalTitleProps = {
|
||||
title: string;
|
||||
isEditable?: boolean;
|
||||
getStyle?: (isEditing: boolean) => CSSProperties;
|
||||
onEdit?: (isEditing: boolean) => void;
|
||||
onTitleUpdate?: (newName: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Displays a modal title that can be edited inline when enabled.
|
||||
*
|
||||
* Renders a centered, bold title. If `isEditable` is true, clicking the title switches it to an input field
|
||||
* so the user can edit the text. Pressing Enter or completing the input will call `onTitleUpdate` with the
|
||||
* new value only if it differs from the original, then exit edit mode. `getStyle` can supply additional
|
||||
* style overrides based on whether the title is currently being edited.
|
||||
*
|
||||
* @param title - The current title text to display.
|
||||
* @param isEditable - If true, the title becomes clickable and editable.
|
||||
* @param getStyle - Optional function that receives `isEditing` and returns CSS overrides merged into the title/input.
|
||||
* @param onTitleUpdate - Optional callback invoked with the new title when the user commits a change (only called if the value changed).
|
||||
* @returns A JSX element: an Input when editing, otherwise a centered span showing the title.
|
||||
*/
|
||||
export function ModalTitle({
|
||||
title,
|
||||
isEditable,
|
||||
getStyle,
|
||||
onTitleUpdate,
|
||||
}: ModalTitleProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const onTitleClick = () => {
|
||||
if (isEditable) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const _onTitleUpdate = (newTitle: string) => {
|
||||
if (newTitle !== title) {
|
||||
onTitleUpdate?.(newTitle);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const style = getStyle?.(isEditing);
|
||||
|
||||
return isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
...style,
|
||||
}}
|
||||
defaultValue={title}
|
||||
onUpdate={_onTitleUpdate}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_onTitleUpdate?.(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={onTitleClick}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
...(isEditable && styles.underlinedText),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalCloseButtonProps = {
|
||||
onPress?: () => void;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* A compact "close" button that renders a small delete icon inside a bare-styled Button.
|
||||
*
|
||||
* The `onPress` handler is forwarded to the Button. The `style` prop customizes the SVG icon's
|
||||
* appearance (it is applied to the `SvgDelete`), not the Button itself. The Button uses fixed
|
||||
* internal padding and the icon is rendered at a width of 10px.
|
||||
*
|
||||
* @param onPress - Optional callback invoked when the button is pressed.
|
||||
* @param style - Optional CSS properties applied to the delete icon SVG.
|
||||
* @returns A JSX element for use in modal headers or other compact UI areas.
|
||||
*/
|
||||
export function ModalCloseButton({ onPress, style }: ModalCloseButtonProps) {
|
||||
return (
|
||||
<Button variant="bare" onPress={onPress} style={{ padding: '10px 10px' }}>
|
||||
<SvgDelete width={10} style={style} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
74
packages/plugins-core/src/client.ts
Normal file
74
packages/plugins-core/src/client.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// React Components (client-side only)
|
||||
export { AlignedText } from '@actual-app/components/aligned-text';
|
||||
export { Block } from '@actual-app/components/block';
|
||||
export { Button, ButtonWithLoading } from '@actual-app/components/button';
|
||||
export { Card } from '@actual-app/components/card';
|
||||
export { FormError } from '@actual-app/components/form-error';
|
||||
export { InitialFocus } from '@actual-app/components/initial-focus';
|
||||
export { InlineField } from '@actual-app/components/inline-field';
|
||||
export { Input } from '@actual-app/components/input';
|
||||
export { Label } from '@actual-app/components/label';
|
||||
export { Menu } from '@actual-app/components/menu';
|
||||
export { Paragraph } from '@actual-app/components/paragraph';
|
||||
export { Popover } from '@actual-app/components/popover';
|
||||
export { Select } from '@actual-app/components/select';
|
||||
export { SpaceBetween } from '@actual-app/components/space-between';
|
||||
export { Stack } from '@actual-app/components/stack';
|
||||
export { Text } from '@actual-app/components/text';
|
||||
export { TextOneLine } from '@actual-app/components/text-one-line';
|
||||
export { Toggle } from '@actual-app/components/toggle';
|
||||
export { Tooltip } from '@actual-app/components/tooltip';
|
||||
export { View } from '@actual-app/components/view';
|
||||
|
||||
// Modal Components (client-side only)
|
||||
export {
|
||||
ModalTitle,
|
||||
ModalButtons,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
} from './BasicModalComponents';
|
||||
|
||||
// Client-side middleware
|
||||
export { initializePlugin } from './middleware';
|
||||
|
||||
// Icons, styles, theme (client-side only)
|
||||
export * from '@actual-app/components/icons/v2';
|
||||
export * from '@actual-app/components/styles';
|
||||
export * from '@actual-app/components/theme';
|
||||
|
||||
// Client-side hooks (React hooks)
|
||||
export { useReport } from './utils';
|
||||
|
||||
// Query System (also needed on client-side for components)
|
||||
export {
|
||||
Query,
|
||||
q,
|
||||
getPrimaryOrderBy,
|
||||
type QueryState,
|
||||
type QueryBuilder,
|
||||
type ObjectExpression,
|
||||
} from './query';
|
||||
|
||||
// Spreadsheet types and utilities (client-side only)
|
||||
export {
|
||||
parametrizedField,
|
||||
type SheetFields,
|
||||
type Binding,
|
||||
type SheetNames,
|
||||
type Spreadsheets,
|
||||
type BindingObject,
|
||||
} from './spreadsheet';
|
||||
|
||||
// Client-side plugin types
|
||||
export type {
|
||||
ActualPlugin,
|
||||
ActualPluginInitialized,
|
||||
ThemeColorOverrides,
|
||||
HostContext,
|
||||
} from './types/actualPlugin';
|
||||
|
||||
export type { BasicModalProps } from '@actual-app/components/props/modalProps';
|
||||
export type {
|
||||
ActualPluginToolkit,
|
||||
ActualPluginToolkitFunctions,
|
||||
} from './types/toolkit';
|
||||
13
packages/plugins-core/src/index.ts
Normal file
13
packages/plugins-core/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Main Entry Point for @actual-app/plugins-core
|
||||
*
|
||||
* Re-exports everything from both server and client exports.
|
||||
* `server` must be used in `loot-core`
|
||||
* `client` must be used in `desktop-client`
|
||||
*/
|
||||
|
||||
// Re-export all server-safe exports
|
||||
export * from './server';
|
||||
|
||||
// Re-export all client-only exports
|
||||
export * from './client';
|
||||
152
packages/plugins-core/src/middleware.ts
Normal file
152
packages/plugins-core/src/middleware.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import { BasicModalProps } from '@actual-app/components/props/modalProps';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import {
|
||||
ActualPlugin,
|
||||
ActualPluginInitialized,
|
||||
SidebarLocations,
|
||||
} from './types/actualPlugin';
|
||||
|
||||
const containerRoots = new WeakMap<HTMLElement, Map<string, ReactDOM.Root>>();
|
||||
|
||||
/**
|
||||
* Generates a short random plugin identifier string.
|
||||
*
|
||||
* The returned value has the form `plugin-<random>` where `<random>` is
|
||||
* a 10-character base-36 substring derived from Math.random().
|
||||
*
|
||||
* @returns A pseudo-random plugin id (not cryptographically unique).
|
||||
*/
|
||||
function generateRandomPluginId() {
|
||||
return 'plugin-' + Math.random().toString(36).slice(2, 12);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve or create a React root for a specific plugin within a host container.
|
||||
*
|
||||
* Returns a cached ReactDOM.Root associated with the given container and pluginId,
|
||||
* creating and caching a new root if none exists. The mapping is stored in a
|
||||
* per-container WeakMap so roots are reused for the same (container, pluginId)
|
||||
* pair and can be garbage-collected with the container.
|
||||
*
|
||||
* @param container - Host DOM element that will host the React root.
|
||||
* @param pluginId - Identifier for the plugin instance; roots are namespaced by this id.
|
||||
* @returns The existing or newly created React root for the specified container and pluginId.
|
||||
*/
|
||||
function getOrCreateRoot(container: HTMLElement, pluginId: string) {
|
||||
let pluginMap = containerRoots.get(container);
|
||||
if (!pluginMap) {
|
||||
pluginMap = new Map();
|
||||
containerRoots.set(container, pluginMap);
|
||||
}
|
||||
|
||||
let root = pluginMap.get(pluginId);
|
||||
if (!root) {
|
||||
root = ReactDOM.createRoot(container);
|
||||
pluginMap.set(pluginId, root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces an initialized plugin wrapper that mounts the plugin's UI into per-plugin React roots
|
||||
* and forwards host resources.
|
||||
*
|
||||
* The returned plugin is a shallow copy of `plugin` with `initialized: true` and an `activate`
|
||||
* implementation that:
|
||||
* - installs `initReactI18next` into the plugin's i18n instance,
|
||||
* - wraps the host activation context to forward host-provided resources (`db`, `q`, theme and utility
|
||||
* helpers) and to replace UI registration helpers so provided React elements are rendered into
|
||||
* per-container, per-plugin React roots (menus, modals, routes, dashboard widgets),
|
||||
* - preserves and then calls the original `plugin.activate` with the wrapped context.
|
||||
*
|
||||
* @param plugin - The plugin to initialize.
|
||||
* @param providedPluginId - Optional plugin identifier to use for scoping per-container React roots;
|
||||
* when omitted a random plugin id is generated.
|
||||
* @returns A plugin object marked `initialized: true` whose `activate` is wrapped to provide the
|
||||
* augmented context and per-plugin rendering behavior.
|
||||
*/
|
||||
export function initializePlugin(
|
||||
plugin: ActualPlugin,
|
||||
providedPluginId?: string,
|
||||
): ActualPluginInitialized {
|
||||
const pluginId = providedPluginId || generateRandomPluginId();
|
||||
|
||||
const originalActivate = plugin.activate;
|
||||
|
||||
const newPlugin: ActualPluginInitialized = {
|
||||
...plugin,
|
||||
initialized: true,
|
||||
activate: context => {
|
||||
context.i18nInstance.use(initReactI18next);
|
||||
|
||||
const wrappedContext = {
|
||||
...context,
|
||||
|
||||
// Database provided by host
|
||||
db: context.db,
|
||||
|
||||
// Query builder passed through directly
|
||||
q: context.q,
|
||||
|
||||
registerMenu(position: SidebarLocations, element: ReactElement) {
|
||||
return context.registerMenu(position, container => {
|
||||
const root = getOrCreateRoot(container, pluginId);
|
||||
root.render(element);
|
||||
});
|
||||
},
|
||||
|
||||
pushModal(element: ReactElement, modalProps?: BasicModalProps) {
|
||||
context.pushModal(container => {
|
||||
const root = getOrCreateRoot(container, pluginId);
|
||||
root.render(element);
|
||||
}, modalProps);
|
||||
},
|
||||
|
||||
registerRoute(path: string, element: ReactElement) {
|
||||
return context.registerRoute(path, container => {
|
||||
const root = getOrCreateRoot(container, pluginId);
|
||||
root.render(element);
|
||||
});
|
||||
},
|
||||
|
||||
registerDashboardWidget(
|
||||
widgetType: string,
|
||||
displayName: string,
|
||||
element: ReactElement,
|
||||
options?: {
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
},
|
||||
) {
|
||||
return context.registerDashboardWidget(
|
||||
widgetType,
|
||||
displayName,
|
||||
container => {
|
||||
const root = getOrCreateRoot(container, pluginId);
|
||||
root.render(element);
|
||||
},
|
||||
options,
|
||||
);
|
||||
},
|
||||
|
||||
// Theme methods - passed through from host context
|
||||
addTheme: context.addTheme,
|
||||
overrideTheme: context.overrideTheme,
|
||||
|
||||
// Report and spreadsheet utilities - passed through from host context
|
||||
createSpreadsheet: context.createSpreadsheet,
|
||||
makeFilters: context.makeFilters,
|
||||
};
|
||||
|
||||
originalActivate(wrappedContext);
|
||||
},
|
||||
};
|
||||
|
||||
return newPlugin;
|
||||
}
|
||||
215
packages/plugins-core/src/query/index.ts
Normal file
215
packages/plugins-core/src/query/index.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Query System - Single Source of Truth
|
||||
*
|
||||
* This is the main query implementation used by both loot-core and plugins.
|
||||
* No more conversion functions needed!
|
||||
*/
|
||||
|
||||
import { WithRequired } from '../types/util';
|
||||
|
||||
export type ObjectExpression = {
|
||||
[key: string]: ObjectExpression | unknown;
|
||||
};
|
||||
|
||||
export type QueryState = {
|
||||
get table(): string;
|
||||
get tableOptions(): Readonly<Record<string, unknown>>;
|
||||
get filterExpressions(): ReadonlyArray<ObjectExpression>;
|
||||
get selectExpressions(): ReadonlyArray<ObjectExpression | string | '*'>;
|
||||
get groupExpressions(): ReadonlyArray<ObjectExpression | string>;
|
||||
get orderExpressions(): ReadonlyArray<ObjectExpression | string>;
|
||||
get calculation(): boolean;
|
||||
get rawMode(): boolean;
|
||||
get withDead(): boolean;
|
||||
get validateRefs(): boolean;
|
||||
get limit(): number | null;
|
||||
get offset(): number | null;
|
||||
};
|
||||
|
||||
export class Query {
|
||||
state: QueryState;
|
||||
|
||||
constructor(state: WithRequired<Partial<QueryState>, 'table'>) {
|
||||
this.state = {
|
||||
tableOptions: state.tableOptions || {},
|
||||
filterExpressions: state.filterExpressions || [],
|
||||
selectExpressions: state.selectExpressions || [],
|
||||
groupExpressions: state.groupExpressions || [],
|
||||
orderExpressions: state.orderExpressions || [],
|
||||
calculation: false,
|
||||
rawMode: false,
|
||||
withDead: false,
|
||||
validateRefs: true,
|
||||
limit: null,
|
||||
offset: null,
|
||||
...state,
|
||||
};
|
||||
}
|
||||
|
||||
filter(expr: ObjectExpression) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: [...this.state.filterExpressions, expr],
|
||||
});
|
||||
}
|
||||
|
||||
unfilter(exprs?: Array<keyof ObjectExpression>) {
|
||||
// Remove all filters if no arguments are passed
|
||||
if (!exprs) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: [],
|
||||
});
|
||||
}
|
||||
|
||||
const exprSet = new Set(exprs);
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: this.state.filterExpressions.filter(
|
||||
expr => !exprSet.has(Object.keys(expr)[0]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
select(
|
||||
exprs:
|
||||
| Array<ObjectExpression | string>
|
||||
| ObjectExpression
|
||||
| string
|
||||
| '*'
|
||||
| ['*'] = [],
|
||||
) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
selectExpressions: exprs,
|
||||
calculation: false,
|
||||
});
|
||||
}
|
||||
|
||||
calculate(expr: ObjectExpression | string) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
selectExpressions: [{ result: expr }],
|
||||
calculation: true,
|
||||
});
|
||||
}
|
||||
|
||||
groupBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
groupExpressions: [...this.state.groupExpressions, ...exprs],
|
||||
});
|
||||
}
|
||||
|
||||
orderBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
orderExpressions: [...this.state.orderExpressions, ...exprs],
|
||||
});
|
||||
}
|
||||
|
||||
limit(num: number) {
|
||||
return new Query({ ...this.state, limit: num });
|
||||
}
|
||||
|
||||
offset(num: number) {
|
||||
return new Query({ ...this.state, offset: num });
|
||||
}
|
||||
|
||||
raw() {
|
||||
return new Query({ ...this.state, rawMode: true });
|
||||
}
|
||||
|
||||
withDead() {
|
||||
return new Query({ ...this.state, withDead: true });
|
||||
}
|
||||
|
||||
withoutValidatedRefs() {
|
||||
return new Query({ ...this.state, validateRefs: false });
|
||||
}
|
||||
|
||||
options(opts: Record<string, unknown>) {
|
||||
return new Query({ ...this.state, tableOptions: opts });
|
||||
}
|
||||
|
||||
reset() {
|
||||
return q(this.state.table);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
serializeAsString() {
|
||||
return JSON.stringify(this.serialize());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new immutable Query preconfigured for the given table.
|
||||
*
|
||||
* @param table - The table name to build the query for.
|
||||
* @returns A new Query instance whose state.table is set to `table`.
|
||||
*/
|
||||
export function q(table: QueryState['table']) {
|
||||
return new Query({ table });
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder type for use in contexts
|
||||
*/
|
||||
export interface QueryBuilder {
|
||||
(table: string): Query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the primary ORDER BY clause for a Query.
|
||||
*
|
||||
* If the query has any order expressions, the first one is normalized into a simple
|
||||
* descriptor. If the first expression is a string it is treated as the field name
|
||||
* with ascending order. If it is an object, the first key is used as the field
|
||||
* and its value as the order.
|
||||
*
|
||||
* When the query has no order expressions and `defaultOrderBy` is provided, the
|
||||
* function returns an object produced by merging `{ order: 'asc' }` with
|
||||
* `defaultOrderBy` (so the default order is ascending unless overridden by the
|
||||
* spread object). If there is no order information and no default, `null` is
|
||||
* returned.
|
||||
*
|
||||
* @param query - The Query instance to inspect.
|
||||
* @param defaultOrderBy - Fallback object expression to use when the query has no order expressions; may be `null`.
|
||||
* @returns An object describing the primary order (commonly `{ field: string, order: 'asc' | 'desc' }`,
|
||||
* or a merged object when `defaultOrderBy` is used), or `null` if no ordering is available.
|
||||
*/
|
||||
export function getPrimaryOrderBy(
|
||||
query: Query,
|
||||
defaultOrderBy: ObjectExpression | null,
|
||||
) {
|
||||
const orderExprs = query.serialize().orderExpressions;
|
||||
if (orderExprs.length === 0) {
|
||||
if (defaultOrderBy) {
|
||||
return { order: 'asc', ...defaultOrderBy };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstOrder = orderExprs[0];
|
||||
if (typeof firstOrder === 'string') {
|
||||
return { field: firstOrder, order: 'asc' };
|
||||
}
|
||||
// Handle this form: { field: 'desc' }
|
||||
const [field] = Object.keys(firstOrder);
|
||||
return { field, order: firstOrder[field] };
|
||||
}
|
||||
53
packages/plugins-core/src/server.ts
Normal file
53
packages/plugins-core/src/server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Server-Only Exports for @actual-app/plugins-core
|
||||
*
|
||||
* This file contains only types and utilities that can be used in server environments
|
||||
* (Web Workers, Node.js) without any DOM dependencies or React components.
|
||||
*/
|
||||
|
||||
// Database types
|
||||
export type {
|
||||
SqlParameter,
|
||||
DatabaseQueryResult,
|
||||
DatabaseRow,
|
||||
DatabaseSelectResult,
|
||||
DatabaseResult,
|
||||
DatabaseOperation,
|
||||
PluginMetadata,
|
||||
} from './types/database';
|
||||
|
||||
// Plugin file types
|
||||
export type { PluginFile, PluginFileCollection } from './types/plugin-files';
|
||||
|
||||
// AQL query result types
|
||||
export type { AQLQueryResult, AQLQueryOptions } from './types/aql-result';
|
||||
|
||||
// Model types (server-safe)
|
||||
export type * from './types/models';
|
||||
|
||||
// Plugin types (server-safe ones)
|
||||
export type {
|
||||
PluginDatabase,
|
||||
PluginSpreadsheet,
|
||||
PluginBinding,
|
||||
PluginCellValue,
|
||||
PluginFilterCondition,
|
||||
PluginFilterResult,
|
||||
PluginConditionValue,
|
||||
PluginMigration,
|
||||
PluginContext,
|
||||
ContextEvent,
|
||||
} from './types/actualPlugin';
|
||||
|
||||
export type { ActualPluginEntry } from './types/actualPluginEntry';
|
||||
export type { ActualPluginManifest } from './types/actualPluginManifest';
|
||||
|
||||
// Query System (server-safe)
|
||||
export {
|
||||
Query,
|
||||
q,
|
||||
getPrimaryOrderBy,
|
||||
type QueryState,
|
||||
type QueryBuilder,
|
||||
type ObjectExpression,
|
||||
} from './query';
|
||||
127
packages/plugins-core/src/spreadsheet.ts
Normal file
127
packages/plugins-core/src/spreadsheet.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Spreadsheet Types and Utilities
|
||||
*
|
||||
* Core spreadsheet schema definitions and binding utilities used across
|
||||
* the application for managing financial data in sheet-like structures.
|
||||
*/
|
||||
|
||||
import { type Query } from './query';
|
||||
|
||||
export type Spreadsheets = {
|
||||
account: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Account fields
|
||||
balance: number;
|
||||
[key: `balance-${string}-cleared`]: number | null;
|
||||
'accounts-balance': number;
|
||||
'onbudget-accounts-balance': number;
|
||||
'offbudget-accounts-balance': number;
|
||||
'closed-accounts-balance': number;
|
||||
balanceCleared: number;
|
||||
balanceUncleared: number;
|
||||
lastReconciled: string | null;
|
||||
};
|
||||
category: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
balance: number;
|
||||
balanceCleared: number;
|
||||
balanceUncleared: number;
|
||||
};
|
||||
'envelope-budget': {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Envelope budget fields
|
||||
'available-funds': number;
|
||||
'last-month-overspent': number;
|
||||
buffered: number;
|
||||
'buffered-auto': number;
|
||||
'buffered-selected': number;
|
||||
'to-budget': number | null;
|
||||
'from-last-month': number;
|
||||
'total-budgeted': number;
|
||||
'total-income': number;
|
||||
'total-spent': number;
|
||||
'total-leftover': number;
|
||||
'group-sum-amount': number;
|
||||
'group-budget': number;
|
||||
'group-leftover': number;
|
||||
budget: number;
|
||||
'sum-amount': number;
|
||||
leftover: number;
|
||||
carryover: number;
|
||||
goal: number;
|
||||
'long-goal': number;
|
||||
};
|
||||
'tracking-budget': {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Tracking budget fields
|
||||
'total-budgeted': number;
|
||||
'total-budget-income': number;
|
||||
'total-saved': number;
|
||||
'total-income': number;
|
||||
'total-spent': number;
|
||||
'real-saved': number;
|
||||
'total-leftover': number;
|
||||
'group-sum-amount': number;
|
||||
'group-budget': number;
|
||||
'group-leftover': number;
|
||||
budget: number;
|
||||
'sum-amount': number;
|
||||
leftover: number;
|
||||
carryover: number;
|
||||
goal: number;
|
||||
'long-goal': number;
|
||||
};
|
||||
[`balance`]: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Balance fields
|
||||
[key: `balance-query-${string}`]: number;
|
||||
[key: `selected-transactions-${string}`]: Array<{ id: string }>;
|
||||
[key: `selected-balance-${string}`]: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SheetNames = keyof Spreadsheets & string;
|
||||
|
||||
export type SheetFields<SheetName extends SheetNames> =
|
||||
keyof Spreadsheets[SheetName] & string;
|
||||
|
||||
export type BindingObject<
|
||||
SheetName extends SheetNames,
|
||||
SheetFieldName extends SheetFields<SheetName>,
|
||||
> = {
|
||||
name: SheetFieldName;
|
||||
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
|
||||
query?: Query | undefined;
|
||||
};
|
||||
|
||||
export type Binding<
|
||||
SheetName extends SheetNames,
|
||||
SheetFieldName extends SheetFields<SheetName>,
|
||||
> =
|
||||
| SheetFieldName
|
||||
| {
|
||||
name: SheetFieldName;
|
||||
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
|
||||
query?: Query | undefined;
|
||||
};
|
||||
|
||||
export const parametrizedField =
|
||||
<SheetName extends SheetNames>() =>
|
||||
<SheetFieldName extends SheetFields<SheetName>>(field: SheetFieldName) =>
|
||||
(id?: string): SheetFieldName =>
|
||||
`${field}-${id}` as SheetFieldName;
|
||||
517
packages/plugins-core/src/types/actualPlugin.ts
Normal file
517
packages/plugins-core/src/types/actualPlugin.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { ReactElement } from 'react';
|
||||
|
||||
import type { BasicModalProps } from '@actual-app/components/props/modalProps';
|
||||
import type { i18n } from 'i18next';
|
||||
|
||||
import { Query, QueryBuilder } from '../query';
|
||||
|
||||
import type {
|
||||
PayeeEntity,
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
AccountEntity,
|
||||
ScheduleEntity,
|
||||
} from './models';
|
||||
|
||||
export type SidebarLocations =
|
||||
| 'main-menu'
|
||||
| 'more-menu'
|
||||
| 'before-accounts'
|
||||
| 'after-accounts'
|
||||
| 'topbar';
|
||||
|
||||
// Define condition value types for filtering
|
||||
export type PluginConditionValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| Array<string | number>
|
||||
| { num1: number; num2: number };
|
||||
|
||||
export type PluginFilterCondition = {
|
||||
field: string;
|
||||
op: string;
|
||||
value: PluginConditionValue;
|
||||
type?: string;
|
||||
customName?: string;
|
||||
};
|
||||
|
||||
export type PluginFilterResult = {
|
||||
filters: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Simple color mapping type for theme methods
|
||||
export type ThemeColorOverrides = {
|
||||
// Page colors
|
||||
pageBackground?: string;
|
||||
pageBackgroundModalActive?: string;
|
||||
pageBackgroundTopLeft?: string;
|
||||
pageBackgroundBottomRight?: string;
|
||||
pageBackgroundLineTop?: string;
|
||||
pageBackgroundLineMid?: string;
|
||||
pageBackgroundLineBottom?: string;
|
||||
pageText?: string;
|
||||
pageTextLight?: string;
|
||||
pageTextSubdued?: string;
|
||||
pageTextDark?: string;
|
||||
pageTextPositive?: string;
|
||||
pageTextLink?: string;
|
||||
pageTextLinkLight?: string;
|
||||
|
||||
// Card colors
|
||||
cardBackground?: string;
|
||||
cardBorder?: string;
|
||||
cardShadow?: string;
|
||||
|
||||
// Table colors
|
||||
tableBackground?: string;
|
||||
tableRowBackgroundHover?: string;
|
||||
tableText?: string;
|
||||
tableTextLight?: string;
|
||||
tableTextSubdued?: string;
|
||||
tableTextSelected?: string;
|
||||
tableTextHover?: string;
|
||||
tableTextInactive?: string;
|
||||
tableHeaderText?: string;
|
||||
tableHeaderBackground?: string;
|
||||
tableBorder?: string;
|
||||
tableBorderSelected?: string;
|
||||
tableBorderHover?: string;
|
||||
tableBorderSeparator?: string;
|
||||
tableRowBackgroundHighlight?: string;
|
||||
tableRowBackgroundHighlightText?: string;
|
||||
tableRowHeaderBackground?: string;
|
||||
tableRowHeaderText?: string;
|
||||
|
||||
// Sidebar colors
|
||||
sidebarBackground?: string;
|
||||
sidebarItemBackgroundPending?: string;
|
||||
sidebarItemBackgroundPositive?: string;
|
||||
sidebarItemBackgroundFailed?: string;
|
||||
sidebarItemBackgroundHover?: string;
|
||||
sidebarItemAccentSelected?: string;
|
||||
sidebarItemText?: string;
|
||||
sidebarItemTextSelected?: string;
|
||||
|
||||
// Menu colors
|
||||
menuBackground?: string;
|
||||
menuItemBackground?: string;
|
||||
menuItemBackgroundHover?: string;
|
||||
menuItemText?: string;
|
||||
menuItemTextHover?: string;
|
||||
menuItemTextSelected?: string;
|
||||
menuItemTextHeader?: string;
|
||||
menuBorder?: string;
|
||||
menuBorderHover?: string;
|
||||
menuKeybindingText?: string;
|
||||
menuAutoCompleteBackground?: string;
|
||||
menuAutoCompleteBackgroundHover?: string;
|
||||
menuAutoCompleteText?: string;
|
||||
menuAutoCompleteTextHover?: string;
|
||||
menuAutoCompleteTextHeader?: string;
|
||||
menuAutoCompleteItemTextHover?: string;
|
||||
menuAutoCompleteItemText?: string;
|
||||
|
||||
// Modal colors
|
||||
modalBackground?: string;
|
||||
modalBorder?: string;
|
||||
|
||||
// Mobile colors
|
||||
mobileHeaderBackground?: string;
|
||||
mobileHeaderText?: string;
|
||||
mobileHeaderTextSubdued?: string;
|
||||
mobileHeaderTextHover?: string;
|
||||
mobilePageBackground?: string;
|
||||
mobileNavBackground?: string;
|
||||
mobileNavItem?: string;
|
||||
mobileNavItemSelected?: string;
|
||||
mobileAccountShadow?: string;
|
||||
mobileAccountText?: string;
|
||||
mobileTransactionSelected?: string;
|
||||
mobileViewTheme?: string;
|
||||
mobileConfigServerViewTheme?: string;
|
||||
|
||||
// Markdown colors
|
||||
markdownNormal?: string;
|
||||
markdownDark?: string;
|
||||
markdownLight?: string;
|
||||
|
||||
// Button colors - Menu buttons
|
||||
buttonMenuText?: string;
|
||||
buttonMenuTextHover?: string;
|
||||
buttonMenuBackground?: string;
|
||||
buttonMenuBackgroundHover?: string;
|
||||
buttonMenuBorder?: string;
|
||||
buttonMenuSelectedText?: string;
|
||||
buttonMenuSelectedTextHover?: string;
|
||||
buttonMenuSelectedBackground?: string;
|
||||
buttonMenuSelectedBackgroundHover?: string;
|
||||
buttonMenuSelectedBorder?: string;
|
||||
|
||||
// Button colors - Primary buttons
|
||||
buttonPrimaryText?: string;
|
||||
buttonPrimaryTextHover?: string;
|
||||
buttonPrimaryBackground?: string;
|
||||
buttonPrimaryBackgroundHover?: string;
|
||||
buttonPrimaryBorder?: string;
|
||||
buttonPrimaryShadow?: string;
|
||||
buttonPrimaryDisabledText?: string;
|
||||
buttonPrimaryDisabledBackground?: string;
|
||||
buttonPrimaryDisabledBorder?: string;
|
||||
|
||||
// Button colors - Normal buttons
|
||||
buttonNormalText?: string;
|
||||
buttonNormalTextHover?: string;
|
||||
buttonNormalBackground?: string;
|
||||
buttonNormalBackgroundHover?: string;
|
||||
buttonNormalBorder?: string;
|
||||
buttonNormalShadow?: string;
|
||||
buttonNormalSelectedText?: string;
|
||||
buttonNormalSelectedBackground?: string;
|
||||
buttonNormalDisabledText?: string;
|
||||
buttonNormalDisabledBackground?: string;
|
||||
buttonNormalDisabledBorder?: string;
|
||||
|
||||
// Button colors - Bare buttons
|
||||
buttonBareText?: string;
|
||||
buttonBareTextHover?: string;
|
||||
buttonBareBackground?: string;
|
||||
buttonBareBackgroundHover?: string;
|
||||
buttonBareBackgroundActive?: string;
|
||||
buttonBareDisabledText?: string;
|
||||
buttonBareDisabledBackground?: string;
|
||||
|
||||
// Calendar colors
|
||||
calendarText?: string;
|
||||
calendarBackground?: string;
|
||||
calendarItemText?: string;
|
||||
calendarItemBackground?: string;
|
||||
calendarSelectedBackground?: string;
|
||||
calendarCellBackground?: string;
|
||||
|
||||
// Status colors - Notice
|
||||
noticeBackground?: string;
|
||||
noticeBackgroundLight?: string;
|
||||
noticeBackgroundDark?: string;
|
||||
noticeText?: string;
|
||||
noticeTextLight?: string;
|
||||
noticeTextDark?: string;
|
||||
noticeTextMenu?: string;
|
||||
noticeTextMenuHover?: string;
|
||||
noticeBorder?: string;
|
||||
|
||||
// Status colors - Warning
|
||||
warningBackground?: string;
|
||||
warningText?: string;
|
||||
warningTextLight?: string;
|
||||
warningTextDark?: string;
|
||||
warningBorder?: string;
|
||||
|
||||
// Status colors - Error
|
||||
errorBackground?: string;
|
||||
errorText?: string;
|
||||
errorTextDark?: string;
|
||||
errorTextDarker?: string;
|
||||
errorTextMenu?: string;
|
||||
errorBorder?: string;
|
||||
|
||||
// Status colors - Upcoming
|
||||
upcomingBackground?: string;
|
||||
upcomingText?: string;
|
||||
upcomingBorder?: string;
|
||||
|
||||
// Form colors
|
||||
formLabelText?: string;
|
||||
formLabelBackground?: string;
|
||||
formInputBackground?: string;
|
||||
formInputBackgroundSelected?: string;
|
||||
formInputBackgroundSelection?: string;
|
||||
formInputBorder?: string;
|
||||
formInputTextReadOnlySelection?: string;
|
||||
formInputBorderSelected?: string;
|
||||
formInputText?: string;
|
||||
formInputTextSelected?: string;
|
||||
formInputTextPlaceholder?: string;
|
||||
formInputTextPlaceholderSelected?: string;
|
||||
formInputTextSelection?: string;
|
||||
formInputShadowSelected?: string;
|
||||
formInputTextHighlight?: string;
|
||||
|
||||
// Checkbox colors
|
||||
checkboxText?: string;
|
||||
checkboxBackgroundSelected?: string;
|
||||
checkboxBorderSelected?: string;
|
||||
checkboxShadowSelected?: string;
|
||||
checkboxToggleBackground?: string;
|
||||
checkboxToggleBackgroundSelected?: string;
|
||||
checkboxToggleDisabled?: string;
|
||||
|
||||
// Pill colors
|
||||
pillBackground?: string;
|
||||
pillBackgroundLight?: string;
|
||||
pillText?: string;
|
||||
pillTextHighlighted?: string;
|
||||
pillBorder?: string;
|
||||
pillBorderDark?: string;
|
||||
pillBackgroundSelected?: string;
|
||||
pillTextSelected?: string;
|
||||
pillBorderSelected?: string;
|
||||
pillTextSubdued?: string;
|
||||
|
||||
// Reports colors
|
||||
reportsRed?: string;
|
||||
reportsBlue?: string;
|
||||
reportsGreen?: string;
|
||||
reportsGray?: string;
|
||||
reportsLabel?: string;
|
||||
reportsInnerLabel?: string;
|
||||
|
||||
// Note tag colors
|
||||
noteTagBackground?: string;
|
||||
noteTagBackgroundHover?: string;
|
||||
noteTagText?: string;
|
||||
|
||||
// Budget colors
|
||||
budgetCurrentMonth?: string;
|
||||
budgetOtherMonth?: string;
|
||||
budgetHeaderCurrentMonth?: string;
|
||||
budgetHeaderOtherMonth?: string;
|
||||
|
||||
// Floating action bar colors
|
||||
floatingActionBarBackground?: string;
|
||||
floatingActionBarBorder?: string;
|
||||
floatingActionBarText?: string;
|
||||
|
||||
// Tooltip colors
|
||||
tooltipText?: string;
|
||||
tooltipBackground?: string;
|
||||
tooltipBorder?: string;
|
||||
|
||||
// Custom colors (plugin-specific)
|
||||
[customColor: `custom-${string}`]: string;
|
||||
};
|
||||
|
||||
export interface PluginDatabase {
|
||||
runQuery<T = unknown>(
|
||||
sql: string,
|
||||
params?: (string | number)[],
|
||||
fetchAll?: boolean,
|
||||
): Promise<T[] | { changes: number; insertId?: number }>;
|
||||
|
||||
execQuery(sql: string): void;
|
||||
|
||||
transaction(fn: () => void): void;
|
||||
|
||||
getMigrationState(): Promise<string[]>;
|
||||
|
||||
setMetadata(key: string, value: string): Promise<void>;
|
||||
|
||||
getMetadata(key: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Execute an AQL (Actual Query Language) query.
|
||||
* This provides a higher-level abstraction over SQL that's consistent with Actual Budget's query system.
|
||||
*
|
||||
* @param query - The AQL query (can be a PluginQuery object or serialized PluginQueryState)
|
||||
* @param options - Optional parameters for the query
|
||||
* @param options.target - Target database: 'plugin' for plugin tables, 'host' for main app tables. Defaults to 'plugin'
|
||||
* @param options.params - Named parameters for the query
|
||||
* @returns Promise that resolves to the query result with data and dependencies
|
||||
*/
|
||||
aql(
|
||||
query: Query,
|
||||
options?: {
|
||||
target?: 'plugin' | 'host';
|
||||
params?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ data: unknown; dependencies: string[] }>;
|
||||
}
|
||||
|
||||
export type PluginBinding = string | { name: string; query?: Query };
|
||||
|
||||
export type PluginCellValue = { name: string; value: unknown | null };
|
||||
|
||||
export interface PluginSpreadsheet {
|
||||
/**
|
||||
* Bind to a cell and observe changes
|
||||
* @param sheetName - Name of the sheet (optional, defaults to global)
|
||||
* @param binding - Cell binding (string name or object with name and optional query)
|
||||
* @param callback - Function called when cell value changes
|
||||
* @returns Cleanup function to stop observing
|
||||
*/
|
||||
bind(
|
||||
sheetName: string | undefined,
|
||||
binding: PluginBinding,
|
||||
callback: (node: PluginCellValue) => void,
|
||||
): () => void;
|
||||
|
||||
/**
|
||||
* Get a cell value directly
|
||||
* @param sheetName - Name of the sheet
|
||||
* @param name - Cell name
|
||||
* @returns Promise that resolves to the cell value
|
||||
*/
|
||||
get(sheetName: string, name: string): Promise<PluginCellValue>;
|
||||
|
||||
/**
|
||||
* Get all cell names in a sheet
|
||||
* @param sheetName - Name of the sheet
|
||||
* @returns Promise that resolves to array of cell names
|
||||
*/
|
||||
getCellNames(sheetName: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Create a query in a sheet
|
||||
* @param sheetName - Name of the sheet
|
||||
* @param name - Query name
|
||||
* @param query - The query to create
|
||||
* @returns Promise that resolves when query is created
|
||||
*/
|
||||
createQuery(sheetName: string, name: string, query: Query): Promise<void>;
|
||||
}
|
||||
|
||||
export type PluginMigration = [
|
||||
timestamp: number,
|
||||
name: string,
|
||||
upCommand: string,
|
||||
downCommand: string,
|
||||
];
|
||||
|
||||
// Plugin context type for easier reuse
|
||||
export type PluginContext = Omit<
|
||||
HostContext,
|
||||
'registerMenu' | 'pushModal' | 'registerRoute' | 'registerDashboardWidget'
|
||||
> & {
|
||||
registerMenu: (location: SidebarLocations, element: ReactElement) => string;
|
||||
pushModal: (element: ReactElement, modalProps?: BasicModalProps) => void;
|
||||
registerRoute: (path: string, routeElement: ReactElement) => string;
|
||||
|
||||
// Dashboard widget registration - wrapped for JSX elements
|
||||
registerDashboardWidget: (
|
||||
widgetType: string,
|
||||
displayName: string,
|
||||
element: ReactElement,
|
||||
options?: {
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
},
|
||||
) => string;
|
||||
|
||||
// Theme methods - simple and direct
|
||||
addTheme: (
|
||||
themeId: string,
|
||||
displayName: string,
|
||||
colorOverrides: ThemeColorOverrides,
|
||||
options?: {
|
||||
baseTheme?: 'light' | 'dark' | 'midnight';
|
||||
description?: string;
|
||||
},
|
||||
) => void;
|
||||
|
||||
overrideTheme: (
|
||||
themeId: 'light' | 'dark' | 'midnight' | string,
|
||||
colorOverrides: ThemeColorOverrides,
|
||||
) => void;
|
||||
|
||||
db?: PluginDatabase;
|
||||
q: QueryBuilder;
|
||||
|
||||
// Report and spreadsheet utilities
|
||||
createSpreadsheet: () => PluginSpreadsheet;
|
||||
|
||||
makeFilters: (
|
||||
conditions: Array<PluginFilterCondition>,
|
||||
) => Promise<PluginFilterResult>;
|
||||
};
|
||||
|
||||
export interface ActualPlugin {
|
||||
name: string;
|
||||
version: string;
|
||||
uninstall: () => void;
|
||||
migrations?: () => PluginMigration[];
|
||||
activate: (context: PluginContext) => void;
|
||||
}
|
||||
|
||||
export type ActualPluginInitialized = Omit<ActualPlugin, 'activate'> & {
|
||||
initialized: true;
|
||||
activate: (context: HostContext & { db: PluginDatabase }) => void;
|
||||
};
|
||||
|
||||
export interface ContextEvent {
|
||||
payess: { payess: PayeeEntity[] };
|
||||
categories: { categories: CategoryEntity[]; groups: CategoryGroupEntity[] };
|
||||
accounts: { accounts: AccountEntity[] };
|
||||
schedules: { schedules: ScheduleEntity[] };
|
||||
}
|
||||
|
||||
export interface HostContext {
|
||||
navigate: (routePath: string) => void;
|
||||
|
||||
pushModal: (
|
||||
parameter: (container: HTMLDivElement) => void,
|
||||
modalProps?: BasicModalProps,
|
||||
) => void;
|
||||
popModal: () => void;
|
||||
|
||||
registerRoute: (
|
||||
path: string,
|
||||
routeElement: (container: HTMLDivElement) => void,
|
||||
) => string;
|
||||
unregisterRoute: (id: string) => void;
|
||||
|
||||
registerMenu: (
|
||||
location: SidebarLocations,
|
||||
parameter: (container: HTMLDivElement) => void,
|
||||
) => string;
|
||||
unregisterMenu: (id: string) => void;
|
||||
|
||||
on: <K extends keyof ContextEvent>(
|
||||
eventType: K,
|
||||
callback: (data: ContextEvent[K]) => void,
|
||||
) => void;
|
||||
|
||||
// Dashboard widget methods
|
||||
registerDashboardWidget: (
|
||||
widgetType: string,
|
||||
displayName: string,
|
||||
renderWidget: (container: HTMLDivElement) => void,
|
||||
options?: {
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
},
|
||||
) => string;
|
||||
unregisterDashboardWidget: (id: string) => void;
|
||||
|
||||
// Theme methods
|
||||
addTheme: (
|
||||
themeId: string,
|
||||
displayName: string,
|
||||
colorOverrides: ThemeColorOverrides,
|
||||
options?: {
|
||||
baseTheme?: 'light' | 'dark' | 'midnight';
|
||||
description?: string;
|
||||
},
|
||||
) => void;
|
||||
|
||||
overrideTheme: (
|
||||
themeId: 'light' | 'dark' | 'midnight' | string,
|
||||
colorOverrides: ThemeColorOverrides,
|
||||
) => void;
|
||||
|
||||
// Query builder provided by host (loot-core's q function)
|
||||
q: QueryBuilder;
|
||||
|
||||
// Report and spreadsheet utilities for dashboard widgets
|
||||
createSpreadsheet: () => PluginSpreadsheet;
|
||||
|
||||
makeFilters: (
|
||||
conditions: Array<PluginFilterCondition>,
|
||||
) => Promise<PluginFilterResult>;
|
||||
|
||||
i18nInstance: i18n;
|
||||
}
|
||||
5
packages/plugins-core/src/types/actualPluginEntry.ts
Normal file
5
packages/plugins-core/src/types/actualPluginEntry.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ActualPluginInitialized } from './actualPlugin';
|
||||
|
||||
export type ActualPluginEntry = () => ActualPluginInitialized;
|
||||
11
packages/plugins-core/src/types/actualPluginManifest.ts
Normal file
11
packages/plugins-core/src/types/actualPluginManifest.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface ActualPluginManifest {
|
||||
url: string;
|
||||
name: string;
|
||||
version: string;
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
pluginType: 'server' | 'client';
|
||||
minimumActualVersion: string;
|
||||
author: string;
|
||||
plugin?: Blob;
|
||||
}
|
||||
27
packages/plugins-core/src/types/aql-result.ts
Normal file
27
packages/plugins-core/src/types/aql-result.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* AQL Query Result Types
|
||||
*
|
||||
* Types for AQL (Advanced Query Language) query results.
|
||||
*/
|
||||
|
||||
import { DatabaseRow } from './database';
|
||||
|
||||
/**
|
||||
* Result of an AQL query operation
|
||||
*/
|
||||
export interface AQLQueryResult {
|
||||
/** The actual data returned by the query */
|
||||
data: DatabaseRow[] | DatabaseRow | null;
|
||||
/** List of table/field dependencies detected during query execution */
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for AQL query execution
|
||||
*/
|
||||
export interface AQLQueryOptions {
|
||||
/** Target for the query - 'plugin' uses plugin DB, 'host' uses main DB */
|
||||
target?: 'plugin' | 'host';
|
||||
/** Parameters to be passed to the query */
|
||||
params?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
51
packages/plugins-core/src/types/database.ts
Normal file
51
packages/plugins-core/src/types/database.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Plugin Database Types
|
||||
*
|
||||
* Shared types for database operations between plugins and the host application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parameters that can be passed to SQL queries
|
||||
*/
|
||||
export type SqlParameter = string | number | boolean | null | Buffer;
|
||||
|
||||
/**
|
||||
* Result of a database query operation (INSERT, UPDATE, DELETE)
|
||||
*/
|
||||
export interface DatabaseQueryResult {
|
||||
changes: number;
|
||||
insertId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row returned from a database SELECT query
|
||||
*/
|
||||
export type DatabaseRow = Record<string, SqlParameter>;
|
||||
|
||||
/**
|
||||
* Result of a database SELECT query
|
||||
*/
|
||||
export type DatabaseSelectResult = DatabaseRow[];
|
||||
|
||||
/**
|
||||
* Union type for all possible database query results
|
||||
*/
|
||||
export type DatabaseResult = DatabaseQueryResult | DatabaseSelectResult;
|
||||
|
||||
/**
|
||||
* Database transaction operation
|
||||
*/
|
||||
export interface DatabaseOperation {
|
||||
type: 'exec' | 'query';
|
||||
sql: string;
|
||||
params?: SqlParameter[];
|
||||
fetchAll?: boolean; // Only used for query operations
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin database metadata key-value pair
|
||||
*/
|
||||
export interface PluginMetadata {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
25
packages/plugins-core/src/types/models/account.ts
Normal file
25
packages/plugins-core/src/types/models/account.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type AccountEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
offbudget: 0 | 1;
|
||||
closed: 0 | 1;
|
||||
sort_order: number;
|
||||
last_reconciled: string | null;
|
||||
tombstone: 0 | 1;
|
||||
} & (_SyncFields<true> | _SyncFields<false>);
|
||||
|
||||
export type _SyncFields<T> = {
|
||||
account_id: T extends true ? string : null;
|
||||
bank: T extends true ? string : null;
|
||||
bankName: T extends true ? string : null;
|
||||
bankId: T extends true ? number : null;
|
||||
mask: T extends true ? string : null; // end of bank account number
|
||||
official_name: T extends true ? string : null;
|
||||
balance_current: T extends true ? number : null;
|
||||
balance_available: T extends true ? number : null;
|
||||
balance_limit: T extends true ? number : null;
|
||||
account_sync_source: T extends true ? AccountSyncSource : null;
|
||||
last_sync: T extends true ? string : null;
|
||||
};
|
||||
|
||||
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';
|
||||
11
packages/plugins-core/src/types/models/category-group.ts
Normal file
11
packages/plugins-core/src/types/models/category-group.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CategoryEntity } from './category';
|
||||
|
||||
export interface CategoryGroupEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
is_income?: boolean;
|
||||
sort_order?: number;
|
||||
tombstone?: boolean;
|
||||
hidden?: boolean;
|
||||
categories?: CategoryEntity[];
|
||||
}
|
||||
7
packages/plugins-core/src/types/models/category-views.ts
Normal file
7
packages/plugins-core/src/types/models/category-views.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CategoryEntity } from './category';
|
||||
import { CategoryGroupEntity } from './category-group';
|
||||
|
||||
export interface CategoryViews {
|
||||
grouped: CategoryGroupEntity[];
|
||||
list: CategoryEntity[];
|
||||
}
|
||||
13
packages/plugins-core/src/types/models/category.ts
Normal file
13
packages/plugins-core/src/types/models/category.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CategoryGroupEntity } from './category-group';
|
||||
|
||||
export interface CategoryEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
is_income?: boolean;
|
||||
group: CategoryGroupEntity['id'];
|
||||
goal_def?: string;
|
||||
template_settings?: { source: 'notes' | 'ui' };
|
||||
sort_order?: number;
|
||||
tombstone?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
7
packages/plugins-core/src/types/models/index.ts
Normal file
7
packages/plugins-core/src/types/models/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type * from './account';
|
||||
export type * from './payee';
|
||||
export type * from './category';
|
||||
export type * from './category-group';
|
||||
export type * from './category-views';
|
||||
export type * from './rule';
|
||||
export type * from './schedule';
|
||||
10
packages/plugins-core/src/types/models/payee.ts
Normal file
10
packages/plugins-core/src/types/models/payee.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AccountEntity } from './account';
|
||||
|
||||
export interface PayeeEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
transfer_acct?: AccountEntity['id'];
|
||||
favorite?: boolean;
|
||||
learn_categories?: boolean;
|
||||
tombstone?: boolean;
|
||||
}
|
||||
179
packages/plugins-core/src/types/models/rule.ts
Normal file
179
packages/plugins-core/src/types/models/rule.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { type RecurConfig } from './schedule';
|
||||
|
||||
export interface NewRuleEntity {
|
||||
stage: 'pre' | null | 'post';
|
||||
conditionsOp: 'or' | 'and';
|
||||
conditions: RuleConditionEntity[];
|
||||
actions: RuleActionEntity[];
|
||||
tombstone?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleEntity extends NewRuleEntity {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type RuleConditionOp =
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'isapprox'
|
||||
| 'isbetween'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'hasTags'
|
||||
| 'and'
|
||||
| 'matches'
|
||||
| 'onBudget'
|
||||
| 'offBudget';
|
||||
|
||||
export type FieldValueTypes = {
|
||||
account: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
date: string | RecurConfig;
|
||||
notes: string;
|
||||
payee: string;
|
||||
payee_name: string;
|
||||
imported_payee: string;
|
||||
saved: string;
|
||||
transfer: boolean;
|
||||
parent: boolean;
|
||||
cleared: boolean;
|
||||
reconciled: boolean;
|
||||
};
|
||||
|
||||
type BaseConditionEntity<
|
||||
Field extends keyof FieldValueTypes,
|
||||
Op extends RuleConditionOp,
|
||||
> = {
|
||||
field: Field;
|
||||
op: Op;
|
||||
value: Op extends 'oneOf' | 'notOneOf'
|
||||
? Array<FieldValueTypes[Field]>
|
||||
: Op extends 'isbetween'
|
||||
? { num1: number; num2: number }
|
||||
: FieldValueTypes[Field];
|
||||
options?: {
|
||||
inflow?: boolean;
|
||||
outflow?: boolean;
|
||||
month?: boolean;
|
||||
year?: boolean;
|
||||
};
|
||||
conditionsOp?: string;
|
||||
type?: 'id' | 'boolean' | 'date' | 'number' | 'string';
|
||||
customName?: string;
|
||||
queryFilter?: Record<string, { $oneof: string[] }>;
|
||||
};
|
||||
|
||||
export type RuleConditionEntity =
|
||||
| BaseConditionEntity<
|
||||
'account',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
| 'onBudget'
|
||||
| 'offBudget'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'category',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'amount',
|
||||
'is' | 'isapprox' | 'isbetween' | 'gt' | 'gte' | 'lt' | 'lte'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'date',
|
||||
'is' | 'isapprox' | 'isbetween' | 'gt' | 'gte' | 'lt' | 'lte'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'notes',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
| 'hasTags'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'payee',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'imported_payee',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
>
|
||||
| BaseConditionEntity<'saved', 'is'>
|
||||
| BaseConditionEntity<'cleared', 'is'>
|
||||
| BaseConditionEntity<'reconciled', 'is'>;
|
||||
|
||||
export type RuleActionEntity =
|
||||
| SetRuleActionEntity
|
||||
| SetSplitAmountRuleActionEntity
|
||||
| LinkScheduleRuleActionEntity
|
||||
| PrependNoteRuleActionEntity
|
||||
| AppendNoteRuleActionEntity;
|
||||
|
||||
export interface SetRuleActionEntity {
|
||||
field: string;
|
||||
op: 'set';
|
||||
value: unknown;
|
||||
options?: {
|
||||
template?: string;
|
||||
splitIndex?: number;
|
||||
};
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface SetSplitAmountRuleActionEntity {
|
||||
op: 'set-split-amount';
|
||||
value: number;
|
||||
options?: {
|
||||
splitIndex?: number;
|
||||
method: 'fixed-amount' | 'fixed-percent' | 'remainder';
|
||||
};
|
||||
}
|
||||
|
||||
export interface LinkScheduleRuleActionEntity {
|
||||
op: 'link-schedule';
|
||||
value: unknown; // Changed from ScheduleEntity to avoid circular dependency
|
||||
}
|
||||
|
||||
export interface PrependNoteRuleActionEntity {
|
||||
op: 'prepend-notes';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AppendNoteRuleActionEntity {
|
||||
op: 'append-notes';
|
||||
value: string;
|
||||
}
|
||||
49
packages/plugins-core/src/types/models/schedule.ts
Normal file
49
packages/plugins-core/src/types/models/schedule.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { AccountEntity } from './account';
|
||||
import type { PayeeEntity } from './payee';
|
||||
import type { RuleConditionEntity, RuleEntity } from './rule';
|
||||
|
||||
export interface RecurPattern {
|
||||
value: number;
|
||||
type: 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'day';
|
||||
}
|
||||
|
||||
export interface RecurConfig {
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
interval?: number;
|
||||
patterns?: RecurPattern[];
|
||||
skipWeekend?: boolean;
|
||||
start: string;
|
||||
endMode: 'never' | 'after_n_occurrences' | 'on_date';
|
||||
endOccurrences?: number;
|
||||
endDate?: string;
|
||||
weekendSolveMode?: 'before' | 'after';
|
||||
}
|
||||
|
||||
export interface ScheduleEntity {
|
||||
id: string;
|
||||
name?: string;
|
||||
rule: RuleEntity['id'];
|
||||
next_date: string;
|
||||
completed: boolean;
|
||||
posts_transaction: boolean;
|
||||
tombstone: boolean;
|
||||
|
||||
// These are special fields that are actually pulled from the
|
||||
// underlying rule
|
||||
_payee: PayeeEntity['id'];
|
||||
_account: AccountEntity['id'];
|
||||
_amount: number | { num1: number; num2: number };
|
||||
_amountOp: string;
|
||||
_date: RecurConfig;
|
||||
_conditions: RuleConditionEntity[];
|
||||
_actions: Array<{ op: unknown }>;
|
||||
}
|
||||
|
||||
export type DiscoverScheduleEntity = {
|
||||
id: ScheduleEntity['id'];
|
||||
account: AccountEntity['id'];
|
||||
payee: PayeeEntity['id'];
|
||||
date: ScheduleEntity['_date'];
|
||||
amount: ScheduleEntity['_amount'];
|
||||
_conditions: ScheduleEntity['_conditions'];
|
||||
};
|
||||
18
packages/plugins-core/src/types/plugin-files.ts
Normal file
18
packages/plugins-core/src/types/plugin-files.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Plugin File Types
|
||||
*
|
||||
* Types for plugin file operations and storage.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a single file within a plugin package
|
||||
*/
|
||||
export interface PluginFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of files that make up a plugin
|
||||
*/
|
||||
export type PluginFileCollection = PluginFile[];
|
||||
7
packages/plugins-core/src/types/toolkit.ts
Normal file
7
packages/plugins-core/src/types/toolkit.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type ActualPluginToolkitFunctions = {
|
||||
pushModal: (modalName: string, options?: unknown) => void;
|
||||
};
|
||||
|
||||
export type ActualPluginToolkit = {
|
||||
functions: ActualPluginToolkitFunctions;
|
||||
};
|
||||
5
packages/plugins-core/src/types/util.ts
Normal file
5
packages/plugins-core/src/types/util.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Utility types for plugins-core
|
||||
*/
|
||||
|
||||
export type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
30
packages/plugins-core/src/utils.ts
Normal file
30
packages/plugins-core/src/utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { PluginSpreadsheet } from './types/actualPlugin';
|
||||
|
||||
/**
|
||||
* React hook that fetches and returns report data for a plugin spreadsheet.
|
||||
*
|
||||
* Calls the provided async `getData` function with the spreadsheet and a setter;
|
||||
* the setter updates the returned results when data becomes available.
|
||||
*
|
||||
* @param sheetName - Identifier for the sheet (kept for API compatibility; not used by the hook).
|
||||
* @param getData - Async function that receives the spreadsheet and a `setData` callback to supply results.
|
||||
* @returns The most recent results of type `T`, or `null` if no results have been set yet.
|
||||
*/
|
||||
export function useReport<T>(
|
||||
sheetName: string,
|
||||
getData: (
|
||||
spreadsheet: PluginSpreadsheet,
|
||||
setData: (results: T) => void,
|
||||
) => Promise<void>,
|
||||
spreadsheet: PluginSpreadsheet,
|
||||
): T | null {
|
||||
const [results, setResults] = useState<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getData(spreadsheet, results => setResults(results));
|
||||
}, [getData, spreadsheet]);
|
||||
|
||||
return results;
|
||||
}
|
||||
13
packages/plugins-core/tsconfig.json
Normal file
13
packages/plugins-core/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
39
packages/plugins-core/vite.config.mts
Normal file
39
packages/plugins-core/vite.config.mts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'build',
|
||||
lib: {
|
||||
entry: {
|
||||
index: 'src/index.ts',
|
||||
server: 'src/server.ts',
|
||||
client: 'src/client.ts',
|
||||
},
|
||||
name: '@actual-app/plugins-core',
|
||||
fileName: (format, entryName) =>
|
||||
format === 'es' ? `${entryName}.js` : `${entryName}.cjs`,
|
||||
formats: ['es', 'cjs'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom', 'i18next'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
i18next: 'i18next',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
insertTypesEntry: true,
|
||||
outDir: 'build',
|
||||
include: ['src/**/*'],
|
||||
exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
rollupTypes: false,
|
||||
copyDtsFiles: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/sync-server",
|
||||
"version": "25.10.0",
|
||||
"version": "25.9.0",
|
||||
"license": "MIT",
|
||||
"description": "actual syncing server",
|
||||
"bin": {
|
||||
|
||||
@@ -162,8 +162,8 @@ function getAccountResponse(results, accountId, startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const needsAttention = results.sferrors.find(e =>
|
||||
e.startsWith(`Connection to ${account.org.name} may need attention`),
|
||||
const needsAttention = results.sferrors.find(
|
||||
e => e === `Connection to ${account.org.name} may need attention`,
|
||||
);
|
||||
if (needsAttention) {
|
||||
logAccountError(results, accountId, {
|
||||
|
||||
@@ -35,28 +35,7 @@ convict.addFormat({
|
||||
validate(val) {
|
||||
if (val === 'never' || val === 'openid-provider') return;
|
||||
if (typeof val === 'number' && Number.isFinite(val) && val >= 0) return;
|
||||
|
||||
// Handle string values that can be converted to numbers (from env vars)
|
||||
if (typeof val === 'string') {
|
||||
const numVal = Number(val);
|
||||
if (Number.isFinite(numVal) && numVal >= 0) return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Invalid token_expiration value: ${val}: value was "${val}"`,
|
||||
);
|
||||
},
|
||||
coerce(val) {
|
||||
if (val === 'never' || val === 'openid-provider') return val;
|
||||
if (typeof val === 'number') return val;
|
||||
|
||||
// Convert string values to numbers for environment variables
|
||||
if (typeof val === 'string') {
|
||||
const numVal = Number(val);
|
||||
if (Number.isFinite(numVal) && numVal >= 0) return numVal;
|
||||
}
|
||||
|
||||
return val; // Let validate() handle invalid values
|
||||
throw new Error(`Invalid token_expiration value: ${val}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import convict from 'convict';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Import the custom format
|
||||
import './load-config.js';
|
||||
|
||||
describe('tokenExpiration format', () => {
|
||||
let originalEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should accept string numbers from environment variables', () => {
|
||||
// Test string number
|
||||
process.env.TEST_TOKEN_EXPIRATION = '86400';
|
||||
const testSchema = convict({
|
||||
token_expiration: {
|
||||
format: 'tokenExpiration',
|
||||
default: 'never',
|
||||
env: 'TEST_TOKEN_EXPIRATION',
|
||||
},
|
||||
});
|
||||
expect(() => testSchema.validate()).not.toThrow();
|
||||
expect(testSchema.get('token_expiration')).toBe(86400);
|
||||
expect(typeof testSchema.get('token_expiration')).toBe('number');
|
||||
});
|
||||
|
||||
it('should accept different string numbers', () => {
|
||||
const testSchema = convict({
|
||||
token_expiration: {
|
||||
format: 'tokenExpiration',
|
||||
default: 'never',
|
||||
env: 'TEST_TOKEN_EXPIRATION',
|
||||
},
|
||||
});
|
||||
|
||||
// Test different string numbers
|
||||
const testCases = ['3600', '7200', '0'];
|
||||
|
||||
for (const testValue of testCases) {
|
||||
process.env.TEST_TOKEN_EXPIRATION = testValue;
|
||||
testSchema.load({});
|
||||
expect(() => testSchema.validate()).not.toThrow();
|
||||
expect(testSchema.get('token_expiration')).toBe(Number(testValue));
|
||||
expect(typeof testSchema.get('token_expiration')).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept special string values', () => {
|
||||
const testSchema = convict({
|
||||
token_expiration: {
|
||||
format: 'tokenExpiration',
|
||||
default: 'never',
|
||||
env: 'TEST_TOKEN_EXPIRATION',
|
||||
},
|
||||
});
|
||||
|
||||
// Test 'never' value
|
||||
process.env.TEST_TOKEN_EXPIRATION = 'never';
|
||||
testSchema.load({});
|
||||
expect(() => testSchema.validate()).not.toThrow();
|
||||
expect(testSchema.get('token_expiration')).toBe('never');
|
||||
|
||||
// Test 'openid-provider' value
|
||||
process.env.TEST_TOKEN_EXPIRATION = 'openid-provider';
|
||||
testSchema.load({});
|
||||
expect(() => testSchema.validate()).not.toThrow();
|
||||
expect(testSchema.get('token_expiration')).toBe('openid-provider');
|
||||
});
|
||||
|
||||
it('should accept numeric values directly', () => {
|
||||
const testSchema = convict({
|
||||
token_expiration: {
|
||||
format: 'tokenExpiration',
|
||||
default: 'never',
|
||||
},
|
||||
});
|
||||
|
||||
testSchema.set('token_expiration', 86400);
|
||||
expect(() => testSchema.validate()).not.toThrow();
|
||||
expect(testSchema.get('token_expiration')).toBe(86400);
|
||||
});
|
||||
|
||||
it('should reject invalid string values', () => {
|
||||
const testSchema = convict({
|
||||
token_expiration: {
|
||||
format: 'tokenExpiration',
|
||||
default: 'never',
|
||||
env: 'TEST_TOKEN_EXPIRATION',
|
||||
},
|
||||
});
|
||||
|
||||
// Test invalid string
|
||||
process.env.TEST_TOKEN_EXPIRATION = 'invalid';
|
||||
testSchema.load({});
|
||||
expect(() => testSchema.validate()).toThrow(
|
||||
/Invalid token_expiration value/,
|
||||
);
|
||||
|
||||
// Test negative number as string
|
||||
process.env.TEST_TOKEN_EXPIRATION = '-100';
|
||||
testSchema.load({});
|
||||
expect(() => testSchema.validate()).toThrow(
|
||||
/Invalid token_expiration value/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,15 @@
|
||||
"paths": {
|
||||
// TEMPORARY: Until we can fix the "exports" in the loot-core package.json
|
||||
"loot-core/*": ["./packages/loot-core/src/*"],
|
||||
"plugins-core/*": ["./packages/plugins-core/src/*"],
|
||||
"@actual-app/plugins-core": ["./packages/plugins-core/src/index.ts"],
|
||||
"@actual-app/plugins-core/server": [
|
||||
"./packages/plugins-core/src/server.ts"
|
||||
],
|
||||
"@actual-app/plugins-core/client": [
|
||||
"./packages/plugins-core/src/client.ts"
|
||||
],
|
||||
"@actual-app/plugins-core/*": ["./packages/plugins-core/src/*"],
|
||||
"@desktop-client/*": ["./packages/desktop-client/src/*"],
|
||||
"@desktop-client/e2e/*": ["./packages/desktop-client/e2e/*"]
|
||||
},
|
||||
@@ -52,7 +61,8 @@
|
||||
"**/dist/*",
|
||||
"**/lib-dist/*",
|
||||
"**/test-results/*",
|
||||
"**/playwright-report/*"
|
||||
"**/playwright-report/*",
|
||||
"packages/test-plugin/*"
|
||||
],
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
|
||||
6
upcoming-release-notes/5037.md
Normal file
6
upcoming-release-notes/5037.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [passabilities]
|
||||
---
|
||||
|
||||
Adds net worth graph for each account page.
|
||||
6
upcoming-release-notes/5414.md
Normal file
6
upcoming-release-notes/5414.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [Triscal]
|
||||
---
|
||||
|
||||
Improved the handling of schedules that have the same payee, amount, account, and date when posted by the schedule automatically or manually via the previews.
|
||||
6
upcoming-release-notes/5562.md
Normal file
6
upcoming-release-notes/5562.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Implement react compiler to take advantage of some performance improvements
|
||||
6
upcoming-release-notes/5572.md
Normal file
6
upcoming-release-notes/5572.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [e13h]
|
||||
---
|
||||
|
||||
Remove auto-scrolling behavior when editing split transactions on mobile
|
||||
6
upcoming-release-notes/5584.md
Normal file
6
upcoming-release-notes/5584.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [karimkodera]
|
||||
---
|
||||
|
||||
Introduction of APIs to handle Schedule + a bit more. Refer to updated API documentation PR2811 on documentation.
|
||||
6
upcoming-release-notes/5610.md
Normal file
6
upcoming-release-notes/5610.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Re-design mobile accounts page to better match the transactions and budget tables/list + add all accounts page
|
||||
6
upcoming-release-notes/5622.md
Normal file
6
upcoming-release-notes/5622.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [itsbekas]
|
||||
---
|
||||
|
||||
Fix range calculator on the MonthPicker component
|
||||
6
upcoming-release-notes/5624.md
Normal file
6
upcoming-release-notes/5624.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [jfdoming]
|
||||
---
|
||||
|
||||
Add tools to migrate/un-migrate to/from UI automations
|
||||
7
upcoming-release-notes/5648.md
Normal file
7
upcoming-release-notes/5648.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add issue types for bug reports and feature requests in GitHub issue templates.
|
||||
|
||||
6
upcoming-release-notes/5649.md
Normal file
6
upcoming-release-notes/5649.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add error boundary for modal windows
|
||||
6
upcoming-release-notes/5653.md
Normal file
6
upcoming-release-notes/5653.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [misu-dev]
|
||||
---
|
||||
|
||||
Add BRL, JMD, RSD, RUB, THB and UAH currencies
|
||||
6
upcoming-release-notes/5662.md
Normal file
6
upcoming-release-notes/5662.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [jfdoming]
|
||||
---
|
||||
|
||||
Fix version bump logic to work if the month has rolled over
|
||||
6
upcoming-release-notes/5676.md
Normal file
6
upcoming-release-notes/5676.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [michaelsanford]
|
||||
---
|
||||
|
||||
Add NO_COLOR standard environment flag to sync-server logging (https://no-color.org/).
|
||||
6
upcoming-release-notes/5684.md
Normal file
6
upcoming-release-notes/5684.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Remove usage of a raw variable in Account component
|
||||
6
upcoming-release-notes/5685.md
Normal file
6
upcoming-release-notes/5685.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Remove usage of a raw variable in AccountAutocomplete component
|
||||
6
upcoming-release-notes/5686.md
Normal file
6
upcoming-release-notes/5686.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Remove usage of a raw variable in CategoryAutocomplete component
|
||||
6
upcoming-release-notes/5687.md
Normal file
6
upcoming-release-notes/5687.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Remove usage of a raw variable in PayeeAutocomplete component
|
||||
6
upcoming-release-notes/5688.md
Normal file
6
upcoming-release-notes/5688.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [biolan]
|
||||
---
|
||||
|
||||
Add Romanian and Moldovan Leu currencies
|
||||
6
upcoming-release-notes/5690.md
Normal file
6
upcoming-release-notes/5690.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Optimize usage of useScrollListener and useTransactionsSearch
|
||||
6
upcoming-release-notes/5695.md
Normal file
6
upcoming-release-notes/5695.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Fix local dockerfile build memory allocation
|
||||
6
upcoming-release-notes/5696.md
Normal file
6
upcoming-release-notes/5696.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [Nalin-Gupta]
|
||||
---
|
||||
|
||||
Fix issue where marking existing transactions as transfer switches the date and notes of the two transactions
|
||||
6
upcoming-release-notes/5698.md
Normal file
6
upcoming-release-notes/5698.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [xshalan]
|
||||
---
|
||||
|
||||
Added support for Egyptian Pound (EGP) and Saudi Riyal (SAR) currencies.
|
||||
6
upcoming-release-notes/5706.md
Normal file
6
upcoming-release-notes/5706.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [mauroartizzu]
|
||||
---
|
||||
|
||||
Fixes detailedAccounts that might me null or undefined with some Italian Banks (Widiba, Poste, Intesa)
|
||||
6
upcoming-release-notes/5707.md
Normal file
6
upcoming-release-notes/5707.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Bump vite from 6.3.5 to 6.3.6
|
||||
6
upcoming-release-notes/5711.md
Normal file
6
upcoming-release-notes/5711.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Add bank sync option to only import account balance
|
||||
6
upcoming-release-notes/5713.md
Normal file
6
upcoming-release-notes/5713.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Update Electron to the latest stable version
|
||||
6
upcoming-release-notes/5714.md
Normal file
6
upcoming-release-notes/5714.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Remove `BANKS_WITH_LIMITED_HISTORY` override array for GoCardless
|
||||
6
upcoming-release-notes/5718.md
Normal file
6
upcoming-release-notes/5718.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Remove usage of raw variables in renders
|
||||
6
upcoming-release-notes/5725.md
Normal file
6
upcoming-release-notes/5725.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Add a setting to disable update notifications
|
||||
6
upcoming-release-notes/5733.md
Normal file
6
upcoming-release-notes/5733.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Mobile: open recurring schedule picker in modal
|
||||
6
upcoming-release-notes/5734.md
Normal file
6
upcoming-release-notes/5734.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [jgeneaguilar]
|
||||
---
|
||||
|
||||
Change account menu popover width to minWidth to accommodate different text lengths when switching languages
|
||||
6
upcoming-release-notes/5735.md
Normal file
6
upcoming-release-notes/5735.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [mgibson-scottlogic]
|
||||
---
|
||||
|
||||
Exclude hidden categories from "Copy last month's budget" when copying whole month
|
||||
6
upcoming-release-notes/5736.md
Normal file
6
upcoming-release-notes/5736.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [milanalexandre]
|
||||
---
|
||||
|
||||
Force the display of the 'Schedules transaction' badge on a single line
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user