Compare commits

...

9 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
a1ea12c3dd [Address suppressed ESLint errors] Fix exhaustive deps errors in FinancesApp.tsx 2025-01-10 09:53:20 -08:00
Joel Jeremy Marquez
f522d18636 Release notes 2025-01-10 09:42:52 -08:00
Joel Jeremy Marquez
900b294975 Fix exhaustive deps errors in App.tsx 2025-01-10 09:37:34 -08:00
Julian Dominguez-Schatz
5a34c06859 Include translations in builds (#4089)
* Pull/push strings via Git instead of via API

This is necessary because the Weblate API doesn't handle stale strings
well. In particular, it won't remove them automatically.

* Schedule workflow instead of running on every commit

This is so we can minimize downtime for Weblate translations.

* Prevent pull requests modifying translations

* Don't commit translations during the merge freeze

* Add release notes

* Undo rename

* Don't commit translations nightly, per feedback

* Include translations just-in-time in builds

* Revert "Prevent pull requests modifying translations"

This reverts commit 8c19a0ce13.

* Re-ignore translations

* Update release notes to be accurate

* Create missing directory

* Fix conditional logic
2025-01-09 20:13:25 -05:00
Joel Jeremy Marquez
92c93b3f6e [Typescript] Server event types (#4110)
* [Typescript] Server event types

* Release notes
2025-01-09 15:09:52 -08:00
Matiss Janis Aboltins
34ffc5c4b2 ♻️ refactor theme variable to be statically defined (#4086) 2025-01-09 18:12:16 +00:00
Koen van Staveren
14b0cd7b1d fix: notes is (nothing) not working (#3998) 2025-01-09 09:21:38 +01:00
Koen van Staveren
daca767808 enhance: allow prefix for budget templates (#4032)
* enhance: allow prefix for budget templates

for example:
Prime video: #template $10

* chore: note

* chore: fix test

* chore: move prefix to template-notes.ts
2025-01-09 09:20:43 +01:00
Matt Fiddaman
6111f94b51 Sort barchart data (#4072) 2025-01-08 17:52:51 +00:00
43 changed files with 604 additions and 188 deletions

View File

@@ -1,5 +1,15 @@
name: Setup
inputs:
working-directory:
description: 'Working directory to run in, default .'
required: false
default: '.'
download-translations:
description: 'Whether to download translations as part of setup, default true'
required: false
default: 'true'
runs:
using: composite
steps:
@@ -15,9 +25,16 @@ runs:
uses: actions/cache@v4
id: cache
with:
path: '**/node_modules'
key: yarn-v1-${{ runner.os }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('**/yarn.lock') }}
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
key: yarn-v1-${{ runner.os }}-${{ hashFiles(format('{0}/.nvmrc', inputs.working-directory)) }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
- name: Install
working-directory: ${{ inputs.working-directory }}
run: yarn --immutable
shell: bash
if: steps.cache.outputs.cache-hit != 'true'
- name: Download translations
uses: actions/checkout@v4
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
if: ${{ inputs.download-translations == 'true' }}

View File

@@ -1,36 +1,80 @@
name: Extract and upload i18n strings
on:
push:
branches:
- master
schedule:
# 4am UTC
- cron: "0 4 * * *"
workflow_dispatch:
jobs:
extract-and-upload-i18n-strings:
runs-on: ubuntu-latest
if: github.repository == 'actualbudget/actual'
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Configure i18n client
run: |
pip install wlc
- name: Generate i18n strings
run: yarn generate:i18n
- name: Upload i18n strings
run: |
if [[ ! -f packages/desktop-client/locale/en.json ]]; then
echo "File packages/desktop-client/locale/en.json not found. Ensure the file was generated correctly."
exit 1
fi
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
upload \
--author-name "Actual Budget" \
--author-email "dev@actualbudget.org" \
--method add \
--input packages/desktop-client/locale/en.json \
actualbudget/actual/en
echo "Translations uploaded"
- name: Check out main repository
uses: actions/checkout@v4
with:
path: actual
- name: Set up environment
uses: ./actual/.github/actions/setup
with:
working-directory: actual
download-translations: false # As we'll manually clone instead
- name: Configure Git config
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Configure i18n client
run: |
pip install wlc
- name: Lock translations
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
lock \
actualbudget/actual
- name: Update VCS with latest translations
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
pull \
actualbudget/actual
- name: Check out updated translations
uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations
path: translations
- name: Generate i18n strings
working-directory: actual
run: |
mkdir -p packages/desktop-client/locale/
cp ../translations/en.json packages/desktop-client/locale/
yarn generate:i18n
if [[ ! -f packages/desktop-client/locale/en.json ]]; then
echo "File packages/desktop-client/locale/en.json not found. Ensure the file was generated correctly."
exit 1
fi
- name: Check in new i18n strings
working-directory: translations
run: |
cp ../actual/packages/desktop-client/locale/en.json .
git add .
if git commit -m "Update source strings"; then
git push
else
echo "No changes to commit"
fi
- name: Unlock translations
if: always() # Clean up even on failure
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
unlock \
actualbudget/actual

View File

@@ -715,7 +715,6 @@ export default [
'packages/desktop-client/src/components/accounts/Account.jsx',
'packages/desktop-client/src/components/accounts/MobileAccount.jsx',
'packages/desktop-client/src/components/accounts/MobileAccounts.jsx',
'packages/desktop-client/src/components/App.tsx',
'packages/desktop-client/src/components/budget/BudgetCategories.jsx',
'packages/desktop-client/src/components/budget/BudgetSummaries.tsx',
'packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx',
@@ -724,7 +723,6 @@ export default [
'packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx',
'packages/desktop-client/src/components/budget/envelope/TransferMenu.tsx',
'packages/desktop-client/src/components/common/Menu.tsx',
'packages/desktop-client/src/components/FinancesApp.tsx',
'packages/desktop-client/src/components/GlobalKeys.ts',
'packages/desktop-client/src/components/LoggedInUser.tsx',
'packages/desktop-client/src/components/manager/ManagementApp.jsx',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -53,74 +53,78 @@ function AppInner() {
const userData = useSelector(state => state.user.data);
const { signOut, addNotification } = useActions();
const maybeUpdate = async <T,>(cb?: () => T): Promise<T> => {
if (global.Actual.isUpdateReadyForDownload()) {
dispatch(
setAppState({
loadingText: t('Downloading and applying update...'),
}),
);
await global.Actual.applyAppUpdate();
}
return cb?.();
};
async function init() {
const socketName = await maybeUpdate(() => global.Actual.getServerSocket());
dispatch(
setAppState({
loadingText: t('Initializing the connection to the local database...'),
}),
);
await initConnection(socketName);
// Load any global prefs
dispatch(
setAppState({
loadingText: t('Loading global preferences...'),
}),
);
await dispatch(loadGlobalPrefs());
// Open the last opened budget, if any
dispatch(
setAppState({
loadingText: t('Opening last budget...'),
}),
);
const budgetId = await send('get-last-opened-backup');
if (budgetId) {
await dispatch(loadBudget(budgetId));
// Check to see if this file has been remotely deleted (but
// don't block on this in case they are offline or something)
dispatch(
setAppState({
loadingText: t('Retrieving remote files...'),
}),
);
const files = await send('get-remote-files');
if (files) {
const remoteFile = files.find(f => f.fileId === cloudFileId);
if (remoteFile && remoteFile.deleted) {
dispatch(closeBudget());
}
}
await maybeUpdate();
}
}
useEffect(() => {
const maybeUpdate = async <T,>(cb?: () => T): Promise<T> => {
if (global.Actual.isUpdateReadyForDownload()) {
dispatch(
setAppState({
loadingText: t('Downloading and applying update...'),
}),
);
await global.Actual.applyAppUpdate();
}
return cb?.();
};
async function init() {
const socketName = await maybeUpdate(() =>
global.Actual.getServerSocket(),
);
dispatch(
setAppState({
loadingText: t(
'Initializing the connection to the local database...',
),
}),
);
await initConnection(socketName);
// Load any global prefs
dispatch(
setAppState({
loadingText: t('Loading global preferences...'),
}),
);
await dispatch(loadGlobalPrefs());
// Open the last opened budget, if any
dispatch(
setAppState({
loadingText: t('Opening last budget...'),
}),
);
const budgetId = await send('get-last-opened-backup');
if (budgetId) {
await dispatch(loadBudget(budgetId));
// Check to see if this file has been remotely deleted (but
// don't block on this in case they are offline or something)
dispatch(
setAppState({
loadingText: t('Retrieving remote files...'),
}),
);
const files = await send('get-remote-files');
if (files) {
const remoteFile = files.find(f => f.fileId === cloudFileId);
if (remoteFile && remoteFile.deleted) {
dispatch(closeBudget());
}
}
await maybeUpdate();
}
}
async function initAll() {
await Promise.all([installPolyfills(), init()]);
dispatch(setAppState({ loadingText: null }));
}
initAll().catch(showErrorBoundary);
}, []);
}, [cloudFileId, dispatch, showErrorBoundary, t]);
useEffect(() => {
global.Actual.updateAppMenu(budgetId);
@@ -140,7 +144,7 @@ function AppInner() {
},
});
}
}, [userData, userData?.tokenExpired]);
}, [addNotification, signOut, t, userData?.tokenExpired]);
return budgetId ? <FinancesApp /> : <ManagementApp />;
}
@@ -185,7 +189,7 @@ export function App() {
window.removeEventListener('focus', checkScrollbars);
window.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [dispatch]);
}, [dispatch, hiddenScrollbars]);
const [theme] = useTheme();

