Compare commits

..

42 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
59501edb56 Do not pass entire react query result to provider because it is not a stable reference 2026-03-03 17:17:04 +00:00
Joel Jeremy Marquez
d4d4cde3c7 Merge remote-tracking branch 'origin/master' into react-query-useSchedules 2026-03-03 16:09:47 +00:00
github-actions[bot]
acd1309752 Update VRT screenshots
Auto-generated by VRT workflow

PR: #6766
2026-02-23 19:25:21 +00:00
Joel Jeremy Marquez
95fd47d255 Fix tests 2026-02-23 18:46:02 +00:00
github-actions[bot]
24bd6dff45 Update VRT screenshots
Auto-generated by VRT workflow

PR: #6766
2026-02-23 18:39:21 +00:00
autofix-ci[bot]
3f1ba9ce88 [autofix.ci] apply automated fixes 2026-02-23 18:07:35 +00:00
Joel Jeremy Marquez
7ebf0c3693 Remove isLoading 2026-02-23 18:06:26 +00:00
Joel Jeremy Marquez
18f7eee81a Merge remote-tracking branch 'origin/master' into react-query-useSchedules 2026-02-23 17:42:52 +00:00
github-actions[bot]
e980c0cef7 Update VRT screenshots
Auto-generated by VRT workflow

PR: #6766
2026-02-19 19:54:43 +00:00
Joel Jeremy Marquez
b891f203d3 Set minHeight on variable hright rows and add borders 2026-02-19 19:28:34 +00:00
github-actions[bot]
a950ad9086 Update VRT screenshots
Auto-generated by VRT workflow

PR: #6766
2026-02-19 19:04:44 +00:00
autofix-ci[bot]
ac668664b4 [autofix.ci] apply automated fixes 2026-02-19 18:31:31 +00:00
Joel Jeremy Marquez
038b46df9a Merge branch 'master' into react-query-useSchedules 2026-02-19 10:28:36 -08:00
Joel Jeremy Marquez
dba2a2a1a8 Fix virtualized list messing up with the rendering 2026-02-19 18:26:36 +00:00
Joel Jeremy Marquez
ebd68a38ef Update test to wait for schedules list to load 2026-02-18 22:08:01 +00:00
Joel Jeremy Marquez
b126693968 Merge remote-tracking branch 'origin/master' into react-query-useSchedules 2026-02-18 20:08:26 +00:00
Joel Jeremy Marquez
9c4df27a8a Fix test using non-existent testid 2026-02-18 15:48:04 +00:00
Joel Jeremy Marquez
2aff508368 Use renderEmptyState 2026-02-18 00:12:52 +00:00
Joel Jeremy Marquez
5b63efaa10 Fix loading states 2026-02-17 22:59:15 +00:00
Joel Jeremy Marquez
673b5d241f Fix imports 2026-02-17 17:59:34 +00:00
autofix-ci[bot]
2223aa1c96 [autofix.ci] apply automated fixes 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
ecc95084f5 Add useSchedule and Fix loading states 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
089ad26419 Use isFetching instead of isPending 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
0cd55d5f36 Use isFetching instead of isPending 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
dcd0486f9a useSchedules and useScheduleStatus refetch on sync 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
ca7ea8a896 Fix typecheck error 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
a8f2cd6e36 Add sync and undo listeners for schedules to invalidate cache when updates are made 2026-02-17 17:44:56 +00:00
autofix-ci[bot]
8b2e114e99 [autofix.ci] apply automated fixes 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
27030adf40 Use isPending instead of isLoading 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
98a758c1b4 Use isPending instead of isFetching 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
d9a18d03ee Update type of SchedulesProvider 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
705b0f5d28 Fix typecheck error 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
cea282f3bc Coderabbit: Use StatusTypes renamed to ScheduleTransactionStatus) instead of ScheduleStatusLabel 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
c00db8ed95 Fix typecheck errors 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
b02386d654 Move useScheduleStatus to a separate file 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
27a43d6560 Add placeholderData to statuses query 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
9e317b0a71 Separate useSchedules and useSchedulesStatus because not all callers of useSchedules use the status. This saves us some unnecessary queries. 2026-02-17 17:44:56 +00:00
github-actions[bot]
11a4eb65a0 Add release notes for PR #6766 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
e3c178b89a Rename to ScheduleStatusMap and ScheduleStatusLabelMap to be clear that they are Maps 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
478aac731e Update imports 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
953c9fcf09 Retrofit useSchedules to use react-query under the hood 2026-02-17 17:44:56 +00:00
Copilot
fc1811c0db Migrate setupTests.js to TypeScript with proper types (#6871)
* Initial plan

* Rename setupTests.js to setupTests.ts and add proper types

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Extract Size type to avoid duplication

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Add release note for setupTests TypeScript migration

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Rename release note file to match PR number 6871

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>
2026-02-17 17:44:56 +00:00
460 changed files with 3133 additions and 10201 deletions

View File

@@ -1,74 +0,0 @@
---
description: Rules for AI-generated commits and pull requests
globs:
alwaysApply: true
---
# PR and Commit Rules for AI Agents
Canonical source: `.github/agents/pr-and-commit-rules.md`
## Commit Rules
### [AI] Prefix Requirement
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
### Git Safety Rules
- **Never** update git config
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
- **Never** force push to `main`/`master`
- **Never** commit unless explicitly asked by the user
## Pre-Commit Quality Checklist
Before committing, ensure all of the following:
- [ ] Commit message is prefixed with `[AI]`
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] User-facing strings are translated
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
## Pull Request Rules
### [AI] Prefix Requirement
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
### Labels
Add the **"AI generated"** label to all AI-created pull requests.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese** (简体中文).
## Quick-Reference Workflow
1. Make your changes
2. Run `yarn typecheck` — fix any errors
3. Run `yarn lint:fix` — fix any remaining lint errors
4. Run relevant tests (`yarn test` for all, or workspace-specific)
5. Stage files and commit with `[AI]` prefix — do not skip hooks
6. When creating a PR:
- Use `[AI]` prefix in the title
- Add the `"AI generated"` label
- Leave the PR template blank (do not fill it in)

