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
39 changes: 11 additions & 28 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,12 @@
# Milestones ICS Export - Implementation Tasks
# Wallet CSV/JSON Export Implementation

## ✅ Completed
- [x] Explored repository structure and understood codebase patterns
- [x] Gathered requirements and created implementation plan
- [x] Plan approved by user
- [x] Step 1: Created `src/lib/icsExport.ts`
- [x] `escapeICSText(value: string): string` - Escape `\`, `;`, `,`, `\n` per RFC 5545
- [x] `milestoneStatusToICS(status: string): string` - Map milestone status to ICS STATUS
- [x] `formatICSDDate(date: Date): string` - Format Date to YYYYMMDD
- [x] `milestonesToICS(milestones: Milestone[]): string` - Build VCALENDAR + VEVENT blocks
- [x] `downloadMilestonesICS(milestones: Milestone[], filename?: string): void` - Download trigger
- [x] Step 2: Created `src/lib/__tests__/icsExport.test.ts`
- [x] Text escaping tests (backslash, semicolon, comma, newline, combined)
- [x] ICS status mapping tests
- [x] Date formatting tests (YYYYMMDD, padding, edge months)
- [x] ICS generation tests (empty array, with/without due dates, structure, VEVENT fields)
- [x] Download trigger tests (Blob, URL lifecycle, anchor interaction)
- [x] Step 3: Created `docs/lib/ics-export.md`
- [x] Purpose, usage, format details, escaping rules, edge cases
- [x] Step 4: Updated `src/app/milestones/page.tsx`
- [x] Added "Add to Calendar" button in the toolbar area
- [x] Wired up to `downloadMilestonesICS(sortedMilestones)`
- [x] Step 5: Verify
- [x] Dependencies installed (`npm install`)
- [x] 49/49 tests pass for `icsExport.test.ts`
- [ ] Lint check - running
- [ ] Full test suite
- [ ] Build
## Steps

- [x] Create TODO.md
- [x] 1. Create `src/lib/exportWallet.ts` - export module with csvEscape, walletItemsToCsv, walletItemsToJson, triggerDownload, downloadWalletCsv, downloadWalletJson
- [x] 2. Edit `src/components/wallet/WalletBulkToolbar.tsx` - split `onExport` into `onExportCsv` and `onExportJson`, add two buttons
- [ ] 3. Edit `src/app/wallet/page.tsx` - add CSV/JSON export handlers, update toolbar props
- [ ] 4. Create `src/lib/__tests__/exportWallet.test.ts` - comprehensive tests
- [ ] 5. Edit `src/components/wallet/__tests__/WalletBulkToolbar.test.tsx` - update tests for new callbacks
- [ ] 6. Edit `src/app/wallet/__tests__/page.test.tsx` - update export tests
- [ ] 7. Run `npm run lint`, `npm test`, `npm run build`
37 changes: 34 additions & 3 deletions src/app/wallet/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,21 @@ jest.mock('@/lib/repository', () => ({
deleteWalletItems: jest.fn(),
}));

// Mock export functions to avoid Blob/URL in jsdom
jest.mock('@/lib/exportWallet', () => ({
downloadWalletCsv: jest.fn(),
downloadWalletJson: jest.fn(),
}));

const mockListWalletItems = jest.mocked(listWalletItems);
const mockSaveWalletItem = jest.mocked(saveWalletItem);
const mockUpdateWalletItem = jest.mocked(updateWalletItem);
const mockDeleteWalletItems = jest.mocked(deleteWalletItems);

import { downloadWalletCsv, downloadWalletJson } from '@/lib/exportWallet';
const mockDownloadWalletCsv = jest.mocked(downloadWalletCsv);
const mockDownloadWalletJson = jest.mocked(downloadWalletJson);

const renderWithProviders = (ui: React.ReactElement) => {
return render(
<PreferencesProvider>
Expand Down Expand Up @@ -125,16 +135,37 @@ describe('WalletPage Integration & Bulk Selection', () => {
expect(itemCheckbox).not.toBeChecked();
});

it('exports selected items and triggers success toast', () => {
it('exports selected items as CSV and triggers success toast', () => {
mockListWalletItems.mockReturnValue(SAMPLE_WALLET_ITEMS);
renderWithProviders(<WalletPage />);

const itemCheckbox = screen.getByTestId('select-item-checkbox-w-1');
fireEvent.click(itemCheckbox);

const csvBtn = screen.getByRole('button', { name: /export 1 selected item as csv/i });
fireEvent.click(csvBtn);

expect(mockDownloadWalletCsv).toHaveBeenCalledTimes(1);
expect(mockDownloadWalletCsv).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'w-1' })]),
);
expect(screen.getByText('Export successful')).toBeInTheDocument();
});

it('exports selected items as JSON and triggers success toast', () => {
mockListWalletItems.mockReturnValue(SAMPLE_WALLET_ITEMS);
renderWithProviders(<WalletPage />);

const itemCheckbox = screen.getByTestId('select-item-checkbox-w-1');
fireEvent.click(itemCheckbox);

const exportBtn = screen.getByRole('button', { name: /export 1 selected item/i });
fireEvent.click(exportBtn);
const jsonBtn = screen.getByRole('button', { name: /export 1 selected item as json/i });
fireEvent.click(jsonBtn);

expect(mockDownloadWalletJson).toHaveBeenCalledTimes(1);
expect(mockDownloadWalletJson).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ id: 'w-1' })]),
);
expect(screen.getByText('Export successful')).toBeInTheDocument();
});

Expand Down
106 changes: 61 additions & 45 deletions src/app/wallet/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { ConfirmDialog } from '../../components/ConfirmDialog';
import { WalletBulkToolbar } from '../../components/wallet/WalletBulkToolbar';
import { WalletItemList } from '../../components/wallet/WalletItemList';
import { KbdHint } from '@/components/KbdHint';
import { listWalletItems, saveWalletItem, updateWalletItem, deleteWalletItems } from '@/lib/repository';
import {
listWalletItems,
saveWalletItem,
updateWalletItem,
deleteWalletItems,
} from '@/lib/repository';
import { downloadWalletCsv, downloadWalletJson } from '@/lib/exportWallet';
import { useToast } from '@/components/toast/toast-provider';
import type { WalletItem } from '@/types/domain';
import { SAMPLE_WALLET_ITEMS } from './constants';
Expand Down Expand Up @@ -63,26 +69,37 @@ export default function WalletPage() {
setSelectedIds(new Set());
}, []);

const handleExportSelected = useCallback(() => {
if (selectedIds.size === 0) return;
const selectedItems = items.filter((item) => selectedIds.has(item.id));
const jsonStr = JSON.stringify(selectedItems, null, 2);

try {
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `wallet-export-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
// Fallback for non-browser or strict CSP environments
}
const handleExportCsv = useCallback(() => {
const selectedItems =
selectedIds.size > 0
? items.filter((i) => selectedIds.has(i.id))
: items;
if (selectedItems.length === 0) return;

downloadWalletCsv(selectedItems);

showSuccess({
title: 'Export successful',
description: `Exported ${selectedItems.length} ${selectedItems.length === 1 ? 'item' : 'items'} to JSON.`,
description: `Exported ${selectedItems.length} ${
selectedItems.length === 1 ? 'item' : 'items'
} to CSV.`,
});
}, [items, selectedIds, showSuccess]);

