Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,9 @@ A React hook that manages loading and processing Actual Budget data from a zip f
- `loading`: `boolean` - Loading state
- `error`: `string | null` - Error message if loading failed
- `progress`: `number` - Loading progress (0-100)
- `fetchData`: `(file: File) => Promise<void>` - Function to load data from a file
- `refreshData`: `() => Promise<void>` - Function to reload data from the last loaded file
- `fetchData`: `(file: File, includeOffBudget?: boolean, includeBudgetedTransfers?: boolean, includeAllTransfers?: boolean, overrideCurrencySymbol?: string) => Promise<void>` - Function to load data from a file
- `refreshData`: `(includeOffBudget?: boolean, includeBudgetedTransfers?: boolean, includeAllTransfers?: boolean, overrideCurrencySymbol?: string) => Promise<void>` - Function to reload data from the last loaded file
- `retransformData`: `(includeOffBudget: boolean, includeBudgetedTransfers: boolean, includeAllTransfers: boolean, overrideCurrencySymbol?: string) => void` - Function to re-transform data with new filter settings
- `retry`: `() => Promise<void> | undefined` - Function to retry loading the last file

**Example:**
Expand All @@ -199,10 +200,18 @@ function MyComponent() {

**Usage Pattern:**

1. Call `fetchData(file)` when a user uploads a budget zip file
1. Call `fetchData(file, includeOffBudget, includeBudgetedTransfers, includeAllTransfers, overrideCurrencySymbol)` when a user uploads a budget zip file
2. The hook automatically initializes the database, fetches all data, and transforms it
3. Access the processed `data` once loading completes
4. The hook automatically cleans up the database on component unmount
4. Use `retransformData()` to re-process data when filter toggles change (without reloading from file)
5. The hook automatically cleans up the database on component unmount

**Filter Parameters:**

- `includeOffBudget`: Include transactions from off-budget accounts (default: `false`)
- `includeBudgetedTransfers`: Include transfers between on-budget and off-budget accounts (on→off or off→on) (default: `true`). When `true` but `includeAllTransfers` is `false`, excludes transfers between two on-budget accounts (on→on) and transfers between two off-budget accounts (off→off)
- `includeAllTransfers`: Include all transfers including between two on-budget accounts and between two off-budget accounts (default: `false`). When enabled, automatically enables `includeBudgetedTransfers` and includes ALL transfer types
- `overrideCurrencySymbol`: Override the currency symbol from the database (optional)

### `useAnimatedNumber(target: number, duration?: number, decimals?: number): number`

Expand Down Expand Up @@ -271,7 +280,7 @@ function Settings() {

## Data Transformation Utilities

### `transformToWrappedData(transactions, categories, payees, accounts, year?): WrappedData`
### `transformToWrappedData(transactions, categories, payees, accounts, year?, includeOffBudget?, includeBudgetedTransfers?, includeAllTransfers?, currencySymbol?, budgetData?, groupSortOrders?): WrappedData`

Transforms raw transaction data into a structured `WrappedData` object with all calculated metrics and aggregations.

Expand All @@ -282,6 +291,12 @@ Transforms raw transaction data into a structured `WrappedData` object with all
- `payees`: Array of Payee objects
- `accounts`: Array of Account objects
- `year`: Optional year number (defaults to 2025)
- `includeOffBudget`: Optional boolean to include off-budget transactions (defaults to `false`)
- `includeBudgetedTransfers`: Optional boolean to include transfers between on-budget and off-budget accounts (on→off or off→on) (defaults to `true`). When `true` but `includeAllTransfers` is `false`, excludes transfers between two on-budget accounts (on→on) and transfers between two off-budget accounts (off→off). When `false`, excludes ALL transfers
- `includeAllTransfers`: Optional boolean to include all transfers including between two on-budget accounts and between two off-budget accounts (defaults to `false`). When `true`, automatically enables `includeBudgetedTransfers` and includes ALL transfer types
- `currencySymbol`: Optional currency symbol string (defaults to `'$'`)
- `budgetData`: Optional array of budget data for budget comparison
- `groupSortOrders`: Optional map of category group sort orders

**Returns:** `WrappedData` object containing:

Expand Down Expand Up @@ -312,7 +327,17 @@ const categories = await getCategories();
const payees = await getPayees();
const accounts = await getAccounts();

const wrappedData = transformToWrappedData(transactions, categories, payees, accounts, 2025);
const wrappedData = transformToWrappedData(
transactions,
categories,
payees,
accounts,
2025,
false, // includeOffBudget
true, // includeBudgetedTransfers (default: true)
false, // includeAllTransfers
'$' // currencySymbol
);

console.log(wrappedData.totalIncome);
console.log(wrappedData.topCategories);
Expand All @@ -322,8 +347,15 @@ console.log(wrappedData.monthlyData);
**Important Notes:**

- Automatically filters transactions to the specified year (defaults to 2025)
- Excludes transfer transactions (transactions where the payee has a `transfer_acct` field)
- Excludes off-budget transactions (transactions from accounts where `offbudget` is true)
- **Transfer Filtering**:
- By default (`includeBudgetedTransfers = true`, `includeAllTransfers = false`): Includes transfers between on-budget and off-budget accounts (on→off or off→on). Excludes transfers between two on-budget accounts (on→on) and transfers between two off-budget accounts (off→off)
- When `includeBudgetedTransfers = false`: Excludes ALL transfers regardless of account types
- When `includeAllTransfers = true`: Includes ALL transfers (on→on, on→off, off→on, off→off). Automatically enables `includeBudgetedTransfers`
- **Transfer Labeling**: When transfers are included:
- **Categories**: Transfers without categories are automatically labeled with the destination account name (e.g., "Transfer: Savings Account") instead of showing as "Uncategorized"
- **Payees**: Transfer payees are automatically labeled with the destination account name (e.g., "Transfer: Savings Account") instead of showing as "Unknown"
- Multiple transfers to the same account are grouped together in both categories and payees
- **Off-Budget Filtering**: Excludes off-budget transactions by default. Set `includeOffBudget = true` to include them
- Excludes starting balance transactions (transactions where payee name is "Starting Balance")
- Handles deleted categories/payees (marks with "deleted: " prefix)
- Converts amounts from cents to dollars
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ A beautiful year-in-review application for your Actual Budget data, styled like
- ⌨️ **Keyboard Navigation**: Navigate with arrow keys (← →)
- 📱 **Responsive Design**: Works on desktop and mobile devices
- 🧪 **Well Tested**: Unit tests with Vitest and E2E tests with Playwright
- ⚙️ **Flexible Filtering**: Toggle to include/exclude off-budget transactions, on-budget transfers, and cross-account transfers
- 💱 **Currency Override**: Change currency display without modifying your budget data
- 🔄 **Smart Transfer Labeling**: Transfers are automatically labeled with destination account names (e.g., "Transfer: Savings Account") in both categories and payees lists, instead of showing as uncategorized or unknown

## Prerequisites

Expand Down Expand Up @@ -80,7 +83,12 @@ yarn preview

1. **Upload Your Budget**: Click "Choose File" and select your exported Actual Budget `.zip` file
2. **Wait for Processing**: The app will extract and process your 2025 budget data (this happens entirely in your browser)
3. **Navigate Through Pages**: Use the Next/Previous buttons or arrow keys (← →) to navigate through the wrapped pages
3. **Adjust Settings** (optional): Click the settings menu (☰) in the top-right corner to:
- Include/exclude off-budget transactions
- Include/exclude budgeted transfers (transfers between on-budget and off-budget accounts)
- Include all transfers (includes all transfer types, including between two on-budget accounts)
- Override currency display
4. **Navigate Through Pages**: Use the Next/Previous buttons or arrow keys (← →) to navigate through the wrapped pages

## Technology Stack

Expand Down
80 changes: 69 additions & 11 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { ConnectionForm } from './components/ConnectionForm';
import { CurrencySelector } from './components/CurrencySelector';
import { Navigation } from './components/Navigation';
import { OffBudgetToggle } from './components/OffBudgetToggle';
import { AllTransfersToggle } from './components/OffBudgetTransfersToggle';
import { OnBudgetTransfersToggle } from './components/OnBudgetTransfersToggle';
import { AccountBreakdownPage } from './components/pages/AccountBreakdownPage';
import { BudgetVsActualPage } from './components/pages/BudgetVsActualPage';
import { CalendarHeatmapPage } from './components/pages/CalendarHeatmapPage';
Expand All @@ -17,6 +19,7 @@ import { SavingsRatePage } from './components/pages/SavingsRatePage';
import { SpendingVelocityPage } from './components/pages/SpendingVelocityPage';
import { TopCategoriesPage } from './components/pages/TopCategoriesPage';
import { TopPayeesPage } from './components/pages/TopPayeesPage';
import { SettingsMenu } from './components/SettingsMenu';
import { useActualData } from './hooks/useActualData';
import { useLocalStorage } from './hooks/useLocalStorage';

Expand All @@ -38,30 +41,74 @@ const PAGES = [
function App() {
const [currentPage, setCurrentPage] = useState(0);
const [includeOffBudget, setIncludeOffBudget] = useLocalStorage('includeOffBudget', false);
const [includeOnBudgetTransfers, setIncludeOnBudgetTransfers] = useLocalStorage(
'includeOnBudgetTransfers',
true, // Default to true (on by default)
);
const [includeAllTransfers, setIncludeAllTransfers] = useLocalStorage(
'includeAllTransfers',
false,
);
const [overrideCurrency, setOverrideCurrency] = useLocalStorage<string | null>(
'overrideCurrency',
null,
);
const { data, loading, error, progress, fetchData, retransformData, retry } = useActualData();

const handleConnect = async (file: File) => {
await fetchData(file, includeOffBudget, overrideCurrency || undefined);
await fetchData(
file,
includeOffBudget,
includeOnBudgetTransfers,
includeAllTransfers,
overrideCurrency || undefined,
);
};

const handleToggle = (value: boolean) => {
const handleOffBudgetToggle = (value: boolean) => {
setIncludeOffBudget(value);
retransformData(value, overrideCurrency || undefined);
retransformData(
value,
includeOnBudgetTransfers,
includeAllTransfers,
overrideCurrency || undefined,
);
};

const handleOnBudgetTransfersToggle = (value: boolean) => {
setIncludeOnBudgetTransfers(value);
retransformData(includeOffBudget, value, includeAllTransfers, overrideCurrency || undefined);
};

const handleAllTransfersToggle = (value: boolean) => {
setIncludeAllTransfers(value);
// When "Include All Transfers" is enabled, automatically enable "Include Budgeted Transfers"
const effectiveIncludeOnBudgetTransfers = value ? true : includeOnBudgetTransfers;
if (value && !includeOnBudgetTransfers) {
setIncludeOnBudgetTransfers(true);
}
retransformData(
includeOffBudget,
effectiveIncludeOnBudgetTransfers, // If includeAllTransfers is true, also enable on-budget transfers
value,
overrideCurrency || undefined,
);
};

const handleCurrencyChange = (currencySymbol: string) => {
// If the selected currency matches the default from database, clear the override
const defaultCurrency = data?.currencySymbol || '$';
if (currencySymbol === defaultCurrency) {
setOverrideCurrency(null);
retransformData(includeOffBudget, undefined);
retransformData(includeOffBudget, includeOnBudgetTransfers, includeAllTransfers, undefined);
} else {
setOverrideCurrency(currencySymbol);
retransformData(includeOffBudget, currencySymbol);
retransformData(
includeOffBudget,
includeOnBudgetTransfers,
includeAllTransfers,
currencySymbol,
);
}
};

Expand Down Expand Up @@ -116,12 +163,23 @@ function App() {

return (
<div className={styles.app}>
<OffBudgetToggle includeOffBudget={includeOffBudget} onToggle={handleToggle} />
<CurrencySelector
selectedCurrency={effectiveCurrency}
defaultCurrency={data.currencySymbol || '$'}
onCurrencyChange={handleCurrencyChange}
/>
<SettingsMenu>
<OffBudgetToggle includeOffBudget={includeOffBudget} onToggle={handleOffBudgetToggle} />
<OnBudgetTransfersToggle
includeOnBudgetTransfers={includeAllTransfers || includeOnBudgetTransfers}
onToggle={handleOnBudgetTransfersToggle}
disabled={includeAllTransfers} // Disable when "Include All Transfers" is enabled
/>
<AllTransfersToggle
includeAllTransfers={includeAllTransfers}
onToggle={handleAllTransfersToggle}
/>
<CurrencySelector
selectedCurrency={effectiveCurrency}
defaultCurrency={data.currencySymbol || '$'}
onCurrencyChange={handleCurrencyChange}
/>
</SettingsMenu>
{isIntroPage ? (
<IntroPage
data={
Expand Down
10 changes: 9 additions & 1 deletion src/components/CurrencySelector.module.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.selector {
position: fixed;
top: calc(2rem + 3.5rem); /* Position below OffBudgetToggle (2rem + ~3.5rem height) */
top: calc(
2rem + 7rem
); /* Position below OffBudgetToggle and OnBudgetTransfersToggle (2rem + ~3.5rem + ~3.5rem) */
right: 2rem;
z-index: 1001;
background: rgba(0, 0, 0, 0.8);
Expand All @@ -16,13 +18,17 @@
.label {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.75rem;
}

.labelText {
font-size: 0.85rem;
opacity: 0.9;
white-space: nowrap;
text-align: left;
flex: 1;
}

.select {
Expand All @@ -36,6 +42,8 @@
outline: none;
transition: all 0.2s;
min-width: 120px;
flex-shrink: 0;
margin-left: auto;
}

.select:hover {
Expand Down
6 changes: 6 additions & 0 deletions src/components/OffBudgetToggle.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.75rem;
}

Expand All @@ -32,6 +34,8 @@
border-radius: 12px;
cursor: pointer;
transition: background-color 0.3s;
flex-shrink: 0;
margin-left: auto;
}

.toggleSwitch.active {
Expand All @@ -56,4 +60,6 @@
.toggleText {
font-size: 0.85rem;
opacity: 0.9;
text-align: left;
flex: 1;
}
65 changes: 65 additions & 0 deletions src/components/OffBudgetTransfersToggle.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
.toggle {
position: fixed;
top: calc(2rem + 3.5rem); /* Position below OffBudgetToggle (2rem top + ~3.5rem height) */
right: 2rem;
z-index: 1001;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
padding: 0.75rem 1.25rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 0.75rem;
color: white;
font-size: 0.9rem;
font-weight: 500;
}

.toggleLabel {
user-select: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 0.75rem;
}

.toggleSwitch {
position: relative;
width: 44px;
height: 24px;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
cursor: pointer;
transition: background-color 0.3s;
flex-shrink: 0;
margin-left: auto;
}

.toggleSwitch.active {
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
}

.toggleSlider {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.3s;
}

.toggleSwitch.active .toggleSlider {
transform: translateX(20px);
}

.toggleText {
font-size: 0.85rem;
opacity: 0.9;
text-align: left;
flex: 1;
}
Loading