View File

@@ -37,14 +37,12 @@ async function getPRDetails() {
console.log('- PR Author:', pr.user.login);
console.log('- PR Title:', pr.title);
console.log('- Base Branch:', pr.base.ref);
console.log('- Head Branch:', pr.head.ref);
const result = {
number: pr.number,
author: pr.user.login,
title: pr.title,
baseBranch: pr.base.ref,
headBranch: pr.head.ref,
};
setOutput('result', JSON.stringify(result));

View File

@@ -1,14 +1,14 @@
#!/usr/bin/env node
// overview:
// 1. Identify the migrations in packages/loot-core/migrations/* on `master` and HEAD
// 2. Make sure that any new migrations on HEAD are dated after the latest migration on `master`.
import { spawnSync } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const { spawnSync } = require('child_process');
const path = require('path');
const migrationsDir = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
__dirname,
'..',
'..',
'packages',
@@ -16,7 +16,7 @@ const migrationsDir = path.join(
'migrations',
);
function readMigrations(ref: string) {
function readMigrations(ref) {
const { stdout } = spawnSync('git', [
'ls-tree',
'--name-only',

View File

@@ -1,70 +0,0 @@
# PR and Commit Rules for AI Agents
This is the single source of truth for all commit and pull request rules that AI agents must follow when working with Actual Budget.
## Commit Rules
### [AI] Prefix Requirement
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
### Git Safety Rules
- **Never** update git config
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
- **Never** force push to `main`/`master`
- **Never** commit unless explicitly asked by the user
## Pre-Commit Quality Checklist
Before committing, ensure all of the following:
- [ ] Commit message is prefixed with `[AI]`
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
- [ ] User-facing strings are translated
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
## Pull Request Rules
### [AI] Prefix Requirement
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
**Examples:**
- `[AI] Fix type error in account validation`
- `[AI] Add support for new transaction categories`
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
### Labels
Add the **"AI generated"** label to all AI-created pull requests. This helps maintainers understand the nature of the contribution.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. Humans are expected to fill in the Description, Related issue(s), Testing, and Checklist sections.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
## Quick-Reference Workflow
Follow these steps when committing and creating PRs:
1. Make your changes
2. Run `yarn typecheck` — fix any errors
3. Run `yarn lint:fix` — fix any remaining lint errors
4. Run relevant tests (`yarn test` for all, or workspace-specific)
5. Stage files and commit with `[AI]` prefix — do not skip hooks
6. When creating a PR:
- Use `[AI]` prefix in the title
- Add the `"AI generated"` label
- Leave the PR template blank (do not fill it in)

View File

@@ -41,12 +41,21 @@ jobs:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Check if PR targets master branch
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
id: check-base-branch
run: |
BASE_BRANCH=$(echo '${{ steps.pr-details.outputs.result }}' | jq -r '.baseBranch')
echo "Base branch: $BASE_BRANCH"
if [ "$BASE_BRANCH" = "master" ]; then
echo "targets_master=true" >> $GITHUB_OUTPUT
else
echo "targets_master=false" >> $GITHUB_OUTPUT
echo "PR does not target master branch, skipping release notes generation"
fi
- name: Check if release notes file already exists
if: >-
steps.check-first-comment.outputs.result == 'true' &&
steps.pr-details.outputs.result != 'null' &&
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null' && steps.check-base-branch.outputs.targets_master == 'true'
id: check-release-notes-exists
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
env:

View File

@@ -60,9 +60,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
download-translations: 'false'
node-version: 22
- name: Check migrations
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts
run: node ./.github/actions/check-migrations.js

View File

@@ -87,8 +87,8 @@ jobs:
- name: Test that the docker image boots
run: |
docker run --detach --network=host actualbudget/actual-server-testing
sleep 10
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
sleep 5
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/

View File

@@ -156,3 +156,53 @@ jobs:
-NoStatus `
-AutoCommit `
-Force
publish-flathub:
needs: build
runs-on: ubuntu-22.04
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
steps:
- name: Download Linux artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: actual-electron-ubuntu-22.04
- name: Calculate AppImage SHA256
id: appimage_sha256
run: |
APPIMAGE_X64_SHA256=$(sha256sum Actual-linux-x86_64.AppImage | awk '{ print $1 }')
APPIMAGE_ARM64_SHA256=$(sha256sum Actual-linux-arm64.AppImage | awk '{ print $1 }')
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@v6
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
- name: Update manifest with new SHA256
run: |
# Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
branch: 'release/${{ needs.build.outputs.version }}'
draft: true
title: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
body: |
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.
:link: [View release notes](https://actualbudget.org/blog/release-${{ needs.build.outputs.version }})
reviewers: 'jfdoming,MatissJanis,youngcw' # The core team that have accepted the collaborator access to the Flathub repo

View File

@@ -1,126 +0,0 @@
name: Publish Flathub
defaults:
run:
shell: bash
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g. v25.3.0)'
required: true
type: string
concurrency:
group: publish-flathub
cancel-in-progress: false
jobs:
publish-flathub:
runs-on: ubuntu-22.04
steps:
- name: Resolve version
id: resolve_version
env:
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
run: |
if [[ "$EVENT_NAME" == "release" ]]; then
TAG="$RELEASE_TAG"
else
TAG="$INPUT_TAG"
fi
if [[ -z "$TAG" ]]; then
echo "::error::No tag provided"
exit 1
fi
# Validate tag format (v-prefixed semver, e.g. v25.3.0 or v1.2.3-beta.1)
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Invalid tag format: $TAG (expected v-prefixed semver, e.g. v25.3.0)"
exit 1
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved tag=$TAG version=$VERSION"
- name: Verify release assets exist
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.resolve_version.outputs.tag }}"
echo "Checking release assets for tag $TAG..."
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
echo "Found assets:"
echo "$ASSETS"
if ! echo "$ASSETS" | grep -qx "Actual-linux-x86_64.AppImage"; then
echo "::error::Missing asset: Actual-linux-x86_64.AppImage"
exit 1
fi
if ! echo "$ASSETS" | grep -qx "Actual-linux-arm64.AppImage"; then
echo "::error::Missing asset: Actual-linux-arm64.AppImage"
exit 1
fi
echo "All required AppImage assets found."
- name: Calculate AppImage SHA256 (streamed)
run: |
VERSION="${{ steps.resolve_version.outputs.version }}"
BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}"
echo "Streaming x86_64 AppImage to compute SHA256..."
APPIMAGE_X64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-x86_64.AppImage" | sha256sum | awk '{ print $1 }')
echo "x86_64 SHA256: $APPIMAGE_X64_SHA256"
echo "Streaming arm64 AppImage to compute SHA256..."
APPIMAGE_ARM64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-arm64.AppImage" | sha256sum | awk '{ print $1 }')
echo "arm64 SHA256: $APPIMAGE_ARM64_SHA256"
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@v6
with:
repository: flathub/com.actualbudget.actual
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
- name: Update manifest with new version
run: |
VERSION="${{ steps.resolve_version.outputs.version }}"
# Replace x86_64 entry
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
# Replace arm64 entry
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
echo "Updated manifest:"
cat com.actualbudget.actual.yml
- name: Create PR in Flathub repo
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
branch: 'release/${{ steps.resolve_version.outputs.version }}'
title: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
body: |
This PR updates the Actual desktop flatpak to version ${{ steps.resolve_version.outputs.version }}.
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.resolve_version.outputs.version }})
reviewers: 'jfdoming,MatissJanis,youngcw'

