diff --git a/.github/RELEASE.md b/.github/RELEASE.md new file mode 100644 index 0000000..21ed6a4 --- /dev/null +++ b/.github/RELEASE.md @@ -0,0 +1,103 @@ +# Release Process + +This document describes the automated release workflow for Platito. + +## Overview + +Releases are managed through two GitHub Actions workflows that together handle the full lifecycle: branch creation, changelog update, PR to main, GitHub Release creation, and backport to develop. + +``` +develop ──► release/X.Y.Z ──► main ──► backport/X.Y.Z ──► develop + (PR) (Release) (PR) +``` + +## Prerequisites + +Before running a release for the first time, enable **Allow auto-merge** in the repository: + +> GitHub → Settings → General → Pull Requests → Allow auto-merge + +Without this, the workflows will still create the PRs but they will need to be merged manually. + +## How to trigger a release + +1. Go to **Actions → Create Release → Run workflow** +2. Enter the version number following [Semantic Versioning](https://semver.org/) (e.g. `1.9.0`) +3. Click **Run workflow** + +Everything else is automatic. + +## What happens step by step + +### Phase 1 — `release.yml` (triggered manually) + +| Step | What it does | +|---|---| +| Checkout develop | Starts from the current state of `develop` | +| Create release branch | Creates `release/X.Y.Z` from `develop` | +| Bump version | Updates `package.json` and `package-lock.json` | +| Update CHANGELOG.md | Moves `[Unreleased]` content to `[X.Y.Z] - YYYY-MM-DD` and updates comparison links | +| Commit & push | Commits as `chore: release X.Y.Z` and pushes the branch | +| Create PR | Opens `release/X.Y.Z → main` with title `release: X.Y.Z` | +| Auto-merge | Enables auto-merge; the PR merges once the PR check passes | + +### Phase 2 — `release-finalize.yml` (triggered automatically when the release PR merges into main) + +| Step | What it does | +|---|---| +| Create GitHub Release | Tags the commit and creates a release with notes extracted from CHANGELOG.md | +| Create backport branch | Creates `backport/X.Y.Z` from `develop` and merges `release/X.Y.Z` into it | +| Create backport PR | Opens `backport/X.Y.Z → develop` | +| Auto-merge | Enables auto-merge on the backport PR | + +## CHANGELOG convention + +The `[Unreleased]` section at the top of `CHANGELOG.md` is where all changes are documented during development. When a release is triggered, the automation: + +1. Renames `[Unreleased]` to `[X.Y.Z] - YYYY-MM-DD` +2. Adds a fresh empty `[Unreleased]` section above it +3. Updates the comparison links at the bottom of the file + +During development, add entries under `[Unreleased]` using the standard categories: + +```markdown +## [Unreleased] + +### Added +- New feature description + +### Changed +- Changed behavior description + +### Fixed +- Bug fix description +``` + +## Conflict handling + +If the backport merge has conflicts (e.g. because `develop` has new CHANGELOG entries), the workflow will: + +- Skip the auto-merge +- Print a warning in the Actions log +- Leave the backport PR creation incomplete + +In this case, create the backport manually: + +```bash +git checkout develop +git pull origin develop +git checkout -b backport/X.Y.Z +git merge origin/release/X.Y.Z +# resolve conflicts +git push origin backport/X.Y.Z +gh pr create --base develop --head backport/X.Y.Z --title "chore: backport X.Y.Z to develop" +``` + +## Files involved + +| File | Purpose | +|---|---| +| [workflows/release.yml](workflows/release.yml) | Manual workflow — creates release branch and PR | +| [workflows/release-finalize.yml](workflows/release-finalize.yml) | Automatic workflow — GitHub Release and backport | +| [scripts/update-changelog.js](scripts/update-changelog.js) | Updates CHANGELOG.md during release creation | +| [scripts/extract-release-notes.js](scripts/extract-release-notes.js) | Extracts version notes for the GitHub Release | diff --git a/.github/scripts/extract-release-notes.js b/.github/scripts/extract-release-notes.js new file mode 100644 index 0000000..f45a399 --- /dev/null +++ b/.github/scripts/extract-release-notes.js @@ -0,0 +1,15 @@ +// Usage: node extract-release-notes.js +const fs = require('fs'); + +const version = process.argv[2]; +if (!version) { + console.error('Usage: node extract-release-notes.js '); + process.exit(1); +} + +const content = fs.readFileSync('CHANGELOG.md', 'utf8'); +const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const pattern = new RegExp(`## \\[${escaped}\\][^\\n]*\\n(.*?)(?=\\n## \\[|$)`, 's'); +const m = content.match(pattern); + +process.stdout.write(m ? m[1].trim() : ''); diff --git a/.github/scripts/update-changelog.js b/.github/scripts/update-changelog.js new file mode 100644 index 0000000..dc16a68 --- /dev/null +++ b/.github/scripts/update-changelog.js @@ -0,0 +1,41 @@ +// Usage: VERSION=1.9.0 node update-changelog.js +const fs = require('fs'); + +const version = process.env.VERSION; +const date = new Date().toISOString().split('T')[0]; +const repo = 'https://github.com/tomas2305/platito'; + +if (!version) { + console.error('VERSION environment variable is required'); + process.exit(1); +} + +let content = fs.readFileSync('CHANGELOG.md', 'utf8'); + +// Find previous version before modifying content +const prevMatch = content.match(/^## \[(\d+\.\d+\.\d+)\]/m); +const prevVersion = prevMatch?.[1] ?? null; + +// Insert new version header right after [Unreleased] +content = content.replace( + /^## \[Unreleased\]/m, + `## [Unreleased]\n\n## [${version}] - ${date}` +); + +// Build the new version link +const versionLink = prevVersion + ? `[${version}]: ${repo}/compare/${prevVersion}...${version}` + : `[${version}]: ${repo}/releases/tag/${version}`; + +// Update [Unreleased] link and insert version link below it +if (/^\[Unreleased\]:/m.test(content)) { + content = content.replace( + /^\[Unreleased\]:.*$/m, + `[Unreleased]: ${repo}/compare/${version}...HEAD\n${versionLink}` + ); +} else { + content += `\n[Unreleased]: ${repo}/compare/${version}...HEAD\n${versionLink}\n`; +} + +fs.writeFileSync('CHANGELOG.md', content); +console.log(`CHANGELOG updated: ${version} (previous: ${prevVersion ?? 'none'})`); diff --git a/.github/workflows/release-finalize.yml b/.github/workflows/release-finalize.yml new file mode 100644 index 0000000..7599396 --- /dev/null +++ b/.github/workflows/release-finalize.yml @@ -0,0 +1,97 @@ +name: Finalize Release + +on: + pull_request: + types: [closed] + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + finalize: + if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/') + runs-on: ubuntu-latest + + steps: + - name: Extract version + id: meta + env: + BRANCH: ${{ github.event.pull_request.head.ref }} + run: echo "version=${BRANCH#release/}" >> $GITHUB_OUTPUT + + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Extract release notes + env: + VERSION: ${{ steps.meta.outputs.version }} + run: node .github/scripts/extract-release-notes.js "$VERSION" > /tmp/release-notes.txt + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + gh release create "$VERSION" \ + --title "v$VERSION" \ + --notes-file /tmp/release-notes.txt + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create backport branch and merge + id: backport + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + git fetch origin develop + git checkout -b backport/$VERSION origin/develop + + if git merge --no-edit origin/release/$VERSION; then + echo "merged=true" >> $GITHUB_OUTPUT + else + echo "::warning::Merge conflict on backport/$VERSION — resolve it manually." + git merge --abort + echo "merged=false" >> $GITHUB_OUTPUT + fi + + - name: Push backport branch + if: steps.backport.outputs.merged == 'true' + env: + VERSION: ${{ steps.meta.outputs.version }} + run: git push origin backport/$VERSION + + - name: Create backport PR + if: steps.backport.outputs.merged == 'true' + id: backport-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + URL=$(gh pr create \ + --base develop \ + --head backport/$VERSION \ + --title "chore: backport $VERSION to develop" \ + --body "Automated backport of release $VERSION to develop.") + echo "url=$URL" >> $GITHUB_OUTPUT + + - name: Enable auto-merge on backport + if: steps.backport.outputs.merged == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + PR_URL: ${{ steps.backport-pr.outputs.url }} + run: | + gh pr merge backport/$VERSION --auto --merge \ + || echo "::warning::Auto-merge unavailable — merge $PR_URL manually." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e0e4ac8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 1.9.0)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + create-release: + runs-on: ubuntu-latest + env: + VERSION: ${{ inputs.version }} + + steps: + - uses: actions/checkout@v4 + with: + ref: develop + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Create release branch + run: git checkout -b release/$VERSION + + - name: Bump version in package.json + run: npm version $VERSION --no-git-tag-version + + - name: Update CHANGELOG.md + run: node .github/scripts/update-changelog.js + + - name: Commit and push + run: | + git add package.json package-lock.json CHANGELOG.md + git commit -m "chore: release $VERSION" + git push origin release/$VERSION + + - name: Create PR to main + id: pr + run: | + URL=$(gh pr create \ + --base main \ + --head release/$VERSION \ + --title "release: $VERSION" \ + --body "Automated release PR for v$VERSION. See [CHANGELOG.md](CHANGELOG.md) for details.") + echo "url=$URL" >> $GITHUB_OUTPUT + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Enable auto-merge + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_URL: ${{ steps.pr.outputs.url }} + run: | + gh pr merge release/$VERSION --auto --merge \ + || echo "::warning::Auto-merge unavailable — enable it in repo Settings → Pull Requests, or merge $PR_URL manually." diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a4e38..fbc1557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.9.0] - 2026-05-26 + +### Added +- Bulk import page for loading multiple transactions and transfers at once via a structured JSON payload +- AI-assisted import prompt that embeds the user's current accounts, categories and tags so an AI agent can generate correctly referenced IDs +- Real-time JSON validation panel showing record counts, affected accounts, field errors, and duplicate warnings before committing the import +- Automated release workflow using GitHub Actions: triggers from `develop`, creates a versioned release branch, bumps `package.json`, updates `CHANGELOG.md`, opens a PR to `main`, and auto-merges when checks pass +- Automated post-merge workflow that creates a GitHub Release with notes extracted from `CHANGELOG.md` and opens a backport PR to `develop` + ## [1.8.0] - 2026-05-01 ### Added diff --git a/index.html b/index.html index 1fd6771..684f045 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,7 @@ - - - - + Platito diff --git a/package-lock.json b/package-lock.json index 8db5199..e30a23c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "platito", "version": "1.3.3", "dependencies": { + "@fontsource-variable/manrope": "^5.2.8", "@mantine/carousel": "^8.3.9", "@mantine/core": "^8.3.9", "@mantine/dates": "^8.3.9", @@ -2863,6 +2864,15 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz", + "integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.9.15", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", diff --git a/package.json b/package.json index cd0221e..97d76c0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/manrope": "^5.2.8", "@mantine/carousel": "^8.3.9", "@mantine/core": "^8.3.9", "@mantine/dates": "^8.3.9", diff --git a/src/App.tsx b/src/App.tsx index b5b6d73..6e2c0d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { CategoriesPage } from './pages/CategoriesPage'; import { TagsPage } from './pages/TagsPage'; import { TransactionsPage } from './pages/TransactionsPage'; import { TransfersPage } from './pages/TransfersPage'; +import { BulkImportPage } from './pages/BulkImportPage'; import { initializeSettings } from './stores/settingsStore'; import { ensureDefaultCategories } from './stores/categoriesStore'; import './App.css'; @@ -31,6 +32,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index cc1e9ba..328b6a3 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -9,6 +9,7 @@ import { IconSettings, IconArrowsExchange, IconBrandGithub, + IconTableImport, } from '@tabler/icons-react'; import { Stack, UnstyledButton } from '@mantine/core'; import classes from './Navbar.module.css'; @@ -41,6 +42,7 @@ const navigationLinks = [ { icon: IconWallet, label: 'Accounts', path: '/accounts' }, { icon: IconFolder, label: 'Categories', path: '/categories' }, { icon: IconTag, label: 'Tags', path: '/tags' }, + { icon: IconTableImport, label: 'Import', path: '/bulk-import' }, { icon: IconSettings, label: 'Settings', path: '/settings' }, ]; diff --git a/src/main.tsx b/src/main.tsx index 0e4b216..a33431e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import { ColorSchemeScript, MantineProvider } from '@mantine/core' import { Notifications } from '@mantine/notifications' import { registerSW } from 'virtual:pwa-register' +import '@fontsource-variable/manrope/index.css' import '@mantine/core/styles.css' import '@mantine/dates/styles.css' import '@mantine/notifications/styles.css' diff --git a/src/pages/BulkImportPage.tsx b/src/pages/BulkImportPage.tsx new file mode 100644 index 0000000..9c8dea2 --- /dev/null +++ b/src/pages/BulkImportPage.tsx @@ -0,0 +1,478 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Title, + Text, + Textarea, + Button, + Stack, + Group, + Paper, + List, + ThemeIcon, + Badge, + Divider, + Box, + Center, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconAlertTriangle, IconX, IconCopy, IconUpload, IconWallet } from '@tabler/icons-react'; +import { getAllAccounts } from '../stores/accountsStore'; +import { getAllCategories } from '../stores/categoriesStore'; +import { getAllTags } from '../stores/tagsStore'; +import { getAllTransactions } from '../stores/transactionsStore'; +import { getDB } from '../db'; +import type { Account, Category, Tag, Transaction } from '../types'; +import promptTemplate from '../prompts/bulk-import.md?raw'; + +interface BulkTransaction { + accountId: number; + categoryId: number; + type: 'expense' | 'income'; + amount: number; + currency: 'ARS' | 'USD_BLUE' | 'USD_MEP' | 'USDT'; + date: string; + description: string; + tagIds?: number[]; +} + +interface BulkTransfer { + fromAccountId: number; + toAccountId: number; + amount: number; + convertedAmount: number; + exchangeRate: number; + date: string; + description?: string; +} + +interface BulkPayload { + transactions?: BulkTransaction[]; + transfers?: BulkTransfer[]; +} + +interface ValidationResult { + ok: boolean; + transactions: number; + transfers: number; + affectedAccountIds: number[]; + errors: string[]; + warnings: string[]; + parsed: BulkPayload | null; +} + +const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; +const VALID_CURRENCIES = ['ARS', 'USD_BLUE', 'USD_MEP', 'USDT']; +const VALID_TYPES = ['expense', 'income']; + +function validatePayload( + input: string, + accounts: Account[], + categories: Category[], + tags: Tag[], + existingTransactions: Transaction[], +): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + let parsed: BulkPayload; + try { + parsed = JSON.parse(input); + } catch { + return { ok: false, transactions: 0, transfers: 0, affectedAccountIds: [], errors: ['Invalid JSON'], warnings: [], parsed: null }; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { ok: false, transactions: 0, transfers: 0, affectedAccountIds: [], errors: ['JSON must be an object'], warnings: [], parsed: null }; + } + + if (!parsed.transactions && !parsed.transfers) { + return { + ok: false, + transactions: 0, + transfers: 0, + affectedAccountIds: [], + errors: ['JSON must have at least "transactions" or "transfers"'], + warnings: [], + parsed: null, + }; + } + + const accountMap = new Map(accounts.map(a => [a.id!, a])); + const categoryMap = new Map(categories.map(c => [c.id!, c])); + const tagIds = new Set(tags.map(t => t.id!)); + const affectedAccountIds = new Set(); + + // Validate transactions + const txs = parsed.transactions ?? []; + if (!Array.isArray(txs)) { + errors.push('"transactions" must be an array'); + } else { + txs.forEach((t, i) => { + const prefix = `transactions[${i}]`; + + if (!accountMap.has(t.accountId)) { + errors.push(`${prefix}: accountId ${t.accountId} does not exist`); + } else { + affectedAccountIds.add(t.accountId); + } + + const cat = categoryMap.get(t.categoryId); + if (!cat) { + errors.push(`${prefix}: categoryId ${t.categoryId} does not exist`); + } else if (cat.type !== t.type) { + errors.push(`${prefix}: type "${t.type}" does not match category "${cat.name}" (${cat.type})`); + } + + if (!VALID_TYPES.includes(t.type)) { + errors.push(`${prefix}: type "${t.type}" is invalid (must be "expense" or "income")`); + } + + if (!VALID_CURRENCIES.includes(t.currency)) { + errors.push(`${prefix}: currency "${t.currency}" is invalid`); + } else { + const account = accountMap.get(t.accountId); + if (account && account.currency !== t.currency) { + errors.push(`${prefix}: currency "${t.currency}" does not match account "${account.name}" (${account.currency})`); + } + } + + if (typeof t.amount !== 'number' || t.amount <= 0) { + errors.push(`${prefix}: amount must be a positive number`); + } + + if (!DATE_REGEX.test(t.date)) { + errors.push(`${prefix}: date "${t.date}" is invalid (use YYYY-MM-DD)`); + } + + if (t.tagIds && Array.isArray(t.tagIds)) { + t.tagIds.forEach(tid => { + if (!tagIds.has(tid)) { + errors.push(`${prefix}: tagId ${tid} does not exist`); + } + }); + } + + const isDuplicate = existingTransactions.some( + existing => + existing.accountId === t.accountId && + existing.amount === t.amount && + existing.date === t.date, + ); + if (isDuplicate) { + warnings.push(`${prefix}: possible duplicate (same date, amount and account)`); + } + }); + } + + // Validate transfers + const transfers = parsed.transfers ?? []; + if (!Array.isArray(transfers)) { + errors.push('"transfers" must be an array'); + } else { + transfers.forEach((t, i) => { + const prefix = `transfers[${i}]`; + + if (!accountMap.has(t.fromAccountId)) { + errors.push(`${prefix}: fromAccountId ${t.fromAccountId} does not exist`); + } else { + affectedAccountIds.add(t.fromAccountId); + } + + if (!accountMap.has(t.toAccountId)) { + errors.push(`${prefix}: toAccountId ${t.toAccountId} does not exist`); + } else { + affectedAccountIds.add(t.toAccountId); + } + + if (t.fromAccountId === t.toAccountId) { + errors.push(`${prefix}: fromAccountId and toAccountId cannot be the same`); + } + + if (typeof t.amount !== 'number' || t.amount <= 0) { + errors.push(`${prefix}: amount must be a positive number`); + } + if (typeof t.convertedAmount !== 'number' || t.convertedAmount <= 0) { + errors.push(`${prefix}: convertedAmount must be a positive number`); + } + if (typeof t.exchangeRate !== 'number' || t.exchangeRate <= 0) { + errors.push(`${prefix}: exchangeRate must be a positive number`); + } + + if (!DATE_REGEX.test(t.date)) { + errors.push(`${prefix}: date "${t.date}" is invalid (use YYYY-MM-DD)`); + } + }); + } + + return { + ok: errors.length === 0, + transactions: Array.isArray(txs) ? txs.length : 0, + transfers: Array.isArray(transfers) ? transfers.length : 0, + affectedAccountIds: [...affectedAccountIds], + errors, + warnings, + parsed: errors.length === 0 ? parsed : null, + }; +} + +export function BulkImportPage() { + const [jsonInput, setJsonInput] = useState(''); + const [validation, setValidation] = useState(null); + const [importing, setImporting] = useState(false); + const [accounts, setAccounts] = useState([]); + const [categories, setCategories] = useState([]); + const [tags, setTags] = useState([]); + const [existingTransactions, setExistingTransactions] = useState([]); + + useEffect(() => { + Promise.all([getAllAccounts(), getAllCategories(), getAllTags(), getAllTransactions()]).then( + ([accs, cats, tgs, txs]) => { + setAccounts(accs); + setCategories(cats); + setTags(tgs); + setExistingTransactions(txs); + }, + ); + }, []); + + const handleJsonChange = useCallback( + (value: string) => { + setJsonInput(value); + if (!value.trim()) { + setValidation(null); + return; + } + setValidation(validatePayload(value, accounts, categories, tags, existingTransactions)); + }, + [accounts, categories, tags, existingTransactions], + ); + + const generatePrompt = () => { + const accountsContext = accounts + .filter(a => !a.isArchived) + .map(a => `- ID ${a.id}: "${a.name}" (${a.currency})`) + .join('\n'); + + const categoriesContext = categories + .map(c => `- ID ${c.id}: "${c.name}" (type: ${c.type})`) + .join('\n'); + + const tagsContext = tags.length + ? tags.map(t => `- ID ${t.id}: "${t.name}"`).join('\n') + : '(no tags)'; + + return promptTemplate + .replace('{{ACCOUNTS}}', accountsContext) + .replace('{{CATEGORIES}}', categoriesContext) + .replace('{{TAGS}}', tagsContext); + }; + + const handleCopyPrompt = async () => { + try { + await navigator.clipboard.writeText(generatePrompt()); + notifications.show({ + title: 'Prompt copied', + message: 'Paste it into Claude or any agent you use', + color: 'blue', + autoClose: 3000, + }); + } catch { + notifications.show({ title: 'Error', message: 'Could not copy to clipboard', color: 'red' }); + } + }; + + const handleImport = async () => { + if (!validation?.parsed) return; + setImporting(true); + try { + const db = getDB(); + const { transactions = [], transfers = [] } = validation.parsed; + const now = new Date().toISOString(); + + await db.transaction('rw', [db.transactions, db.transfers], async () => { + if (transactions.length) { + await db.transactions.bulkAdd( + transactions.map(t => ({ ...t, tagIds: t.tagIds ?? [] })), + ); + } + if (transfers.length) { + await db.transfers.bulkAdd( + transfers.map(t => ({ ...t, createdAt: now, updatedAt: now })), + ); + } + }); + + notifications.show({ + title: 'Import successful', + message: `${transactions.length} transactions and ${transfers.length} transfers added`, + color: 'green', + autoClose: 5000, + }); + + setJsonInput(''); + setValidation(null); + getAllTransactions().then(setExistingTransactions); + } catch (err) { + notifications.show({ + title: 'Import failed', + message: err instanceof Error ? err.message : 'Unknown error', + color: 'red', + }); + } finally { + setImporting(false); + } + }; + + const accountMap = new Map(accounts.map(a => [a.id!, a])); + + return ( +
+ + + + Bulk Import + + Use an AI agent to generate the JSON, then paste it here to import transactions and transfers. + + + + + + Step 1 — Copy the prompt + + + + The prompt includes your current accounts, categories and tags so the agent uses the correct IDs. + + + + + Step 2 — Paste the JSON +