const handleExportJson = useCallback(() => {
const selectedItems =
selectedIds.size > 0
? items.filter((i) => selectedIds.has(i.id))
: items;
if (selectedItems.length === 0) return;

downloadWalletJson(selectedItems);

showSuccess({
title: 'Export successful',
description: `Exported ${selectedItems.length} ${
selectedItems.length === 1 ? 'item' : 'items'
} to JSON.`,
});
}, [items, selectedIds, showSuccess]);

Expand Down Expand Up @@ -144,34 +161,32 @@ export default function WalletPage() {
setEditingId(id);
}, []);

const handleSaveEdit = useCallback((id: string, updated: WalletItem) => {
const ok = updateWalletItem(id, updated);
if (ok) {
const reloaded = listWalletItems();
setItems(reloaded);
setEditingId(null);
showSuccess({
title: 'Item updated',
description: `"${updated.name}" has been updated successfully.`,
});
} else {
showError({
title: 'Update failed',
description: 'Failed to save changes to the wallet item.',
});
}
}, [showSuccess, showError]);
const handleSaveEdit = useCallback(
(id: string, updated: WalletItem) => {
const ok = updateWalletItem(id, updated);
if (ok) {
const reloaded = listWalletItems();
setItems(reloaded);
setEditingId(null);
showSuccess({
title: 'Item updated',
description: `"${updated.name}" has been updated successfully.`,
});
} else {
showError({
title: 'Update failed',
description: 'Failed to save changes to the wallet item.',
});
}
},
[showSuccess, showError]
);

const handleCancelEdit = useCallback((_id: string) => {
setEditingId(null);
}, []);

// Global wallet shortcuts: Ctrl/Cmd+Shift+A (select all) and
// Ctrl/Cmd+Shift+E (export selected). Shift is included specifically to
// avoid clashing with the browser's own Ctrl/Cmd+A (select-all-text) and
// Ctrl/Cmd+E (address-bar search in some browsers). Ignored while a text
// input, textarea, or contenteditable element (e.g. inline item editing)
// has focus so normal typing/selecting text is never intercepted.
// Global wallet keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || !event.shiftKey) return;
Expand All @@ -183,13 +198,13 @@ export default function WalletPage() {
handleToggleSelectAll();
} else if (key === 'e') {
event.preventDefault();
handleExportSelected();
handleExportCsv();
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleToggleSelectAll, handleExportSelected]);
}, [handleToggleSelectAll, handleExportCsv]);

const deleteModalTitle = useMemo(() => {
const count = targetDeleteIds.length;
Expand Down Expand Up @@ -226,7 +241,8 @@ export default function WalletPage() {
<WalletBulkToolbar
selectedCount={selectedIds.size}
onClearSelection={handleClearSelection}
onExport={handleExportSelected}
onExportCsv={handleExportCsv}
onExportJson={handleExportJson}
onDelete={handleRequestBulkDelete}
/>
)}
Expand Down Expand Up @@ -263,4 +279,4 @@ export default function WalletPage() {
/>
</main>
);
}
}
Loading
Loading