View File

@@ -20,13 +20,11 @@ jobs:
- name: Update package versions
run: |
# Get new nightly versions
NEW_CORE_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/loot-core/package.json --type nightly)
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
# Set package versions
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
@@ -35,10 +33,6 @@ jobs:
run: |
yarn install
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Server & Web
run: yarn build:server
@@ -59,7 +53,6 @@ jobs:
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
@@ -83,12 +76,6 @@ jobs:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly

View File

@@ -16,10 +16,6 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
- name: Pack the core package
run: |
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
- name: Build Web
run: yarn build:server
@@ -40,7 +36,6 @@ jobs:
with:
name: npm-packages
path: |
packages/loot-core/@actual-app/core.tgz
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
@@ -64,12 +59,6 @@ jobs:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Publish Core
run: |
npm publish loot-core/@actual-app/core.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public

2
.gitignore vendored
View File

@@ -33,9 +33,7 @@ packages/desktop-electron/dist
packages/desktop-electron/loot-core
packages/desktop-client/service-worker
packages/plugins-service/dist
packages/component-library/dist
packages/loot-core/lib-dist
**/.tsbuildinfo
packages/sync-server/coverage
bundle.desktop.js
bundle.desktop.js.map

View File

@@ -10,7 +10,7 @@
"builtin",
"external",
"loot-core",
["parent", "subpath"],
"parent",
"sibling",
"index",
"desktop-client"
@@ -22,7 +22,7 @@
},
{
"groupName": "loot-core",
"elementNamePattern": ["loot-core/**", "@actual-app/core/**"]
"elementNamePattern": ["loot-core/**"]
},
{
"groupName": "desktop-client",

View File

@@ -101,18 +101,8 @@
"typescript/no-var-requires": "error",
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
"typescript/no-duplicate-type-constituents": "off",
// we want to allow unions such as "string | 'network' | 'file-key-mismatch'"
"typescript/no-redundant-type-constituents": "off",
"typescript/await-thenable": "error",
"typescript/no-floating-promises": "error",
"typescript/require-array-sort-compare": "error",
"typescript/unbound-method": "error",
"typescript/no-for-in-array": "error",
"typescript/restrict-template-expressions": "error",
"typescript/no-misused-spread": "warn", // TODO: enable this
"typescript/no-base-to-string": "warn", // TODO: enable this
"typescript/no-unsafe-unary-minus": "warn", // TODO: enable this
"typescript/no-unsafe-type-assertion": "warn", // TODO: enable this
"typescript/no-floating-promises": "warn", // TODO: covert to error
// Import rules
"import/consistent-type-specifier-style": "error",

View File

@@ -44,9 +44,25 @@ yarn start:desktop
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
**THIS IS A MANDATORY REQUIREMENT THAT MUST BE FOLLOWED WITHOUT EXCEPTION:**
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for the full specification, including git safety rules, pre-commit checklist, and PR workflow.
- **ALL commit messages MUST be prefixed with `[AI]`**
- **ALL pull request titles MUST be prefixed with `[AI]`**
**Examples:**
-`[AI] Fix type error in account validation`
-`[AI] Add support for new transaction categories`
-`Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
-`Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
**This requirement applies to:**
- Every single commit message created by AI agents
- Every single pull request title created by AI agents
- No exceptions are permitted
**This is a hard requirement that agents MUST follow. Failure to include the `[AI]` prefix is a violation of these instructions.**
### Task Orchestration with Lage
@@ -84,7 +100,7 @@ The core application logic that runs on any platform.
```bash
# Run all loot-core tests
yarn workspace @actual-app/core run test
yarn workspace loot-core run test
# Or run tests across all packages using lage
yarn test
@@ -219,7 +235,7 @@ yarn test
yarn test:debug
# Run tests for a specific package
yarn workspace @actual-app/core run test
yarn workspace loot-core run test
```
**E2E Tests (Playwright)**
@@ -298,7 +314,6 @@ Always run `yarn typecheck` before committing.
**React Patterns:**
- The project uses **React Compiler** (`babel-plugin-react-compiler`) in the desktop-client. The compiler auto-memoizes component bodies, so you can omit manual `useCallback`, `useMemo`, and `React.memo` when adding or refactoring code; prefer inline callbacks and values unless a stable identity is required by a non-compiled dependency.
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
- Don't use `React.*` patterns - use named imports instead
- Use `<Link>` instead of `<a>` tags
@@ -345,7 +360,13 @@ Always maintain newlines between import groups.
**Git Commands:**
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
- **MANDATORY: ALL commit messages MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- **MANDATORY: ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- Never update git config
- Never run destructive git operations (force push, hard reset) unless explicitly requested
- Never skip hooks (--no-verify, --no-gpg-sign)
- Never force push to main/master
- Never commit unless explicitly asked
## File Structure Patterns
@@ -544,7 +565,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
Before committing changes, ensure:
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
- [ ] **MANDATORY: Commit message is prefixed with `[AI]`** - This is a hard requirement with no exceptions
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
@@ -557,7 +578,17 @@ Before committing changes, ensure:
## Pull Request Guidelines
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete PR creation rules, including title prefix requirements, labeling, and PR template handling.
When creating pull requests:
- **MANDATORY PREFIX REQUIREMENT**: **ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement that MUST be followed without exception
- ✅ Correct: `[AI] Fix type error in account validation`
- ❌ Incorrect: `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. We expect **humans** to fill in the Description, Related issue(s), Testing, and Checklist sections.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
## Code Review Guidelines
@@ -625,7 +656,7 @@ Standard commands documented in `package.json` scripts and the Quick Start secti
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
- `yarn test` (lage across all workspaces)
- `yarn typecheck` (tsgo + lage typecheck)
- `yarn typecheck` (tsc + lage typecheck)
### Testing and previewing the app

View File

@@ -1,2 +0,0 @@
@AGENTS.md
@.github/agents/pr-and-commit-rules.md

View File

@@ -17,7 +17,7 @@ packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace @actual-app/core build:browser
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser
echo "packages/desktop-client/build"

View File

@@ -51,17 +51,14 @@ fi
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace plugins-service build
yarn workspace @actual-app/core build:node
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn workspace @actual-app/core build:browser
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
yarn workspace @actual-app/core exec tsgo -p tsconfig.json
yarn workspace desktop-electron update-client
(

View File

@@ -3,7 +3,6 @@ module.exports = {
pipeline: {
typecheck: {
type: 'npmScript',
dependsOn: ['^typecheck'],
},
test: {
type: 'npmScript',

View File

@@ -25,16 +25,16 @@
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"start:docs": "yarn workspace docs start",
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
"start:service-plugins": "yarn workspace plugins-service watch",
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"start:storybook": "yarn workspace @actual-app/components start:storybook",
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
"build:browser-backend": "yarn workspace loot-core build:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
@@ -53,11 +53,11 @@
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware",
"lint:fix": "oxfmt . && oxlint --fix --type-aware",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
@@ -65,7 +65,6 @@
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.10",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"baseline-browser-mapping": "^2.9.19",
"cross-env": "^10.1.0",
"eslint": "^9.39.2",
@@ -88,7 +87,6 @@
"typescript": "^5.9.3"
},
"resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"rollup": "4.40.1",
"socks": ">=2.8.3"
},
@@ -97,7 +95,7 @@
"oxfmt --no-error-on-unmatched-pattern"
],
"*.{js,mjs,jsx,ts,tsx}": [
"oxlint --fix --type-aware --quiet"
"oxlint --fix --type-aware"
]
},
"browserslist": [

View File

@@ -3,18 +3,26 @@ import type {
RequestInit as FetchInit,
} from 'node-fetch';
import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main';
// loot-core types
import type { InitConfig } from 'loot-core/server/main';
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore: bundle not available until we build it
import * as bundle from './app/bundle.api.js';
import * as injected from './injected';
import { validateNodeVersion } from './validateNodeVersion';
let actualApp: null | typeof bundle.lib;
export const internal = bundle.lib;
export * from './methods';
export * as utils from './utils';
/** @deprecated Please use return value of `init` instead */
export let internal: typeof lib | null = null;
export async function init(config: InitConfig = {}) {
if (actualApp) {
return;
}
validateNodeVersion();
if (!globalThis.fetch) {
@@ -25,19 +33,21 @@ export async function init(config: InitConfig = {}) {
};
}
internal = await initLootCore(config);
return internal;
await bundle.init(config);
actualApp = bundle.lib;
injected.override(bundle.lib.send);
return bundle.lib;
}
export async function shutdown() {
if (internal) {
if (actualApp) {
try {
await internal.send('sync');
await actualApp.send('sync');
} catch {
// most likely that no budget is loaded, so the sync failed
}
await internal.send('close-budget');
internal = null;
await actualApp.send('close-budget');
actualApp = null;
}
}

View File

@@ -1,25 +0,0 @@
import { init as initLootCore } from '@actual-app/core/server/main';
import type { InitConfig, lib } from '@actual-app/core/server/main';
export * from './methods';
export * as utils from './utils';
let internal: typeof lib | null = null;
export async function init(config: InitConfig = {}) {
internal = await initLootCore(config);
return internal;
}
export async function shutdown() {
if (internal) {
try {
await internal.send('sync');
} catch {
// most likely that no budget is loaded, so the sync failed
}
await internal.send('close-budget');
internal = null;
}
}

7
packages/api/injected.js Normal file
View File

@@ -0,0 +1,7 @@
// TODO: comment on why it works this way
export let send;
export function override(sendImplementation) {
send = sendImplementation;
}

View File

@@ -1,29 +1,10 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { vi } from 'vitest';
import type { RuleEntity } from '@actual-app/core/types/models';
import type { RuleEntity } from 'loot-core/types/models';
import * as api from './index';
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
// Mock the fs so path constants point at loot-core package root where migrations live.
vi.mock(
'../loot-core/src/platform/server/fs/index.api',
async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>;
const pathMod = await import('path');
const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core');
return {
...actual,
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
};
},
);
const budgetName = 'test-budget';
global.IS_TESTING = true;

View File

@@ -6,16 +6,16 @@ import type {
APIPayeeEntity,
APIScheduleEntity,
APITagEntity,
} from '@actual-app/core/server/api-models';
import { lib } from '@actual-app/core/server/main';
import type { Query } from '@actual-app/core/shared/query';
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
import type { Handlers } from '@actual-app/core/types/handlers';
} from 'loot-core/server/api-models';
import type { Query } from 'loot-core/shared/query';
import type { Handlers } from 'loot-core/types/handlers';
import type {
ImportTransactionEntity,
RuleEntity,
TransactionEntity,
} from '@actual-app/core/types/models';
} from 'loot-core/types/models';
import * as injected from './injected';
export { q } from './app/query';
@@ -23,7 +23,7 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
name: K,
args?: Parameters<T>[0],
): Promise<Awaited<ReturnType<T>>> {
return lib.send(name, args);
return injected.send(name, args);
}
export async function runImport(
@@ -126,6 +126,11 @@ export function addTransactions(
});
}
export type ImportTransactionsOpts = {
defaultCleared?: boolean;
dryRun?: boolean;
};
export function importTransactions(
accountId: APIAccountEntity['id'],
transactions: ImportTransactionEntity[],

View File

@@ -8,43 +8,30 @@
"dist"
],
"main": "dist/index.js",
"module": "dist/browser.js",
"types": "@types/index.d.ts",
"exports": {
".": {
"browser": {
"types": "./@types/index.d.ts",
"default": "./dist/browser.js"
},
"default": {
"types": "./@types/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build": "yarn build:node && yarn build:browser",
"build:node": "vite build",
"build:browser": "vite build --config vite.browser.config.ts",
"test": "vitest --run",
"typecheck": "tsgo -b && tsc-strict"
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc && tsc-alias",
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
"clean": "rm -rf dist @types",
"typecheck": "yarn build && tsc --noEmit && tsc-strict"
},
"dependencies": {
"@actual-app/core": "workspace:*",
"@actual-app/crdt": "workspace:*",
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^12.6.2",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.5",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.0"
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20"

View File

@@ -1,29 +1,21 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
// Using ES2021 because that's the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
"module": "es2022",
"moduleResolution": "bundler",
"module": "CommonJS",
"moduleResolution": "node10",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": ".",
"declarationDir": "@types",
"tsBuildInfoFile": "dist/.tsbuildinfo",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
},
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
},
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
"include": ["."],
"exclude": [
"**/node_modules/*",
"dist",
"@types",
"*.test.ts",
"*.config.ts",
"*.browser.config.ts"
]
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
}

View File

@@ -1,2 +0,0 @@
declare module 'hyperformula/i18n/languages/enUS';
declare module '*.pegjs';

View File

@@ -1,4 +1,6 @@
import { lib } from '@actual-app/core/server/main';
// oxlint-disable-next-line typescript/ban-ts-comment
// @ts-ignore: bundle not available until we build it
import * as bundle from './app/bundle.api.js';
export const amountToInteger = lib.amountToInteger;
export const integerToAmount = lib.integerToAmount;
export const amountToInteger = bundle.lib.amountToInteger;
export const integerToAmount = bundle.lib.integerToAmount;

View File

@@ -1,26 +0,0 @@
import path from 'path';
import { defineConfig } from 'vite';
import peggyLoader from 'vite-plugin-peggy-loader';
const distDir = path.resolve(__dirname, 'dist');
export default defineConfig({
build: {
target: 'esnext',
outDir: distDir,
emptyOutDir: false,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.web.ts'),
formats: ['es'],
fileName: () => 'browser.js',
},
},
plugins: [peggyLoader()],
resolve: {
// Default extensions — picks up browser implementations (index.ts)
// instead of .api.ts (which resolves to Node.js/Electron code)
extensions: ['.js', '.ts', '.tsx', '.json'],
},
});

View File

@@ -1,93 +0,0 @@
import fs from 'fs';
import path from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
import peggyLoader from 'vite-plugin-peggy-loader';
const lootCoreRoot = path.resolve(__dirname, '../loot-core');
const distDir = path.resolve(__dirname, 'dist');
const typesDir = path.resolve(__dirname, '@types');
function cleanOutputDirs() {
return {
name: 'clean-output-dirs',
buildStart() {
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true });
if (fs.existsSync(typesDir)) fs.rmSync(typesDir, { recursive: true });
},
};
}
function copyMigrationsAndDefaultDb() {
return {
name: 'copy-migrations-and-default-db',
closeBundle() {
const migrationsSrc = path.join(lootCoreRoot, 'migrations');
const defaultDbPath = path.join(lootCoreRoot, 'default-db.sqlite');
if (!fs.existsSync(migrationsSrc)) {
throw new Error(`migrations directory not found at ${migrationsSrc}`);
}
const migrationsStat = fs.statSync(migrationsSrc);
if (!migrationsStat.isDirectory()) {
throw new Error(`migrations path is not a directory: ${migrationsSrc}`);
}
const migrationsDest = path.join(distDir, 'migrations');
fs.mkdirSync(migrationsDest, { recursive: true });
for (const name of fs.readdirSync(migrationsSrc)) {
if (name.endsWith('.sql') || name.endsWith('.js')) {
fs.copyFileSync(
path.join(migrationsSrc, name),
path.join(migrationsDest, name),
);
}
}
if (!fs.existsSync(defaultDbPath)) {
throw new Error(`default-db.sqlite not found at ${defaultDbPath}`);
}
fs.copyFileSync(defaultDbPath, path.join(distDir, 'default-db.sqlite'));
},
};
}
export default defineConfig({
ssr: { noExternal: true, external: ['better-sqlite3'] },
build: {
ssr: true,
target: 'node20',
outDir: distDir,
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'index.ts'),
formats: ['cjs'],
fileName: () => 'index.js',
},
},
plugins: [
cleanOutputDirs(),
peggyLoader(),
dts({
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
outDir: path.resolve(__dirname, '@types'),
rollupTypes: true,
}),
copyMigrationsAndDefaultDb(),
visualizer({ template: 'raw-data', filename: 'app/stats.json' }),
],
resolve: {
extensions: ['.api.ts', '.js', '.ts', '.tsx', '.json'],
},
test: {
globals: true,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
});

View File

@@ -0,0 +1,10 @@
export default {
test: {
globals: true,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
maxWorkers: 2,
},
};

View File

@@ -1 +0,0 @@
dist/*

View File

@@ -3,18 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"tsx": "node --import=extensionless/register --experimental-strip-types",
"test": "vitest --run",
"typecheck": "tsgo -b"
"test": "vitest --run"
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"extensionless": "^2.0.6",
"vitest": "^4.1.0"
},
"extensionless": {
"lookFor": [
"ts"
]
"vitest": "^4.0.18"
}
}

View File

@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [],
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
"strict": true,
"types": ["node"],
"outDir": "dist",
"rootDir": ".",
"composite": true
},
"include": ["src/**/*", "bin/**/*"],
"exclude": ["node_modules"]
}

View File

@@ -2,7 +2,7 @@ import { dirname } from 'path';
import { fileURLToPath } from 'url';
import type { StorybookConfig } from '@storybook/react-vite';
import react from '@vitejs/plugin-react';
import viteTsconfigPaths from 'vite-tsconfig-paths';
/**
* This function is used to resolve the absolute path of a package.
@@ -32,9 +32,11 @@ const config: StorybookConfig = {
const { mergeConfig } = await import('vite');
return mergeConfig(config, {
plugins: [react()],
resolve: {
tsconfigPaths: true,
// Telling Vite how to resolve path aliases
plugins: [viteTsconfigPaths({ root: '../..' })],
esbuild: {
// Needed to handle JSX in .ts/.tsx files
jsx: 'automatic',
},
});
},

View File

@@ -3,7 +3,6 @@ import { type ReactNode } from 'react';
import type { Preview } from '@storybook/react-vite';
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
// TODO: this needs refactoring
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
import * as lightTheme from '../../desktop-client/src/style/themes/light';

View File

@@ -40,7 +40,7 @@
"test:web": "ENV=web vitest --run -c vitest.web.config.ts",
"start:storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"typecheck": "tsgo -b"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@emotion/css": "^11.13.5",
@@ -54,14 +54,11 @@
"@storybook/react-vite": "^10.2.7",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.5",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint-plugin-storybook": "^10.2.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "^10.2.7",
"vite": "^8.0.0",
"vitest": "^4.1.0"
"vitest": "^4.0.18"
},
"peerDependencies": {
"react": ">=19.2",

View File

@@ -16,8 +16,8 @@ import { View } from './View';
const MenuLine: unique symbol = Symbol('menu-line');
const MenuLabel: unique symbol = Symbol('menu-label');
Menu.line = MenuLine as typeof MenuLine;
Menu.label = MenuLabel as typeof MenuLabel;
Menu.line = MenuLine;
Menu.label = MenuLabel;
type KeybindingProps = {
keyName: ReactNode;

View File

@@ -1,14 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"noEmit": true,
"rootDir": "src",
"strict": true,
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]

View File

@@ -1,6 +1,5 @@
import path from 'path';
import react from '@vitejs/plugin-react';
import peggyLoader from 'vite-plugin-peggy-loader';
import { defineConfig } from 'vitest/config';
@@ -24,7 +23,13 @@ export default defineConfig({
maxWorkers: 2,
},
resolve: {
alias: [
{
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve('../../../crdt/src$1'),
},
],
extensions: resolveExtensions,
},
plugins: [react(), peggyLoader()],
plugins: [peggyLoader()],
});

View File

@@ -8,23 +8,12 @@
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"scripts": {
"build:node": "tsgo",
"build:node": "tsc",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --run",
"typecheck": "tsgo -b"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"google-protobuf": "^3.21.4",
@@ -33,9 +22,9 @@
},
"devDependencies": {
"@types/google-protobuf": "3.15.12",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"protoc-gen-js": "3.21.4-4",
"ts-protoc-gen": "0.15.0",
"vitest": "^4.1.0"
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}

View File

@@ -91,7 +91,7 @@ export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
while (true) {
const keyset = new Set([...getKeys(node1), ...getKeys(node2)]);
const keys = [...keyset.values()];
keys.sort((a, b) => a.localeCompare(b));
keys.sort();
let diffkey: null | '0' | '1' | '2' = null;
@@ -145,7 +145,7 @@ export function prune(trie: TrieNode, n = 2): TrieNode {
}
const keys = getKeys(trie);
keys.sort((a, b) => a.localeCompare(b));
keys.sort();
const next: TrieNode = { hash: trie.hash };

View File

@@ -121,12 +121,12 @@ describe('Timestamp', function () {
it('should fail with counter overflow', function () {
now = 40;
for (let i = 0; i < 65536; i++) Timestamp.send();
expect(() => Timestamp.send()).toThrow(Timestamp.OverflowError);
expect(Timestamp.send).toThrow(Timestamp.OverflowError);
});
it('should fail with clock drift', function () {
now = -(5 * 60 * 1000 + 1);
expect(() => Timestamp.send()).toThrow(Timestamp.ClockDriftError);
expect(Timestamp.send).toThrow(Timestamp.ClockDriftError);
});
});

View File

@@ -1,7 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
// Using ES2021 because that's the newest version where
// the latest Node 16.x release supports all of the features
"target": "ES2021",
@@ -9,10 +8,8 @@
"moduleResolution": "node10",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"strict": true,
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo"
"outDir": "dist"
},
"include": ["."],
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -61,7 +61,7 @@ export class ConfigurationPage {
break;
default:
throw new Error(`Unrecognized import type: ${String(type)}`);
throw new Error(`Unrecognized import type: ${type}`);
}
const fileChooser = await fileChooserPromise;

View File

@@ -39,7 +39,7 @@ export class CustomReportPage {
.click();
break;
default:
throw new Error(`Unrecognized mode: ${String(mode)}`);
throw new Error(`Unrecognized mode: ${mode}`);
}
}
}

View File

@@ -1,18 +1,18 @@
import type { Locator, Page } from '@playwright/test';
const NO_PAYEES_FOUND_TEXT = 'No payees found.';
export class MobilePayeesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly payeesList: Locator;
readonly emptyMessage: Locator;
readonly loadingIndicator: Locator;
readonly noPayeesFoundText: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter payees…');
this.payeesList = page.getByRole('grid', { name: 'Payees' });
this.emptyMessage = page.getByText('No payees found.');
this.loadingIndicator = page.getByTestId('animated-loading');
this.noPayeesFoundText = this.payeesList.getByText(NO_PAYEES_FOUND_TEXT);
}
async waitFor(options?: {
@@ -47,7 +47,11 @@ export class MobilePayeesPage {
* Get all visible payee items
*/
getAllPayees() {
return this.payeesList.getByRole('gridcell');
// `GridList.renderEmptyState` still renders a row with "No payees found" text
// when no payees are present, so we need to filter that out to get the actual payee items.
return this.payeesList
.getByRole('row')
.filter({ hasNotText: NO_PAYEES_FOUND_TEXT });
}
/**
@@ -65,11 +69,4 @@ export class MobilePayeesPage {
const payees = this.getAllPayees();
return await payees.count();
}
/**
* Wait for loading to complete
*/
async waitForLoadingToComplete(timeout: number = 10000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout });
}
}

View File

@@ -1,18 +1,21 @@
import type { Locator, Page } from '@playwright/test';
const NO_RULES_FOUND_TEXT =
'No rules found. Create your first rule to get started!';
export class MobileRulesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly addButton: Locator;
readonly rulesList: Locator;
readonly emptyMessage: Locator;
readonly noRulesFoundText: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter rules…');
this.addButton = page.getByRole('button', { name: 'Add new rule' });
this.rulesList = page.getByRole('main');
this.emptyMessage = page.getByText('No rules found');
this.rulesList = page.getByRole('grid', { name: 'Rules' });
this.noRulesFoundText = this.rulesList.getByText(NO_RULES_FOUND_TEXT);
}
async waitFor(options?: {
@@ -47,7 +50,11 @@ export class MobileRulesPage {
* Get all visible rule items
*/
getAllRules() {
return this.page.getByRole('grid', { name: 'Rules' }).getByRole('row');
// `GridList.renderEmptyState` still renders a row with "No rules found" text
// when no rules are present, so we need to filter that out to get the actual rule items.
return this.rulesList
.getByRole('row')
.filter({ hasNotText: NO_RULES_FOUND_TEXT });
}
/**

View File

@@ -1,22 +1,23 @@
import type { Locator, Page } from '@playwright/test';
const NO_SCHEDULES_FOUND_TEXT =
'No schedules found. Create your first schedule to get started!';
export class MobileSchedulesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly addButton: Locator;
readonly schedulesList: Locator;
readonly emptyMessage: Locator;
readonly loadingIndicator: Locator;
readonly noSchedulesFoundText: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter schedules…');
this.addButton = page.getByRole('button', { name: 'Add new schedule' });
this.schedulesList = page.getByRole('grid', { name: 'Schedules' });
this.emptyMessage = page.getByText(
'No schedules found. Create your first schedule to get started!',
this.noSchedulesFoundText = this.schedulesList.getByText(
NO_SCHEDULES_FOUND_TEXT,
);
this.loadingIndicator = page.getByTestId('animated-loading');
}
async waitFor(options?: {
@@ -51,7 +52,11 @@ export class MobileSchedulesPage {
* Get all visible schedule items
*/
getAllSchedules() {
return this.schedulesList.getByRole('gridcell');
// `GridList.renderEmptyState` still renders a row with "No schedules found" text
// when no schedules are present, so we need to filter that out to get the actual schedule items.
return this.schedulesList
.getByRole('row')
.filter({ hasNotText: NO_SCHEDULES_FOUND_TEXT });
}
/**
@@ -76,11 +81,4 @@ export class MobileSchedulesPage {
const schedules = this.getAllSchedules();
return await schedules.count();
}
/**
* Wait for loading to complete
*/
async waitForLoadingToComplete(timeout: number = 10000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout });
}
}

View File

@@ -34,8 +34,6 @@ test.describe('Mobile Payees', () => {
});
test('checks the page visuals', async () => {
await payeesPage.waitForLoadingToComplete();
// Check that the header is present
await expect(page.getByRole('heading', { name: 'Payees' })).toBeVisible();
@@ -63,8 +61,6 @@ test.describe('Mobile Payees', () => {
});
test('clicking on a payee opens payee edit page', async () => {
await payeesPage.waitForLoadingToComplete();
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
@@ -89,8 +85,7 @@ test.describe('Mobile Payees', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
const emptyMessage = page.getByText('No payees found.');
await expect(emptyMessage).toBeVisible();
await expect(payeesPage.noPayeesFoundText).toBeVisible();
// Check that no payee items are visible
const payees = payeesPage.getAllPayees();
@@ -99,8 +94,6 @@ test.describe('Mobile Payees', () => {
});
test('search functionality works correctly', async () => {
await payeesPage.waitForLoadingToComplete();
// Test searching for a specific payee
await payeesPage.searchFor('Fast Internet');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -79,8 +79,7 @@ test.describe('Mobile Rules', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
const emptyMessage = page.getByText(/No rules found/);
await expect(emptyMessage).toBeVisible();
await expect(rulesPage.noRulesFoundText).toBeVisible();
// Check that no rule items are visible
const rules = rulesPage.getAllRules();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -27,6 +27,7 @@ test.describe('Mobile Schedules', () => {
// Navigate to schedules page and wait for it to load
schedulesPage = await navigation.goToSchedulesPage();
await schedulesPage.waitFor();
});
test.afterEach(async () => {
@@ -34,8 +35,6 @@ test.describe('Mobile Schedules', () => {
});
test('checks the page visuals', async () => {
await schedulesPage.waitForLoadingToComplete();
// Check that the header is present
await expect(
page.getByRole('heading', { name: 'Schedules' }),
@@ -60,14 +59,15 @@ test.describe('Mobile Schedules', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
await expect(schedulesPage.emptyMessage).toBeVisible();
await expect(schedulesPage.noSchedulesFoundText).toBeVisible();
// Check that no schedule items are visible
const schedules = schedulesPage.getAllSchedules();
await expect(schedules).toHaveCount(0);
await expect(page).toMatchThemeScreenshots();
});
test('clicking on a schedule opens edit form', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for at least one schedule to be present
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();
@@ -89,8 +89,6 @@ test.describe('Mobile Schedules', () => {
});
test('searches and filters schedules', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for schedules to load
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();
@@ -118,8 +116,6 @@ test.describe('Mobile Schedules', () => {
});
test('displays schedule details correctly in list', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for schedules to load
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Some files were not shown because too many files have changed in this diff Show More