* [AI] Fix budget templates with a priority not allocating funds in tracking budgets
Tracking budgets never define a `to-budget` sheet cell (only envelope budgets
do), so the priority-tier engine always saw $0 available and clamped every
priority>0 template to 0. Seed the pool from `total-saved` instead when in
tracking mode, since it's the structural analog (budgeted income minus
budgeted expenses) and preserves genuine priority-tier competition rather
than disabling the clamp outright.
Fixes#8422
* Trigger Build
* Trigger Build
* Trigger Build
* [AI] Promote Age of Money report to a stable feature
Remove the experimental ageOfMoneyReport feature flag so the report
is always available, matching the recent Payee Locations promotion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Change release note category to Features
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* [AI] Count filtered-out on-budget transfers in Age of Money
When account filters are applied, transfers to/from accounts outside the
filtered set should count as expenses/income so filtering to a cash
account reflects age of cash rather than budget-wide AoM.
Co-authored-by: Cursor <cursoragent@cursor.com>
* [AI] Fix Age of Money lint: template expressions and formatting
Co-authored-by: Cursor <cursoragent@cursor.com>
* [AI] Address CodeRabbit feedback on AoM transfer filters
Treat empty notOneOf as a tautology, narrow array values without
assertions, and clarify the release note covers both transfer directions.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* [AI] Fix updateTransaction crash when converting a transaction to a split
* [AI] Clear is_child/parent_id when promoting a transaction to a split parent
* [AI] Type-fix: clear parent_id with undefined, not null (parent_id is an optional string)
---------
Co-authored-by: Hossein Moosavi <h.moosavi@kif-devs.com>
* [AI] ci: support parallel e2e shards with per-shard port
- Add 'Set E2E port' step to functional and vrt jobs (E2E_PORT = 3000 + shard)
- Use vars.E2E_SHARD_TOTAL (with fallback '3') for shard denominator in job
names and test commands; pass --var E2E_SHARD_TOTAL=1 to run full suite in
a single local shard without relaunching
- Add .actrc with depot runner image mappings
* [AI] fix(ci): address review feedback on e2e shard guard/injection
* [AI] Improve the first-account creation flow
Rewrites the empty-account states on desktop and mobile in plain
language, with a centered layout and a visible Add account button on
mobile. When no sync server is configured, the add-account chooser is
skipped in favor of the account creation form directly, which gains a
name placeholder, an explanation of off-budget accounts, and a pointer
to QIF/OFX/QFX file import.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* coderabbit
* add linebreak
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* [AI] Add bank sync provider setup to the mobile Bank Sync page
The mobile Bank Sync page previously only listed existing accounts,
with no way to configure a provider or link a new bank account, and
its empty state wrongly claimed an account must be created first
(linking creates accounts). It now renders the same provider section
as desktop, corrects the empty-state guidance on both platforms, and
hides the account filter when there are no accounts.
VRT snapshots for the mobile bank sync test were deleted and need
regenerating with yarn vrt:docker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8497
* collapsible provider
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8497
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* [AI] Promote Payee Locations to a stable feature
Remove the experimental payeeLocations feature flag and make payee
locations always available on the mobile transaction screen. Move the
documentation out of the experimental section into the transactions area.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3sk6D2TPLFBBaE17VptxJ
* [AI] Shorten payee locations release note
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3sk6D2TPLFBBaE17VptxJ
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8511
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* [AI] Add tooltip with status icon legend to cleared column header
Hovering the ✓ header in the transactions table now shows a tooltip
explaining the column, with a legend of each status icon that can
appear there (uncleared, cleared, reconciled, and the upcoming/due/
missed scheduled-transaction states).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Broaden cleared/uncleared tooltip wording beyond bank sync
Reviewer feedback: cleared status doesn't require bank sync — it can be
a manual "I've verified this transaction" mark. Reword the legend to
avoid implying the bank performs the confirmation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix electron invalid origin
* [AI] Add tests for the desktop GoCardless linking fix
Covers the electron fetch wrapper's derived Origin header and the
sync-server /gocardless/create-web-token origin and id validation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TKqNWc6Lbigv8e61uLoopU
* note
* allow old origin and move link so that it exists on electron bundled server
* provide a valid redirect URL to GoCardless
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* [AI] feat(reports): add browser-independent month range picker
Replace the report date dropdowns with a popover month range picker built
from React and Actual's own components instead of a native month input
(which does not work in Firefox). Includes start/end month grids,
quick-select presets folded into the popover, a draft selection that only
recomputes the report on close, today highlighting, and an unbounded future
range.
Assisted-by: ClaudeCode:claude-opus-4.8
Co-authored-by: rznn7 <160644362+rznn7@users.noreply.github.com>
* [AI] refactor(desktop-client): replace pikaday with react-aria in DateSelect
Swap the Pikaday-based day picker in DateSelect for react-aria-components'
Calendar primitives (backed by @internationalized/date). The user's
firstDayOfWeekIdx preference is encoded via the Unicode 'fw' locale
extension, and the prev/next arrows now use the existing SvgCheveron
icons instead of the two bundled PNGs.
Removes the pikaday and @types/pikaday dependencies, deletes the unused
DateSelect.left/right.png assets, and adds @internationalized/date as a
direct dependency.
Assisted-by: ClaudeCode:claude-opus-4.8
* [AI] fix(desktop-client): resolve CI failures in month picker PR
Remove four devDependencies (@juggle/resize-observer, @types/lodash, @types/promise-retry, memoize-one) that were added but never used, fixing the knip check. Dedupe the split @codemirror/state and @codemirror/view resolutions that the added/removed deps introduced, fixing typecheck. Pin shell-quote (a transitive dependency of launch-editor/npm-run-all) to >=1.8.4 via resolutions, matching the existing socks/serialize-javascript overrides, to clear the critical CVE flagged by Dependency Review.
Assisted-by: ClaudeCode:claude-sonnet-5
* updated yarn.lock
* Updated yarn.lock to address dependency issue
* address more dependency issues
* `yarn up -R '**'` to hopefully address all dependency issues
* fixed donutgraph.tsx typing
* [AI] feat(reports): add Month/Day granularity toggle to range picker
Add a Month/Day toggle above the quick-select column in the report range
picker so users can pick exact days, not just whole months. Switching to Day
expands the range to cover whole days (start -> first of month, end -> last of
month); switching back collapses to months. In day mode the picker emits
yyyy-MM-dd start/end.
Make the month-based reports tolerate day-level values: month range helpers in
monthUtils already coerce day strings to months, and this replaces the few
fragile 'start + "-01"' concatenations (BalanceForecast, BalanceForecastCard,
BudgetAnalysis, balanceForecastChartData) with firstDayOfMonth/getMonth so a
yyyy-MM-dd value is normalized instead of corrupted.
Assisted-by: ClaudeCode:claude-opus-4.8
* [AI] feat(reports): honor day granularity, live end-offset, format-aware picker
Task 3: derive the range picker trigger's display format from the committed
value's shape (yyyy-MM-dd vs yyyy-MM) via a new valueIsDay() helper instead of
the granularity flag, so the label stays correct (day vs month) after the
popover closes.
Task 1: let a live/sliding range end before the current month.
getLatestRange(offset, endOffset=0) and calculateTimeRange's sliding-window
branch derives endOffset from the saved frame (clamped >= 0) so the window's
gap-from-now is preserved as months roll over. Surface it with an 'Exclude
current month' checkbox in the picker popover (shifts the whole range back one
month, keeping its width), enabled for past ranges only.
Task 2: add a maintainer-facing granularities prop on MonthRangePicker (threaded
through Header), defaulting to ['month','day']. Pass ['month'] to hide the Day
toggle on month-only reports; Budget Analysis is locked to month. AgeOfMoney's
granularity stays a distinct concept.
Add unit tests for getLatestRange/calculateTimeRange endOffset and the picker
util helpers. Use named imports throughout.
Assisted-by: ClaudeCode:claude-opus-4.8
Co-authored-by: rznn7 <rznn7@users.noreply.github.com>
* [AI] feat(reports): single-calendar range picker, fix granularity reset on remount
Replaces the two side-by-side Start/End calendar grids in the reports date-range picker with a single calendar (RangeSelector) supporting click-to-pick range selection with a live hover preview, for both Month and Day granularity.
Also fixes the Month/Day toggle silently reverting after a pick: CashFlow, NetWorth, AgeOfMoney, and BalanceForecast unmount their Header/MonthRangePicker subtree on every date change (useReport nulls out data during refetch), which wiped the picker's internal granularity state. Lifted that state into each report so it survives the remount.
Assisted-by: ClaudeCode:claude-sonnet-5
* [AI] fix(reports): persist sliding-window endOffset so live ranges actually slide forward
calculateTimeRange's sliding-window branch re-derived the gap between a saved end date and 'now' on every call, using the stale, decaying persisted end. Since most reports only rewrite that persisted end when the user explicitly saves the widget, the derived gap grew by however much time had passed since the last save — meaning a live range never actually advanced once its gap was nonzero; it stayed pinned to the last-saved dates indefinitely. Caught by useFormulaExecution.test.ts's 'shifts each formula report query window when the current month changes' test, which was failing on this branch.
Adds TimeFrame.endOffset, persisted explicitly alongside start/end/mode instead of re-derived from decaying absolute dates. getLatestRange and calculateTimeRange now return the resolved endOffset as a 4th tuple element; MonthRangePicker computes it at commit time and threads it through Header's onChangeDates into every report that persists a timeFrame (CashFlow, NetWorth, AgeOfMoney, BalanceForecast, Calendar, Sankey, Crossover, Summary, BudgetAnalysis). Legacy saved widgets without endOffset fall back to the old derivation once, as a migration.
Assisted-by: ClaudeCode:claude-sonnet-5
* [AI] fix(reports): route day-grid clicks through pick directly
DayGrid's per-day onSelect was wired to selectMonth, a handler meant for
month-grid clicks and month-navigation day-carry-over. Fed a full day
string it built a malformed candidate (e.g. 2026-07-20-10) that never
string-matched the clicked day, so the range endpoint lost its selected
(primary) styling and only showed as part of the in-range band.
Assisted-by: ClaudeCode:claude-sonnet-5
* [AI] fix(reports): keep day-mode range width when reopening picker
openPopover unconditionally expanded start/end to the full month via
toDayStart/toDayEnd whenever reopening in day mode, even when the range
was already day-shaped from a prior precise pick. Only expand when the
incoming value is still month-shaped (an actual month-to-day switch).
Assisted-by: ClaudeCode:claude-sonnet-5
* [AI] fix(reports): address code review findings on month/day range picker
Fix clamp() returning granularity-mismatched bounds when min/max are month-shaped but the picker is in day mode. Add accessible labels to NavRow's prev/next icon buttons. Deduplicate the Pie shape index coercion in DonutGraph into a shared helper. Drop the unnecessary useMemo around MonthRangePicker's label string.
Skipped: the Header.tsx allMonths[allMonths.length - 1] access can't crash since every caller already guards on allMonths being non-empty before rendering Header. The RangeEndSelector.tsx dead-code finding refers to a file that no longer exists in this codebase. The DateSelect.tsx firstDayOfWeek suggestion doesn't apply: react-aria-components' public CalendarGrid props don't expose firstDayOfWeek in the installed version, so the locale '-u-fw-' extension is still the only way to configure it.
Assisted-by: ClaudeCode:claude-sonnet-5
* [AI] fix(reports): stop widening day-shaped bounds in balance forecast chart data
buildBalanceForecastChartData's Daily branch applied firstDayOfMonth/lastDayOfMonth to start/end unconditionally, which expanded an already day-shaped range (e.g. 2024-03-15) out to its whole month. Only expand when the bound is month-shaped (isValidYearMonth), otherwise use it as-is.
Assisted-by: ClaudeCode:claude-sonnet-5
* [AI] fix(reports): address CodeRabbit findings on month/day range picker (round 2)
Header: fall back minDate to the current month when allMonths is still empty (Summary/Calendar render before their async load populates it). RangeSelector/DayGrid: normalize min/max to day granularity in DayGrid so a month-shaped bound doesn't disable every day in its own month. AgeOfMoney: derive the picker's initial Month/Day mode from the restored range's shape instead of hardcoding month, since that mode isn't persisted separately from timeFrame. BudgetAnalysis: normalize start/end to yyyy-MM inside calculateIsConcise so a legacy day-shaped persisted timeFrame can't corrupt the date-fns parse. DonutGraph: read props.index directly instead of coercing a second shape-callback argument. MonthRangePicker: use && short-circuit JSX per the style guide. AgeOfMoney: drop an unnecessary useCallback with no deps, matching sibling reports. DayGrid/RangeSelector/MonthRangePicker/Header: thread firstDayOfWeekIdx through so the day grid's weekday header and alignment follow the user's configured first day of week instead of hardcoding Sunday-first.
Skipped: the Crossover.tsx 'double shift' finding — the extra -1 month shift is pre-existing, documented, intentional business logic (excludes the current month), applied symmetrically on both load and onChangeDates regardless of whether endOffset was legacy-derived or explicitly persisted; the suggested fix wouldn't touch that code path anyway.
Assisted-by: ClaudeCode:claude-sonnet-5
* Update category and authors in release notes
Address technical debt by introducing a day picker for the calendar.
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8427
* [AI] Address review feedback on month/day range picker and DateSelect
- Crossover: treat legacy widgets' missing endOffset as 0 (no double shift)
- Only offer "Exclude current month" in month mode for ranges ending at the
current or previous month
- Cap past-oriented reports at the newest month; future stays for forecasts
- Don't commit on Month/Day switch so the popover survives report reloads
- Month-only picker for Calendar, Summary, Sankey and Crossover
- Transaction date picker: visible month-nav arrows that keep focus in the
input, normal-sized heading, square day cells, tighter popover
- Give unselected grid cells a transparent border so selecting doesn't
resize the grid
- Derive picker granularity from the committed value shape and drop the
lifted per-report state and Header props
- Simplify per review: reuse ModeButton/monthUtils/Calendar firstDayOfWeek,
remove allowFuture, add useLanguage, fix double render on keystroke,
strip prose comments
- Restore master's yarn.lock (only the pikaday removal remains), drop the
shell-quote resolution, revert unrelated DonutGraph change
- Release note: describe the feature, add MatissJanis to authors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8427
* [AI] Cap day-mode picking at today and clamp exclude-toggle shifts
- A month-shaped max that reaches the current month no longer widens to the
month's last day in day mode; day cells after today are disabled and the
Month->Day draft widening clamps its end to today
- DayGrid and clamp() keep already day-shaped bounds exact instead of
widening them back to whole months
- toggleExcludeCurrentMonth clamps the shifted range to minDate/maxDate like
every other draft mutation
- granFor: function declaration per code style
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Use one SpaceBetween for consistent report header spacing
The header row was two SpaceBetween groups (gap 15 and gap 3) with no gap
between them, and reports wrapped their own controls in ad-hoc gap-10 Views.
Merge the mode toggle, date picker, filter button and report controls into a
single SpaceBetween, render header children through one too, and drop the
per-report wrappers (Balance Forecast, Cash Flow, Budget Analysis, Sankey).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Drop endOffset; sliding windows always anchor to the current month
Remove the persisted TimeFrame.endOffset and its wiring through
reportRanges, the range picker, Header and all report pages. A live
(sliding-window) range now always ends at the current month; "Exclude
current month" still shifts the draft range and commits it as static.
Also address code review findings: guard the "All time" preset while
allMonths is still loading, remount the range selector on granularity
switch so a month-shaped click-anchor can't produce a mixed-shape range,
only offer the exclude toggle when both of its states fit the configured
bounds, and give the month/day grid cells full localized accessible
names and a pressed state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8427
* [AI] Remove the "Exclude current month" checkbox
Its only unique capability was creating a live range ending last month via
endOffset, which is gone; picking the end month in the grid now does the
same thing. Deletes the toggle component, the allowExcludeCurrentMonth
prop and Header wiring, and the now-unused shiftMonths util.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Simplify month-range picker after cleanup review
Drop the util.ts aliases of shared months.ts helpers, derive picker
granularity from the draft value's shape instead of separate state,
normalize min/max bounds once in MonthRangePicker, inline the Header
preset builders, and make month-only the default granularity with
day-capable reports opting in explicitly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Rebuild day-mode picking on react-aria RangeCalendar
Replace the hand-rolled DayGrid with react-aria's RangeCalendar, which
adds keyboard navigation and built-in hover range preview, and use
month/year dropdowns (CalendarMonthPicker plus a bounds-based year
select) in the calendar header for quick jumping. The month grid stays
custom since react-aria has no month-granularity range picker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Address review feedback and fix picker styling glitches
Validate the full yyyy-MM-dd shape in valueIsDay instead of negating
the year-month check, cap the year dropdown when the report has no
upper date bound (the far-future sentinel would render ~8000 options),
use the bare-button hover color so hovered dates and month/year
selects stay readable, and stretch the day grid to the calendar's
full width.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Simplify month-range picker after cleanup review
- YearSelect reads bounds from RangeCalendarStateContext instead of props
- Dedupe first-day-of-week pref decoding into getFirstDayOfWeek in DateSelect
- Hoist the day-calendar emotion css() call to module scope
- RangeSelector tracks viewYear directly instead of a month it only read the year from
- Inline the single-consumer Grid component into MonthGrid
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Let live report ranges slide by days for day-shaped windows
Day-shaped sliding-window ranges now keep their width in days and
re-anchor to today, so Live works with day granularity in reports.
Formula queries stay month-granular via asMonthSlidingTimeFrame.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8427
* [AI] Move the date range picker into the component library
Extract the reports MonthRangePicker into @actual-app/components as
DateRangePicker: translated strings arrive via a labels prop, date
formatting runs on Intl.DateTimeFormat with a BCP 47 locale string
instead of loot-core's date-fns wrappers, and the first-day-of-week
pref is resolved by the caller. Header.tsx supplies all of these; the
component library gains @internationalized/date and subpath import
mappings for the picker's internals.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Add VRT for the reports date range picker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Add VRT snapshots for the reports date range picker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Clean up the date range picker after review
- Move ModeButton into the component library and reuse it for the
granularity toggle instead of a near-identical SegmentButton copy
- Move the day-shaped value check into loot-core as isValidYearMonthDay
so reportRanges no longer imports it through the picker component
- Cache Intl.DateTimeFormat instances in formatDate
- Build lastDayOfMonth/currentDay on @internationalized/date
- Pass label subsets as one labels object instead of scalar props
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8427
* [AI] Revert "Update VRT screenshots"
This reverts commit 8cd01e5148.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Hoist DateRangePicker to src root, add ModeButton story, respect app date format
- Move DateRangePicker.tsx and its stories out of the date-range-picker/
folder so the story sorts alphabetically in the Storybook sidenav; only
child components remain in the folder.
- Add a Storybook file for ModeButton.
- Add an optional formatDayLabel prop so day-shaped trigger labels can
follow the app's date format preference; reports Header now passes the
dateFormat synced pref instead of falling back to locale-numeric dates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Address review feedback on the date range picker
- Bound scheduled-occurrence counts to the visible chart range so day-mode
ranges don't count occurrences outside the selected days
- Carry the running balance forward from points before a day-shaped start
instead of starting the daily chart at zero
- Drop the day granularity from the tracking-budget forecast header, which
only resolves monthly
- Validate the day against the month and year (incl. leap years) in
isValidYearMonthDay
- Target the date range picker trigger by test id in the reports e2e test
- Drop a redundant useMemo in useLocale (React Compiler memoizes)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Keep the date range picker open when a quick-select preset is clicked
Presets now preview their range in the open picker draft, like manual
selection, and commit on close so live-range modes are preserved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Improve date range picker colors and highlight the active preset
Style the calendar header dropdown options so they stay readable in dark
themes, introduce a datePickerRangeBackground theme variable so the light
theme's range band is purple like the other themes, and derive the active
quick-select preset from the current range so it stays highlighted when
reopening.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8427
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8427
---------
Co-authored-by: rznn7 <160644362+rznn7@users.noreply.github.com>
Co-authored-by: rznn7 <rznn7@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <matiss@mja.lv>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* [AI] Redesign first-run onboarding for web/PWA users
Fresh web/PWA installs whose origin does not serve a sync server now
start in local-only mode on a redesigned welcome screen instead of the
"Where's the server?" page, with syncing offered as an option. The
server connection page is retitled and redesigned with a back button
and friendlier copy. Transient probe failures (server unreachable)
keep the previous behavior and persist nothing, so a briefly-down
self-hosted server is retried on the next load. Electron keeps its
existing server-config first run, and origins that serve a sync
server still go to bootstrap/login.
VRT snapshots for the onboarding test were deleted and need
regenerating with yarn vrt:docker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update VRT screenshots
Auto-generated by VRT workflow
PR: #8495
* coderabbit
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
SQLite accepts null as a bound parameter (verifyParamTypes already
allowed it at runtime), but the runQuery/all/first/firstSync/run
signatures only declared string | number, forcing callers to cast.
Extract a shared SqlParam type (string | number | null) from the
sqlite platform modules and use it everywhere. Adds a test covering
null params through all/first.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* [AI] Support custom dataDir in API browser build and stop masking worker errors
Two fixes for the browser build of @actual-app/api:
1. A dataDir other than /documents failed with a cryptic DataCloneError:
the directory didn't exist in the worker's virtual filesystem, and the
resulting Emscripten ErrnoError (which carries a function-valued
property) couldn't be structured-cloned, so postMessage's own
DataCloneError replaced the real error entirely.
The web worker connection now retries a failed postMessage with a
serialized, cloneable copy of the message so the client always receives
the actual failure. Cloneable errors pass through unchanged.
2. The web filesystem registers the configured document dir as a persisted
root: it is created automatically on init, its files are persisted to
IndexedDB (sqlite files symlinked into the blocked fs), and files
recorded in IndexedDB are restored at boot regardless of prefix. The
API browser worker defaults dataDir to /documents when omitted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Trim trailing slashes without a regex
CodeQL flags the anchored /\/+$/ replace as quadratic on strings with
many repeated slashes; walk the index back instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Address review: single persisted root, guarded fallback post, plain release note
- Replace the accumulating persistedRoots set with the default /documents
plus a single replaceable custom root, so switching document dirs no
longer leaves stale roots marked as persisted
- Guard safePost's final fallback post so a broken channel can't escape
as an unhandled rejection
- Reword the release note in plain user-facing language
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] loot-core: route server push messages through safePost
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* [AI] fix(loot-core): replace adm-zip with fflate for zip handling
Replaces the adm-zip dependency (which requires Node's `process`/`Buffer`
and can break in browser bundle contexts) with fflate, a zero-dependency,
isomorphic zip library that works identically in node and browser builds.
Adds a small `zip-utils.ts` wrapper (`safeZip`/`safeUnzip`) around fflate
to preserve the safety guarantees a hardened zip library provides by
default, since all three call sites parse zips that may cross a trust
boundary (cloud sync downloads, user-supplied budget/import files):
- Rejects zip-slip/path-traversal entry names (absolute paths, `..`
segments, backslashes, drive letters, NUL bytes).
- Caps total archive size and per-entry uncompressed size, enforced via
fflate's `filter` callback before it allocates the decompression buffer
for each entry, to guard against decompression bombs.
- Rejects duplicate/case-variant entry names to avoid ambiguity attacks.
Updated call sites: budgetfiles/backups.ts (local backup zip write/read),
cloud-storage.ts (cloud sync export/import), importers/ynab4.ts (YNAB4
budget import).
* [AI] test(desktop-client): drop export/import perf e2e test
Removes packages/desktop-client/e2e/export-import-performance.test.ts,
added in an earlier commit to benchmark adm-zip vs fflate. Per review
feedback (matt-fidd): this zip-handling logic changes rarely, and a
future library swap would warrant its own fresh benchmarking rather
than maintaining a ~300 line permanent regression test for it.
The benchmark numbers already recorded in the PR description remain
accurate for this change.
* [AI] fix(loot-core): distinguish oversized-zip errors from generic parse failures
cloud-storage.ts's importBuffer (shared by both cloud-sync download and the
local "Import my budget -> Actual" file picker) caught every safeUnzip
failure the same way and threw FileDownloadError('not-zip-file'). Since
safeZip has no size limit but safeUnzip enforces one by default, a
self-generated export that grows past that cap (e.g. a large db.sqlite)
would fail to re-import with a misleading "this isn't a zip file" message,
even though the file is perfectly valid, just larger than the cap.
Catches UnsafeZipError separately and surfaces a new 'zip-too-large' reason,
with matching UI messages in both places that already handle
not-zip-file/invalid-zip-file/invalid-meta-file (loot-core's shared error
mapper and desktop-client's local import modal), reusing the same support-
link phrasing already used for invalid files.
* [AI] fix(loot-core): stop faking runtime config for zip upload size limit
Vite bakes process.env.ACTUAL_UPLOAD_FILE_SIZE_LIMIT_MB into the client
bundle at build time, so it was never actually runtime-configurable in
the browser build despite looking like sync-server's real env var.
Drop the env indirection and hardcode a fixed 20MB entry cap / 60MB
archive cap instead.
* [AI] fix(loot-core): tighten zip entry-name matching in importBuffer
* [AI] feat(loot-core): raise zip size limits to 500MB, warn on oversized/low-memory exports
Matt-fidd's review on #8393 argued 20MB was too tight for real budgets and
asked for (1) a more generous cap, (2) a warning when a backup is created
that won't be re-importable, and (3) test coverage for zip.ts's safety
checks (path traversal, size limits, duplicate entries, unsafe names,
round-trip). Later review passes asked to trim the verbose comments in
zip.ts and to use <Trans> instead of t() for the new warning strings.
- Collapse the three separate default caps into one MAX_ZIP_SIZE (500MB),
since archive size, per-entry size, and total uncompressed size all
shared the same value anyway. safeUnzip can run outside the browser
(Electron, or Node via @actual-app/api) where the whole archive is held
in memory at once, and some self-hosting providers (PikaPods) run
containers with as little as 256MB RAM, so the cap stays flat rather
than compounding into a multi-GB worst case.
- Add exceedsSafeUnzipLimits() to zip.ts so exportBuffer() can warn
upfront, using the same cap import enforces, instead of drifting.
- Add #platform/server/memory (default/api/electron) exposing
os.freemem() on node/electron and null in the browser, per the existing
platform-conditional-export pattern. exportBuffer() warns if the
uncompressed db.sqlite is bigger than currently-free memory; skipped
entirely when the platform has no such API.
- Surface both warnings in the Export settings screen via <Trans>.
- Add zip.test.ts covering round trip, path traversal, unsafe names,
duplicate entries, and all three size caps.
- Trim zip.ts's file-header comment per review feedback that it was too
verbose to be useful.
* [AI] feat: surface descriptive, translatable errors for unsafe zip imports
UnsafeZipError now carries structured meta (zipReason + entryName/maxSize)
instead of only a free-text English message. The meta rides on
FileDownloadError('zip-too-large', ...) through the download and import-budget
paths, and the desktop-client maps it to detailed, translated messages
(which file, which limit) in getUnsafeZipError(); loot-core's shared
getDownloadError() gets a plain-English equivalent for the headless API.
Why structured meta rather than translating the message itself: i18n must not
live in loot-core (see platform/client/connection/index.ts) - the backend
worker has no i18next instance and doesn't know the user's locale, and a
message translated at throw time would bake a locale into a string that also
feeds the headless @actual-app/api and server logs, which want stable English.
i18next also needs a static key plus interpolation params to extract and look
up translations, so the error must cross the worker boundary as code + params
(zipReason + entryName/maxSize) and be rendered with t() at display time in
the client. The English Error.message is kept for logs and the API.
* Update release note to include safe wrappers
* [AI] fix(loot-core): require db.sqlite and metadata.json from the same archive directory
importBuffer previously located db.sqlite and metadata.json independently,
so an archive with a/db.sqlite and b/metadata.json would pair unrelated
files, and duplicate matches were resolved by archive order. Now prefer the
root-level pair; otherwise both files must come from exactly one shared
directory, and anything ambiguous is rejected as invalid-zip-file.
* [AI] fix(loot-core): restrict safeZip to flat entries so every name is validated
safeZip only ran assertSafeEntryName on top-level keys, but fflate's
Zippable type permits nested directory objects whose child names would
reach zipSync unvalidated. Both callers pass flat records, so narrow the
parameter to Record<string, Uint8Array> and let the compiler rule out
nested input.
* [AI] Enforce append-only migration files in CI
Shipped migrations are append-only: existing installs never re-run
them, so editing one silently forks the database schema across the user
base, and deleting one breaks the migration check for every existing
budget file. The CI migration check now diffs blob hashes against the
merge base to reject edits/deletions, keeps the existing
timestamp-ordering rule, emits advisory PR annotations for migrations
containing DROP/RENAME statements, and fails loudly when git commands
fail instead of passing on empty output.
First of a series that makes clients on different app versions sync
safely against the same budget file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Derive the newest upstream migration id with an explicit max
The ls-tree listing is alphabetical, so taking the last entry only
worked while every migration id has the same digit count.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Post advisory migration warnings as a PR comment
The risky-SQL warnings were only visible as job annotations, and since
the job passes there was no prompt to look at them. The migrations job
now uploads its findings as an artifact, and a new workflow_run workflow
(trusted base-repo context, so it also works for fork PRs whose tokens
are read-only) validates the artifact against a fixed allowlist and
posts/updates a PR comment — removing it again once the warnings are
resolved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* [AI] Align overview numbering with the inline check comments
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* [AI] Attach stable error codes that survive the worker boundary
Rejections from the backend worker reached consumers as an English
message only: the machine-readable reason was stripped when the error
was serialized for postMessage, so API consumers had to regex the prose
to categorize failures.
- Serialize rejected handler errors through a shared helper that lifts
the reason slug onto a `code` field (plus `name`) on the envelope, so
it survives the worker/utility-process boundary. `message` is
unchanged, so the change is purely additive.
- Tag the common connect failures with codes at their throw sites:
init auth failures (network-failure, invalid-password, token-expired)
and api/download-budget, api/load-budget, api/sync, api/bank-sync
(budget-not-found, missing-key, decrypt-failure, ...).
- Harden the error post: if the original error holds a non-cloneable
value (e.g. an Emscripten ErrnoError carrying methods), postMessage
used to throw a DataCloneError in the worker and that clone failure
replaced the real error. Retry with a plain cloneable shape instead.
- Add an integration test driving the real web client/server connection
pair across a structured-clone channel, document the codes in the API
reference, and add a release note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo
* [AI] Remove worker-boundary connection integration test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo
* [AI] Remove prose comment from connection error helpers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo
* [AI] Simplify worker-boundary error posting
- Hoist the three-way reply-envelope dispatch out of the two platform
connection files into a single postErrorReply helper, so the envelope
rules and the DataCloneError fallback live in one place.
- Return APIError envelopes from coerceError unchanged, and drop the
unreachable fallbacks and unused meta field from the cloneable shape.
- Let withErrorCode accept an undefined code, removing the one-off
ternary at the bank-sync throw site.
- Declare the client-side connection error shape once as
ConnectionError instead of twice inline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo
* [AI] Address review feedback on error-code contract wording and types
- Docs and release note: scope the stable-code promise to the common
documented failures, and say errors "usually" carry a message.
- ConnectionError: model APIError (message required) and ServerError
(message may be absent) as separate variants to match the wire shape,
and adapt the sendCatch consumers in SavedFilterMenuButton.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AG6f77cRxhwPFjsNpQRmYo
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-16 06:08:18 +00:00
Matt FiddamanGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
* [AI] Add tooltips to titlebar theme, privacy, and server status icons
Explain what the theme switcher, amount-visibility toggle, and server sync/status indicators do on hover, including what having a server connected means when none is configured yet.
Assisted-by: ClaudeCode:claude-sonnet-5
* [AI] Merge duplicate server status branching in LoggedInUser
Combine serverMessage() and serverTooltip() into a single getServerStatus() resolver so the button label and tooltip stay in sync as server states change.
Assisted-by: ClaudeCode:claude-sonnet-5
* Add in requested line break
* [AI] Enable React Compiler for all monorepo packages with React code
The compiler was previously scoped to desktop-client/src only. Now:
- desktop-client's Vite build also compiles component-library source
(consumed as raw source through the same build), covering web,
desktop, and mobile bundles
- component-library's Storybook and Vitest pipelines apply the compiler
so they exercise the same compiled output
- docs (Docusaurus) enables babel-plugin-react-compiler for the site's
own React components
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdcKq244khwKhedvLCwu7p
* [AI] Declare npm-run-all in component-library so its test script resolves
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdcKq244khwKhedvLCwu7p
* [AI] Generalize React Compiler include to any workspace package source
Match packages/<name>/src instead of enumerating package names, so a
future workspace library with React components is compiled automatically.
Third-party code stays out via the babel plugin's default node_modules
exclude, which applies alongside a custom include.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdcKq244khwKhedvLCwu7p
* [AI] Inline component-library test script instead of using npm-run-all
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdcKq244khwKhedvLCwu7p
* [AI] Drop React Compiler from docs
The docs site is static content with a handful of simple components, so
auto-memoization buys nothing there, and an unscoped babel plugin would
also process Docusaurus theme code and every MDX page. Reverting keeps
the compiler where it pays off: desktop-client and component-library.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PdcKq244khwKhedvLCwu7p
---------
Co-authored-by: Claude <noreply@anthropic.com>
* [AI] Run agent hooks and skills in Codex-based harnesses (ChatGPT desktop app)
The ChatGPT desktop app (and Codex CLI/IDE) never picked up the repo's
agent skills or lifecycle hooks:
- Skills: Codex surfaces scan .agents/skills, not .claude/skills. Add a
symlink mirror so both harness families load the same skill files.
- Hooks: Codex lifecycle hooks are feature-flagged; enable them in the
project .codex/config.toml and document the trust steps (project
.codex layer trust + one-time /hooks trust) required for them to run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMdDFdaErx9dqieTGh8ETg
* [AI] Drop .agents/skills/README.md; document the mirror in AGENTS.md instead
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMdDFdaErx9dqieTGh8ETg
* [AI] Ignore .agents/** in knip like .claude/**
The .agents/skills symlink mirror exposes skill reference files under a
path knip scanned, flagging highlight-element.js as an unused file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMdDFdaErx9dqieTGh8ETg
---------
Co-authored-by: Claude <noreply@anthropic.com>