View File

@@ -103,7 +103,7 @@ export function FinancesApp() {
setTimeout(async () => {
await dispatch(sync());
}, 100);
}, []);
}, [dispatch]);
useEffect(() => {
async function run() {
@@ -126,7 +126,7 @@ export function FinancesApp() {
}
run();
}, []);
}, [dispatch, t]);
useEffect(() => {
async function run() {
@@ -159,7 +159,7 @@ export function FinancesApp() {
}
run();
}, [lastUsedVersion, setLastUsedVersion]);
}, [dispatch, lastUsedVersion, setLastUsedVersion, t]);
const scrollableRef = useRef<HTMLDivElement>(null);

View File

@@ -117,8 +117,8 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
>(null);
useEffect(() => {
const unlisten = listen('sync-event', ({ type, subtype, syncDisabled }) => {
if (type === 'start') {
const unlisten = listen('sync-event', event => {
if (event.type === 'start') {
setSyncing(true);
setSyncState(null);
} else {
@@ -130,19 +130,19 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
}, 200);
}
if (type === 'error') {
if (event.type === 'error') {
// Use the offline state if either there is a network error or
// if this file isn't a "cloud file". You can't sync a local
// file.
if (subtype === 'network') {
if (event.subtype === 'network') {
setSyncState('offline');
} else if (!cloudFileId) {
setSyncState('local');
} else {
setSyncState('error');
}
} else if (type === 'success') {
setSyncState(syncDisabled ? 'disabled' : null);
} else if (event.type === 'success') {
setSyncState(event.syncDisabled ? 'disabled' : null);
}
});

View File

@@ -108,14 +108,16 @@ function BudgetInner(props: BudgetInnerProps) {
run();
const unlistens = [
listen('sync-event', ({ type, tables }) => {
if (
type === 'success' &&
(tables.includes('categories') ||
listen('sync-event', event => {
if (event.type === 'success') {
const tables = event.tables;
if (
tables.includes('categories') ||
tables.includes('category_mapping') ||
tables.includes('category_groups'))
) {
loadCategories();
tables.includes('category_groups')
) {
loadCategories();
}
}
}),

View File

@@ -267,8 +267,9 @@ function TransactionListWithPreviews({
}, [accountId, dispatch]);
useEffect(() => {
return listen('sync-event', ({ type, tables }) => {
if (type === 'applied') {
return listen('sync-event', event => {
if (event.type === 'applied') {
const tables = event.tables;
if (
tables.includes('transactions') ||
tables.includes('category_mapping') ||

View File

@@ -62,8 +62,9 @@ export function CategoryTransactions({
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
useEffect(() => {
return listen('sync-event', ({ type, tables }) => {
if (type === 'applied') {
return listen('sync-event', event => {
if (event.type === 'applied') {
const tables = event.tables;
if (
tables.includes('transactions') ||
tables.includes('category_mapping') ||

View File

@@ -69,15 +69,17 @@ export function Budget() {
init();
const unlisten = listen('sync-event', ({ type, tables }) => {
if (
type === 'success' &&
(tables.includes('categories') ||
const unlisten = listen('sync-event', event => {
if (event.type === 'success') {
const tables = event.tables;
if (
tables.includes('categories') ||
tables.includes('category_mapping') ||
tables.includes('category_groups'))
) {
// TODO: is this loading every time?
dispatch(getCategories());
tables.includes('category_groups')
) {
// TODO: is this loading every time?
dispatch(getCategories());
}
}
});

View File

@@ -48,9 +48,9 @@ export function ManagePayeesWithData({
}
loadData();
const unlisten = listen('sync-event', async ({ type, tables }) => {
if (type === 'applied') {
if (tables.includes('rules')) {
const unlisten = listen('sync-event', async event => {
if (event.type === 'applied') {
if (event.tables.includes('rules')) {
await refetchRuleCounts();
}
}

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useState, type CSSProperties } from 'react';
import React, { useMemo, useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from '@emotion/css';
@@ -203,6 +203,12 @@ export function BarGraph({
const leftMargin = Math.abs(largestValue) > 1000000 ? 20 : 0;
// Sort the data in the bar chart
const unsortedData = data[splitData];
const sortedData = useMemo(() => {
return unsortedData.sort((a, b) => a[balanceTypeOp] - b[balanceTypeOp]);
}, [unsortedData, balanceTypeOp]);
return (
<Container
style={{
@@ -219,7 +225,7 @@ export function BarGraph({
width={width}
height={height}
stackOffset="sign"
data={data[splitData]}
data={sortedData}
style={{ cursor: pointer }}
margin={{
top: labelsMargin,

View File

@@ -21,19 +21,20 @@ export function handleGlobalEvents(actions: BoundActions, store: Store<State>) {
});
});
listen('schedules-offline', ({ payees }) => {
actions.pushModal('schedule-posts-offline-notification', { payees });
listen('schedules-offline', () => {
actions.pushModal('schedule-posts-offline-notification');
});
// This is experimental: we sync data locally automatically when
// data changes from the backend
listen('sync-event', async ({ type, tables }) => {
listen('sync-event', async event => {
// We don't need to query anything until the file is loaded, and
// sync events might come in if the file is being synced before
// being loaded (happens when downloading)
const prefs = store.getState().prefs.local;
if (prefs && prefs.id) {
if (type === 'applied') {
if (event.type === 'applied') {
const tables = event.tables;
if (tables.includes('payees') || tables.includes('payee_mapping')) {
actions.getPayees();
}

View File

@@ -1,4 +1,3 @@
// @ts-strict-ignore
import { useEffect, useState } from 'react';
import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
@@ -42,7 +41,7 @@ export function usePreferredDarkTheme() {
}
export function ThemeStyle() {
const [theme] = useTheme();
const [activeTheme] = useTheme();
const [darkThemePreference] = usePreferredDarkTheme();
const [themeColors, setThemeColors] = useState<
| typeof lightTheme
@@ -53,7 +52,7 @@ export function ThemeStyle() {
>(undefined);
useEffect(() => {
if (theme === 'auto') {
if (activeTheme === 'auto') {
const darkTheme = themes[darkThemePreference];
function darkThemeMediaQueryListener(event: MediaQueryListEvent) {
@@ -85,18 +84,218 @@ export function ThemeStyle() {
);
};
} else {
setThemeColors(themes[theme].colors);
setThemeColors(themes[activeTheme]?.colors);
}
}, [theme, darkThemePreference]);
}, [activeTheme, darkThemePreference]);
if (!themeColors) return null;
const css = Object.keys(themeColors)
.map(key => ` --color-${key}: ${themeColors[key]};`)
const css = Object.entries(themeColors)
.map(([key, value]) => ` --color-${key}: ${value};`)
.join('\n');
return <style>{`:root {\n${css}}`}</style>;
}
export const theme = Object.fromEntries(
Object.keys(lightTheme).map(key => [key, `var(--color-${key})`]),
) as Record<keyof typeof lightTheme, string>;
export const theme = {
pageBackground: 'var(--color-pageBackground)',
pageBackgroundModalActive: 'var(--color-pageBackgroundModalActive)',
pageBackgroundTopLeft: 'var(--color-pageBackgroundTopLeft)',
pageBackgroundBottomRight: 'var(--color-pageBackgroundBottomRight)',
pageBackgroundLineTop: 'var(--color-pageBackgroundLineTop)',
pageBackgroundLineMid: 'var(--color-pageBackgroundLineMid)',
pageBackgroundLineBottom: 'var(--color-pageBackgroundLineBottom)',
pageText: 'var(--color-pageText)',
pageTextLight: 'var(--color-pageTextLight)',
pageTextSubdued: 'var(--color-pageTextSubdued)',
pageTextDark: 'var(--color-pageTextDark)',
pageTextPositive: 'var(--color-pageTextPositive)',
pageTextLink: 'var(--color-pageTextLink)',
pageTextLinkLight: 'var(--color-pageTextLinkLight)',
cardBackground: 'var(--color-cardBackground)',
cardBorder: 'var(--color-cardBorder)',
cardShadow: 'var(--color-cardShadow)',
tableBackground: 'var(--color-tableBackground)',
tableRowBackgroundHover: 'var(--color-tableRowBackgroundHover)',
tableText: 'var(--color-tableText)',
tableTextLight: 'var(--color-tableTextLight)',
tableTextSubdued: 'var(--color-tableTextSubdued)',
tableTextSelected: 'var(--color-tableTextSelected)',
tableTextHover: 'var(--color-tableTextHover)',
tableTextInactive: 'var(--color-tableTextInactive)',
tableHeaderText: 'var(--color-tableHeaderText)',
tableHeaderBackground: 'var(--color-tableHeaderBackground)',
tableBorder: 'var(--color-tableBorder)',
tableBorderSelected: 'var(--color-tableBorderSelected)',
tableBorderHover: 'var(--color-tableBorderHover)',
tableBorderSeparator: 'var(--color-tableBorderSeparator)',
tableRowBackgroundHighlight: 'var(--color-tableRowBackgroundHighlight)',
tableRowBackgroundHighlightText:
'var(--color-tableRowBackgroundHighlightText)',
tableRowHeaderBackground: 'var(--color-tableRowHeaderBackground)',
tableRowHeaderText: 'var(--color-tableRowHeaderText)',
sidebarBackground: 'var(--color-sidebarBackground)',
sidebarItemBackgroundPending: 'var(--color-sidebarItemBackgroundPending)',
sidebarItemBackgroundPositive: 'var(--color-sidebarItemBackgroundPositive)',
sidebarItemBackgroundFailed: 'var(--color-sidebarItemBackgroundFailed)',
sidebarItemAccentSelected: 'var(--color-sidebarItemAccentSelected)',
sidebarItemBackgroundHover: 'var(--color-sidebarItemBackgroundHover)',
sidebarItemText: 'var(--color-sidebarItemText)',
sidebarItemTextSelected: 'var(--color-sidebarItemTextSelected)',
menuBackground: 'var(--color-menuBackground)',
menuItemBackground: 'var(--color-menuItemBackground)',
menuItemBackgroundHover: 'var(--color-menuItemBackgroundHover)',
menuItemText: 'var(--color-menuItemText)',
menuItemTextHover: 'var(--color-menuItemTextHover)',
menuItemTextSelected: 'var(--color-menuItemTextSelected)',
menuItemTextHeader: 'var(--color-menuItemTextHeader)',
menuBorder: 'var(--color-menuBorder)',
menuBorderHover: 'var(--color-menuBorderHover)',
menuKeybindingText: 'var(--color-menuKeybindingText)',
menuAutoCompleteBackground: 'var(--color-menuAutoCompleteBackground)',
menuAutoCompleteBackgroundHover:
'var(--color-menuAutoCompleteBackgroundHover)',
menuAutoCompleteText: 'var(--color-menuAutoCompleteText)',
menuAutoCompleteTextHover: 'var(--color-menuAutoCompleteTextHover)',
menuAutoCompleteTextHeader: 'var(--color-menuAutoCompleteTextHeader)',
menuAutoCompleteItemTextHover: 'var(--color-menuAutoCompleteItemTextHover)',
menuAutoCompleteItemText: 'var(--color-menuAutoCompleteItemText)',
modalBackground: 'var(--color-modalBackground)',
modalBorder: 'var(--color-modalBorder)',
mobileHeaderBackground: 'var(--color-mobileHeaderBackground)',
mobileHeaderText: 'var(--color-mobileHeaderText)',
mobileHeaderTextSubdued: 'var(--color-mobileHeaderTextSubdued)',
mobileHeaderTextHover: 'var(--color-mobileHeaderTextHover)',
mobilePageBackground: 'var(--color-mobilePageBackground)',
mobileNavBackground: 'var(--color-mobileNavBackground)',
mobileNavItem: 'var(--color-mobileNavItem)',
mobileNavItemSelected: 'var(--color-mobileNavItemSelected)',
mobileAccountShadow: 'var(--color-mobileAccountShadow)',
mobileAccountText: 'var(--color-mobileAccountText)',
mobileTransactionSelected: 'var(--color-mobileTransactionSelected)',
mobileViewTheme: 'var(--color-mobileViewTheme)',
mobileConfigServerViewTheme: 'var(--color-mobileConfigServerViewTheme)',
markdownNormal: 'var(--color-markdownNormal)',
markdownDark: 'var(--color-markdownDark)',
markdownLight: 'var(--color-markdownLight)',
buttonMenuText: 'var(--color-buttonMenuText)',
buttonMenuTextHover: 'var(--color-buttonMenuTextHover)',
buttonMenuBackground: 'var(--color-buttonMenuBackground)',
buttonMenuBackgroundHover: 'var(--color-buttonMenuBackgroundHover)',
buttonMenuBorder: 'var(--color-buttonMenuBorder)',
buttonMenuSelectedText: 'var(--color-buttonMenuSelectedText)',
buttonMenuSelectedTextHover: 'var(--color-buttonMenuSelectedTextHover)',
buttonMenuSelectedBackground: 'var(--color-buttonMenuSelectedBackground)',
buttonMenuSelectedBackgroundHover:
'var(--color-buttonMenuSelectedBackgroundHover)',
buttonMenuSelectedBorder: 'var(--color-buttonMenuSelectedBorder)',
buttonPrimaryText: 'var(--color-buttonPrimaryText)',
buttonPrimaryTextHover: 'var(--color-buttonPrimaryTextHover)',
buttonPrimaryBackground: 'var(--color-buttonPrimaryBackground)',
buttonPrimaryBackgroundHover: 'var(--color-buttonPrimaryBackgroundHover)',
buttonPrimaryBorder: 'var(--color-buttonPrimaryBorder)',
buttonPrimaryShadow: 'var(--color-buttonPrimaryShadow)',
buttonPrimaryDisabledText: 'var(--color-buttonPrimaryDisabledText)',
buttonPrimaryDisabledBackground:
'var(--color-buttonPrimaryDisabledBackground)',
buttonPrimaryDisabledBorder: 'var(--color-buttonPrimaryDisabledBorder)',
buttonNormalText: 'var(--color-buttonNormalText)',
buttonNormalTextHover: 'var(--color-buttonNormalTextHover)',
buttonNormalBackground: 'var(--color-buttonNormalBackground)',
buttonNormalBackgroundHover: 'var(--color-buttonNormalBackgroundHover)',
buttonNormalBorder: 'var(--color-buttonNormalBorder)',
buttonNormalShadow: 'var(--color-buttonNormalShadow)',
buttonNormalSelectedText: 'var(--color-buttonNormalSelectedText)',
buttonNormalSelectedBackground: 'var(--color-buttonNormalSelectedBackground)',
buttonNormalDisabledText: 'var(--color-buttonNormalDisabledText)',
buttonNormalDisabledBackground: 'var(--color-buttonNormalDisabledBackground)',
buttonNormalDisabledBorder: 'var(--color-buttonNormalDisabledBorder)',
buttonBareText: 'var(--color-buttonBareText)',
buttonBareTextHover: 'var(--color-buttonBareTextHover)',
buttonBareBackground: 'var(--color-buttonBareBackground)',
buttonBareBackgroundHover: 'var(--color-buttonBareBackgroundHover)',
buttonBareBackgroundActive: 'var(--color-buttonBareBackgroundActive)',
buttonBareDisabledText: 'var(--color-buttonBareDisabledText)',
buttonBareDisabledBackground: 'var(--color-buttonBareDisabledBackground)',
calendarText: 'var(--color-calendarText)',
calendarBackground: 'var(--color-calendarBackground)',
calendarItemText: 'var(--color-calendarItemText)',
calendarItemBackground: 'var(--color-calendarItemBackground)',
calendarSelectedBackground: 'var(--color-calendarSelectedBackground)',
noticeBackground: 'var(--color-noticeBackground)',
noticeBackgroundLight: 'var(--color-noticeBackgroundLight)',
noticeBackgroundDark: 'var(--color-noticeBackgroundDark)',
noticeText: 'var(--color-noticeText)',
noticeTextLight: 'var(--color-noticeTextLight)',
noticeTextDark: 'var(--color-noticeTextDark)',
noticeTextMenu: 'var(--color-noticeTextMenu)',
noticeTextMenuHover: 'var(--color-noticeTextMenuHover)',
noticeBorder: 'var(--color-noticeBorder)',
warningBackground: 'var(--color-warningBackground)',
warningText: 'var(--color-warningText)',
warningTextLight: 'var(--color-warningTextLight)',
warningTextDark: 'var(--color-warningTextDark)',
warningBorder: 'var(--color-warningBorder)',
errorBackground: 'var(--color-errorBackground)',
errorText: 'var(--color-errorText)',
errorTextDark: 'var(--color-errorTextDark)',
errorTextDarker: 'var(--color-errorTextDarker)',
errorTextMenu: 'var(--color-errorTextMenu)',
errorBorder: 'var(--color-errorBorder)',
upcomingBackground: 'var(--color-upcomingBackground)',
upcomingText: 'var(--color-upcomingText)',
upcomingBorder: 'var(--color-upcomingBorder)',
formLabelText: 'var(--color-formLabelText)',
formLabelBackground: 'var(--color-formLabelBackground)',
formInputBackground: 'var(--color-formInputBackground)',
formInputBackgroundSelected: 'var(--color-formInputBackgroundSelected)',
formInputBackgroundSelection: 'var(--color-formInputBackgroundSelection)',
formInputBorder: 'var(--color-formInputBorder)',
formInputTextReadOnlySelection: 'var(--color-formInputTextReadOnlySelection)',
formInputBorderSelected: 'var(--color-formInputBorderSelected)',
formInputText: 'var(--color-formInputText)',
formInputTextSelected: 'var(--color-formInputTextSelected)',
formInputTextPlaceholder: 'var(--color-formInputTextPlaceholder)',
formInputTextPlaceholderSelected:
'var(--color-formInputTextPlaceholderSelected)',
formInputTextSelection: 'var(--color-formInputTextSelection)',
formInputShadowSelected: 'var(--color-formInputShadowSelected)',
formInputTextHighlight: 'var(--color-formInputTextHighlight)',
checkboxText: 'var(--color-checkboxText)',
checkboxBackgroundSelected: 'var(--color-checkboxBackgroundSelected)',
checkboxBorderSelected: 'var(--color-checkboxBorderSelected)',
checkboxShadowSelected: 'var(--color-checkboxShadowSelected)',
checkboxToggleBackground: 'var(--color-checkboxToggleBackground)',
checkboxToggleBackgroundSelected:
'var(--color-checkboxToggleBackgroundSelected)',
checkboxToggleDisabled: 'var(--color-checkboxToggleDisabled)',
pillBackground: 'var(--color-pillBackground)',
pillBackgroundLight: 'var(--color-pillBackgroundLight)',
pillText: 'var(--color-pillText)',
pillTextHighlighted: 'var(--color-pillTextHighlighted)',
pillBorder: 'var(--color-pillBorder)',
pillBorderDark: 'var(--color-pillBorderDark)',
pillBackgroundSelected: 'var(--color-pillBackgroundSelected)',
pillTextSelected: 'var(--color-pillTextSelected)',
pillBorderSelected: 'var(--color-pillBorderSelected)',
pillTextSubdued: 'var(--color-pillTextSubdued)',
reportsRed: 'var(--color-reportsRed)',
reportsBlue: 'var(--color-reportsBlue)',
reportsGreen: 'var(--color-reportsGreen)',
reportsGray: 'var(--color-reportsGray)',
reportsLabel: 'var(--color-reportsLabel)',
reportsInnerLabel: 'var(--color-reportsInnerLabel)',
noteTagBackground: 'var(--color-noteTagBackground)',
noteTagBackgroundHover: 'var(--color-noteTagBackgroundHover)',
noteTagText: 'var(--color-noteTagText)',
budgetOtherMonth: 'var(--color-budgetOtherMonth)',
budgetCurrentMonth: 'var(--color-budgetCurrentMonth)',
budgetHeaderOtherMonth: 'var(--color-budgetHeaderOtherMonth)',
budgetHeaderCurrentMonth: 'var(--color-budgetHeaderCurrentMonth)',
floatingActionBarBackground: 'var(--color-floatingActionBarBackground)',
floatingActionBarBorder: 'var(--color-floatingActionBarBorder)',
floatingActionBarText: 'var(--color-floatingActionBarText)',
tooltipText: 'var(--color-tooltipText)',
tooltipBackground: 'var(--color-tooltipBackground)',
tooltipBorder: 'var(--color-tooltipBorder)',
calendarCellBackground: 'var(--color-calendarCellBackground)',
};

View File

@@ -62,7 +62,7 @@ export class LiveQuery<TResponse = unknown> {
private _data: Data<TResponse>;
private _dependencies: Set<string>;
private _listeners: Array<Listener<TResponse>>;
private _supportedSyncTypes: Set<string>;
private _supportedSyncTypes: Set<'applied' | 'success'>;
private _query: Query;
private _onError: (error: Error) => void;
@@ -107,8 +107,8 @@ export class LiveQuery<TResponse = unknown> {
// TODO: error types?
this._supportedSyncTypes = options.onlySync
? new Set<string>(['success'])
: new Set<string>(['applied', 'success']);
? new Set(['success'])
: new Set(['applied', 'success']);
if (onData) {
this.addListener(onData);
@@ -162,15 +162,18 @@ export class LiveQuery<TResponse = unknown> {
protected subscribe = () => {
if (this._unsubscribeSyncEvent == null) {
this._unsubscribeSyncEvent = listen('sync-event', ({ type, tables }) => {
this._unsubscribeSyncEvent = listen('sync-event', event => {
// If the user is doing optimistic updates, they don't want to
// always refetch whenever something changes because it would
// refetch all data after they've already updated the UI. This
// voids the perf benefits of optimistic updates. Allow querys
// to only react to remote syncs. By default, queries will
// always update to all changes.
if (this._supportedSyncTypes.has(type)) {
this.onUpdate(tables);
if (
(event.type === 'applied' || event.type === 'success') &&
this._supportedSyncTypes.has(event.type)
) {
this.onUpdate(event.tables);
}
});
}

View File

@@ -8,16 +8,14 @@ import type { Notification } from './state-types/notifications';
export function listenForSyncEvent(actions, store) {
let attemptedSyncRepair = false;
listen('sync-event', info => {
const { type, subtype, meta, tables } = info;
listen('sync-event', event => {
const prefs = store.getState().prefs.local;
if (!prefs || !prefs.id) {
// Do nothing if no budget is loaded
return;
}
if (type === 'success') {
if (event.type === 'success') {
if (attemptedSyncRepair) {
attemptedSyncRepair = false;
@@ -28,6 +26,8 @@ export function listenForSyncEvent(actions, store) {
});
}
const tables = event.tables;
if (tables.includes('prefs')) {
actions.loadPrefs();
}
@@ -47,13 +47,13 @@ export function listenForSyncEvent(actions, store) {
if (tables.includes('accounts')) {
actions.getAccounts();
}
} else if (type === 'error') {
} else if (event.type === 'error') {
let notif: Notification | null = null;
const learnMore = `[${t('Learn more')}](https://actualbudget.org/docs/getting-started/sync/#debugging-sync-issues)`;
const githubIssueLink =
'https://github.com/actualbudget/actual/issues/new?assignees=&labels=bug&template=bug-report.yml&title=%5BBug%5D%3A+';
switch (subtype) {
switch (event.subtype) {
case 'out-of-sync':
if (attemptedSyncRepair) {
notif = {
@@ -215,7 +215,7 @@ export function listenForSyncEvent(actions, store) {
break;
case 'encrypt-failure':
case 'decrypt-failure':
if (meta.isMissingKey) {
if (event.meta.isMissingKey) {
notif = {
title: t('Missing encryption key'),
message: t(
@@ -252,7 +252,7 @@ export function listenForSyncEvent(actions, store) {
}
break;
case 'invalid-schema':
console.trace('invalid-schema', meta);
console.trace('invalid-schema', event.meta);
notif = {
title: t('Update required'),
message: t(
@@ -263,7 +263,7 @@ export function listenForSyncEvent(actions, store) {
};
break;
case 'apply-failure':
console.trace('apply-failure', meta);
console.trace('apply-failure', event.meta);
notif = {
message: t(
'We couldnt apply that change to the database. Please report this as a bug by [opening a Github issue]({{githubIssueLink}}).',
@@ -287,7 +287,7 @@ export function listenForSyncEvent(actions, store) {
};
break;
default:
console.trace('unknown error', info);
console.trace('unknown error', event);
notif = {
message: t(
'We had problems syncing your changes. Please report this as a bug by [opening a Github issue]({{githubIssueLink}}).',

View File

@@ -9,7 +9,7 @@ export type Init = typeof init;
export function send<K extends keyof ServerEvents>(
type: K,
args?: ServerEvents[k],
args?: ServerEvents[K],
): void;
export type Send = typeof send;

View File

@@ -47,6 +47,9 @@ describe('Condition', () => {
cond = new Condition('is', 'payee', null, null);
expect(cond.eval({ payee: null })).toBe(true);
cond = new Condition('is', 'notes', '', null);
expect(cond.eval({ notes: null })).toBe(true);
});
test('ops handles undefined fields', () => {

View File

@@ -360,6 +360,12 @@ export class Condition {
eval(object) {
let fieldValue = object[this.field];
const type = this.type;
if (type === 'string') {
fieldValue ??= '';
}
if (fieldValue === undefined) {
return false;
}
@@ -368,8 +374,6 @@ export class Condition {
fieldValue = fieldValue.toLowerCase();
}
const type = this.type;
if (type === 'number' && this.options) {
if (this.options.outflow) {
if (fieldValue > 0) {

View File

@@ -1,7 +1,7 @@
// @ts-strict-ignore
import * as connection from '../../platform/server/connection';
import { Diff } from '../../shared/util';
import { TransactionEntity } from '../../types/models';
import { PayeeEntity, TransactionEntity } from '../../types/models';
import * as db from '../db';
import { incrFetch, whereIn } from '../db/util';
import { batchMessages } from '../sync';
@@ -55,7 +55,7 @@ export async function batchUpdateTransactions({
? await idsWithChildren(deleted.map(d => d.id))
: [];
const oldPayees = new Set();
const oldPayees = new Set<PayeeEntity['id']>();
const accounts = await db.all('SELECT * FROM accounts WHERE tombstone = 0');
// We need to get all the payees of updated transactions _before_

View File

@@ -1,15 +1,21 @@
// @ts-strict-ignore
import mitt from 'mitt';
import mitt, { type Emitter } from 'mitt';
import { captureException } from '../platform/exceptions';
import { ServerEvents } from '../types/server-events';
// This is a simple helper abstraction for defining methods exposed to
// the client. It doesn't do much, but checks for naming conflicts and
// makes it cleaner to combine methods. We call a group of related
// methods an "app".
type Events = {
sync: ServerEvents['sync-event'];
'load-budget': { id: string };
};
class App<Handlers> {
events;
events: Emitter<Events>;
handlers: Handlers;
services;
unlistenServices;

View File

@@ -35,11 +35,11 @@ repeat 'repeat interval'
/ 'year'i { return { annual: true }}
/ years: positive _ 'years'i { return { annual: true, repeat: +years }}
limit = _? upTo _ amount: amount _ 'per week starting'i _ start:date _? hold:hold?
limit = _? upTo _ amount: amount _ 'per week starting'i _ start:date _? hold:hold?
{ return {amount: amount, hold: hold, period: 'weekly', start: start }}
/ _? upTo _ amount: amount _ 'per day'i _? hold: hold?
{ return {amount: amount, hold: hold, period: 'daily', start:null }}
/ _? upTo _ amount: amount _? hold: hold?
/ _? upTo _ amount: amount _? hold: hold?
{ return {amount: amount, hold: hold, period: 'monthly', start:null }}
percentOf = percent:percent _ of _ 'previous'i _ { return { percent: percent, prev: true}}

View File

@@ -73,6 +73,26 @@ describe('storeTemplates', () => {
},
],
},
{
description:
'Stores template when prefix is used with valid template notes',
mockTemplateNotes: [
{
id: 'cat1',
name: 'Category 1',
note: 'test: #template 12',
},
],
expectedTemplates: [
{
type: 'simple',
monthly: 12,
limit: null,
priority: 0,
directive: 'template',
},
],
},
{
description:
'Stores templates for categories with valid goal directive template notes',

View File

@@ -79,7 +79,7 @@ async function getCategoriesWithTemplates(): Promise<CategoryWithTemplates[]> {
const parsedTemplates: Template[] = [];
note.split('\n').forEach(line => {
const trimmedLine = line.trim();
const trimmedLine = line.substring(line.indexOf('#')).trim();
if (
!trimmedLine.startsWith(TEMPLATE_PREFIX) &&

View File

@@ -6,6 +6,6 @@ import { createApp } from './app';
// Main app
export const app = createApp<Handlers>();
app.events.on('sync', info => {
connection.send('sync-event', info);
app.events.on('sync', event => {
connection.send('sync-event', event);
});

View File

@@ -533,7 +533,7 @@ async function advanceSchedulesService(syncSuccess) {
}
if (failedToPost.length > 0) {
connection.send('schedules-offline', { payees: failedToPost });
connection.send('schedules-offline');
} else if (didPost) {
// This forces a full refresh of transactions because it
// simulates them coming in from a full sync. This not a
@@ -542,7 +542,7 @@ async function advanceSchedulesService(syncSuccess) {
connection.send('sync-event', {
type: 'success',
tables: ['transactions'],
syncDisabled: 'false',
syncDisabled: false,
});
}
}

View File

@@ -1,23 +1,86 @@
import { type Backup } from '../server/backups';
import { type UndoState } from '../server/undo';
type SyncSubtype =
| 'out-of-sync'
| 'apply-failure'
| 'decrypt-failure'
| 'encrypt-failure'
| 'invalid-schema'
| 'network'
| 'file-old-version'
| 'file-key-mismatch'
| 'file-not-found'
| 'file-needs-upload'
| 'file-has-reset'
| 'file-has-new-key'
| 'token-expired'
| string;
type SyncEvent = {
meta?: Record<string, unknown>;
} & (
| {
type: 'applied';
tables: string[];
data?: Map<string, unknown>;
prevData?: Map<string, unknown>;
}
| {
type: 'success';
tables: string[];
syncDisabled?: boolean;
}
| {
type: 'error';
subtype?: SyncSubtype;
}
| {
type: 'start';
}
| {
type: 'unauthorized';
}
);
type BackupUpdatedEvent = Backup[];
type CellsChangedEvent = Array<{
name: string;
value: string | number | boolean;
}>;
type FallbackWriteErrorEvent = undefined;
type FinishImportEvent = undefined;
type FinishLoadEvent = undefined;
type OrphanedPayeesEvent = {
orphanedIds: string[];
updatedPayeeIds: string[];
};
type PrefsUpdatedEvent = undefined;
type SchedulesOfflineEvent = undefined;
type ServerErrorEvent = undefined;
type ShowBudgetsEvent = undefined;
type StartImportEvent = { budgetName: string };
type StartLoadEvent = undefined;
type ApiFetchRedirectedEvent = undefined;
export interface ServerEvents {
'backups-updated': Backup[];
'cells-changed': Array<{ name }>;
'fallback-write-error': unknown;
'finish-import': unknown;
'finish-load': unknown;
'orphaned-payees': {
orphanedIds: string[];
updatedPayeeIds: string[];
};
'prefs-updated': unknown;
'schedules-offline': { payees: unknown[] };
'server-error': unknown;
'show-budgets': unknown;
'start-import': unknown;
'start-load': unknown;
'sync-event': { type; subtype; meta; tables; syncDisabled };
'backups-updated': BackupUpdatedEvent;
'cells-changed': CellsChangedEvent;
'fallback-write-error': FallbackWriteErrorEvent;
'finish-import': FinishImportEvent;
'finish-load': FinishLoadEvent;
'orphaned-payees': OrphanedPayeesEvent;
'prefs-updated': PrefsUpdatedEvent;
'schedules-offline': SchedulesOfflineEvent;
'server-error': ServerErrorEvent;
'show-budgets': ShowBudgetsEvent;
'start-import': StartImportEvent;
'start-load': StartLoadEvent;
'sync-event': SyncEvent;
'undo-event': UndoState;
'api-fetch-redirected': unknown;
'api-fetch-redirected': ApiFetchRedirectedEvent;
}

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [UnderKoen]
---
Usage of notes is (nothing) on new transactions

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [UnderKoen]
---
Allow note prefixes in budget templates.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [matt-fidd]
---
Sort barchart data

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatisJanis]
---
Refactor `theme` variable to be statically defined.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [jfdoming]
---
Include translations in builds

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Add types to loot-core server events.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
[Address suppressed ESLint errors] Fix exhaustive deps errors in App.tsx