feat(export): JSON export + date-range/format payments export (Batch 4)

- GET /api/export now accepts a date range (?from=&to= on paid_date) in addition
  to ?year=, for CSV or JSON; filename derived from the range. Validates the
  range (both bounds, from<=to).
- New GET /api/export/user-json — full portable JSON of the user's data, reusing
  the same getUserExportData assembly as the SQLite/Excel exports (money via
  fromCents).
- UI (DownloadMyDataSection): a JSON export card plus a "Payments export" with
  From/To dates and a CSV/JSON toggle; shared blob-download helper; toasts and
  client-side range validation.

Tests: tests/exportRicher.test.js (JSON assembly in dollars, year vs range
filtering, CSV filename, bad-range rejection). Server 134 pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 15:15:36 -05:00
parent bd1eee00b0
commit 314e4ff45e
3 changed files with 237 additions and 56 deletions

View File

@ -1,31 +1,37 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Database, Download, FileSpreadsheet, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react'; import { Database, Download, FileSpreadsheet, FileJson, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import { SectionCard } from './dataShared'; import { SectionCard } from './dataShared';
const USER_EXPORTS_AVAILABLE = true; // Shared blob download: works whether or not the response sets Content-Disposition
// (the JSON payments export doesn't), falling back to the given name.
async function downloadFile(endpoint, fallbackName) {
const res = await fetch(endpoint, { credentials: 'include' });
if (!res.ok) {
let data = {};
try { data = await res.json(); } catch {}
throw new Error(data.message || data.error || `HTTP ${res.status}`);
}
const disposition = res.headers.get('Content-Disposition');
const match = disposition?.match(/filename="?([^"]+)"?/i);
const name = match ? match[1] : fallbackName;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
}
function ExportCard({ icon: Icon, title, description, filename, endpoint }) { function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const handleDownload = async () => { const handleDownload = async () => {
setLoading(true); setLoading(true);
try { try {
const res = await fetch(endpoint, { credentials: 'include' }); await downloadFile(endpoint, filename);
if (!res.ok) {
let data = {};
try { data = await res.json(); } catch {}
throw new Error(data.error || `HTTP ${res.status}`);
}
const disposition = res.headers.get('Content-Disposition');
const match = disposition?.match(/filename="?([^"]+)"?/i);
const name = match ? match[1] : filename;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
toast.success(`${title} downloaded.`); toast.success(`${title} downloaded.`);
} catch (err) { } catch (err) {
toast.error(err.message || 'Download failed.'); toast.error(err.message || 'Download failed.');
@ -33,37 +39,91 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
setLoading(false); setLoading(false);
} }
}; };
const disabled = !USER_EXPORTS_AVAILABLE || loading;
return ( return (
<div className="px-6 py-5 flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6 px-6 py-5">
<div className="flex items-start gap-4 flex-1 min-w-0"> <div className="flex min-w-0 flex-1 items-start gap-4">
<div className="mt-0.5 h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0"> <div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-5 w-5 text-primary" /> <Icon className="h-5 w-5 text-primary" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5 flex-wrap"> <p className="mb-0.5 text-sm font-medium">{title}</p>
<p className="text-sm font-medium">{title}</p>
{!USER_EXPORTS_AVAILABLE && (
<span className="inline-flex items-center rounded-full bg-muted border border-border px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
Coming soon
</span>
)}
</div>
<p className="text-xs text-muted-foreground">{description}</p> <p className="text-xs text-muted-foreground">{description}</p>
</div> </div>
</div> </div>
<div className="shrink-0 pt-0.5"> <div className="shrink-0 pt-0.5">
<Button size="sm" variant="outline" disabled={disabled} <Button size="sm" variant="outline" disabled={loading} onClick={handleDownload}>
onClick={USER_EXPORTS_AVAILABLE ? handleDownload : undefined}> {loading ? <><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />Downloading</>
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Downloading</> : <><Download className="mr-1.5 h-3.5 w-3.5" />Download</>}
: <><Download className="h-3.5 w-3.5 mr-1.5" />{USER_EXPORTS_AVAILABLE ? 'Download' : 'Not Available Yet'}</>}
</Button> </Button>
</div> </div>
</div> </div>
); );
} }
// Filtered payments export: current year by default, or a custom date range, as CSV or JSON.
function PaymentsExport() {
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [format, setFormat] = useState('csv');
const [loading, setLoading] = useState(false);
const handle = async () => {
if ((from && !to) || (!from && to)) { toast.error('Enter both From and To dates, or leave both empty.'); return; }
if (from && to && from > to) { toast.error('“From” must be on or before “To”.'); return; }
const params = new URLSearchParams({ format });
if (from && to) { params.set('from', from); params.set('to', to); }
setLoading(true);
try {
await downloadFile(`/api/export?${params.toString()}`, `bills.${format === 'json' ? 'json' : 'csv'}`);
toast.success('Payments exported.');
} catch (err) {
toast.error(err.message || 'Export failed.');
} finally {
setLoading(false);
}
};
return (
<div className="px-6 py-5">
<div className="rounded-lg border border-border/60 bg-muted/25 p-4">
<p className="text-sm font-medium">Payments export</p>
<p className="mt-0.5 text-xs text-muted-foreground">Just your payment records filter by date and choose a format. Leave dates empty for the current year.</p>
<div className="mt-3 flex flex-wrap items-end gap-3">
<div>
<Label htmlFor="pe-from" className="text-xs">From</Label>
<Input id="pe-from" type="date" value={from} onChange={e => setFrom(e.target.value)} className="mt-1 h-9 w-[9.5rem]" />
</div>
<div>
<Label htmlFor="pe-to" className="text-xs">To</Label>
<Input id="pe-to" type="date" value={to} onChange={e => setTo(e.target.value)} className="mt-1 h-9 w-[9.5rem]" />
</div>
<div>
<span className="block text-xs text-muted-foreground">Format</span>
<div className="mt-1 inline-flex rounded-lg border border-border/60 p-0.5" role="group" aria-label="Export format">
{['csv', 'json'].map(f => (
<button
key={f}
type="button"
aria-pressed={format === f}
onClick={() => setFormat(f)}
className={cn('rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
format === f ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground')}
>
{f.toUpperCase()}
</button>
))}
</div>
</div>
<Button size="sm" className="h-9 gap-1.5" disabled={loading} onClick={handle}>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Download className="h-3.5 w-3.5" />}
Export
</Button>
</div>
</div>
</div>
);
}
export default function DownloadMyDataSection({ cardProps = {} }) { export default function DownloadMyDataSection({ cardProps = {} }) {
return ( return (
<SectionCard <SectionCard
@ -71,33 +131,37 @@ export default function DownloadMyDataSection({ cardProps = {} }) {
subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup." subtitle="Export your bill tracker data for your own records. These exports include only your data — not a full system backup."
{...cardProps} {...cardProps}
> >
<ExportCard icon={Database} title="SQLite Data Export" <ExportCard icon={Database} title="SQLite backup"
description="Download a portable SQLite database containing your bill tracker data." description="A portable SQLite database with all of your bill tracker data — the format used to restore a backup."
filename="bill-tracker-user-export.sqlite" endpoint="/api/export/user-db" /> filename="bill-tracker-user-export.sqlite" endpoint="/api/export/user-db" />
<ExportCard icon={FileSpreadsheet} title="Excel Databook" <ExportCard icon={FileSpreadsheet} title="Excel databook"
description="Download an Excel workbook with sheets for bills, payments, categories, monthly state, and summary data." description="An Excel workbook with sheets for bills, payments, categories, monthly state, and summary data."
filename="bill-tracker-databook.xlsx" endpoint="/api/export/user-excel" /> filename="bill-tracker-databook.xlsx" endpoint="/api/export/user-excel" />
<div className="px-6 py-3 rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/40 flex items-start gap-2.5 mx-6 mt-2"> <ExportCard icon={FileJson} title="JSON export"
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" /> description="Your full data as portable JSON — handy for scripting, backups, or moving between tools."
filename="bill-tracker-user-export.json" endpoint="/api/export/user-json" />
<PaymentsExport />
<div className="mx-6 mt-2 flex items-start gap-2.5 rounded-md border border-amber-200 bg-amber-50 px-6 py-3 dark:border-amber-800/40 dark:bg-amber-950/30">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-xs text-amber-700 dark:text-amber-300">Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.</p> <p className="text-xs text-amber-700 dark:text-amber-300">Exports may contain sensitive account metadata (website URLs, usernames, account info). Store exported files securely and avoid sharing them unencrypted.</p>
</div> </div>
<div className="px-6 py-5 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 px-6 py-5 sm:grid-cols-2">
<div className="rounded-lg bg-muted/40 border border-border/60 p-4"> <div className="rounded-lg border border-border/60 bg-muted/40 p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's included</p> <p className="mb-2.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">What's included</p>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => ( {['Bills','Payments','Categories','Monthly bill state','Monthly starting amounts','History ranges','Notes','Export metadata'].map(i => (
<li key={i} className="flex items-center gap-2 text-xs text-foreground/80"> <li key={i} className="flex items-center gap-2 text-xs text-foreground/80">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />{i} <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />{i}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
<div className="rounded-lg bg-muted/40 border border-border/60 p-4"> <div className="rounded-lg border border-border/60 bg-muted/40 p-4">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's not included</p> <p className="mb-2.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">What's not included</p>
<ul className="space-y-1.5"> <ul className="space-y-1.5">
{['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => ( {['Passwords','Sessions','Admin settings','Server configuration',"Other users' data",'Full system backup files'].map(i => (
<li key={i} className="flex items-center gap-2 text-xs text-muted-foreground"> <li key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
<XCircle className="h-3.5 w-3.5 text-muted-foreground shrink-0" />{i} <XCircle className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />{i}
</li> </li>
))} ))}
</ul> </ul>

View File

@ -9,14 +9,34 @@ const xlsx = require('xlsx');
const { getDb } = require('../db/database'); const { getDb } = require('../db/database');
const { fromCents } = require('../utils/money'); const { fromCents } = require('../utils/money');
// GET /api/export?year=2026&format=csv // GET /api/export?year=2026&format=csv — or a date range: ?from=YYYY-MM-DD&to=YYYY-MM-DD
router.get('/', (req, res) => { router.get('/', (req, res) => {
const db = getDb(); const db = getDb();
const year = parseInt(req.query.year || new Date().getFullYear(), 10);
const format = (req.query.format || 'csv').toLowerCase(); const format = (req.query.format || 'csv').toLowerCase();
const { from, to } = req.query;
const isDate = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s);
if (isNaN(year) || year < 2000 || year > 2100) // Filter by an explicit date range (both bounds required) or by a single year.
return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year')); let where, whereParams, label;
if (from != null || to != null) {
if (!isDate(from) || !isDate(to)) {
return res.status(400).json(standardizeError('from and to must both be YYYY-MM-DD dates', 'VALIDATION_ERROR', 'from'));
}
if (from > to) {
return res.status(400).json(standardizeError('from must be on or before to', 'VALIDATION_ERROR', 'from'));
}
where = 'p.paid_date BETWEEN ? AND ?';
whereParams = [from, to];
label = `${from}_to_${to}`;
} else {
const year = parseInt(req.query.year || new Date().getFullYear(), 10);
if (isNaN(year) || year < 2000 || year > 2100) {
return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year'));
}
where = "strftime('%Y', p.paid_date) = ?";
whereParams = [String(year)];
label = String(year);
}
const rows = db.prepare(` const rows = db.prepare(`
SELECT SELECT
@ -32,12 +52,12 @@ router.get('/', (req, res) => {
FROM payments p FROM payments p
JOIN bills b ON b.id = p.bill_id JOIN bills b ON b.id = p.bill_id
LEFT JOIN categories c ON c.id = b.category_id AND c.deleted_at IS NULL LEFT JOIN categories c ON c.id = b.category_id AND c.deleted_at IS NULL
WHERE strftime('%Y', p.paid_date) = ? WHERE ${where}
AND b.user_id = ? AND b.user_id = ?
AND b.deleted_at IS NULL AND b.deleted_at IS NULL
AND p.deleted_at IS NULL AND p.deleted_at IS NULL
ORDER BY p.paid_date ASC, b.name ASC ORDER BY p.paid_date ASC, b.name ASC
`).all(String(year), req.user.id); `).all(...whereParams, req.user.id);
const mbsStmt = db.prepare( const mbsStmt = db.prepare(
'SELECT actual_amount, notes FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?' 'SELECT actual_amount, notes FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
@ -69,7 +89,7 @@ router.get('/', (req, res) => {
}).join('\n'); }).join('\n');
res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="bills-${year}.csv"`); res.setHeader('Content-Disposition', `attachment; filename="bills-${label}.csv"`);
return res.send(header + body); return res.send(header + body);
} }
@ -86,7 +106,7 @@ router.get('/', (req, res) => {
monthly_notes: mbs?.notes ?? null, monthly_notes: mbs?.notes ?? null,
}; };
}); });
res.json({ year, count: enriched.length, payments: enriched }); res.json({ range: label, count: enriched.length, payments: enriched });
}); });
function getUserExportData(userId) { function getUserExportData(userId) {
@ -226,5 +246,14 @@ router.get('/user-db', (req, res) => {
}); });
}); });
// Full portable JSON export of the user's data (same assembly as SQLite/Excel).
router.get('/user-json', (req, res) => {
const data = getUserExportData(req.user.id);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename="bill-tracker-user-export.json"');
res.send(JSON.stringify(data, null, 2));
});
module.exports = router; module.exports = router;
module.exports.buildUserDbExportFile = buildUserDbExportFile; module.exports.buildUserDbExportFile = buildUserDbExportFile;
module.exports.getUserExportData = getUserExportData;

View File

@ -0,0 +1,88 @@
'use strict';
// Batch 4: richer export — full JSON assembly (money in dollars) and the payments
// export's date-range vs year filtering.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-export-richer-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, closeDb } = require('../db/database');
const exportRouter = require('../routes/export');
const { getUserExportData } = exportRouter;
let userId, billId;
function callExport(query) {
const layer = exportRouter.stack.find(l => l.route?.path === '/' && l.route.methods.get);
const handler = layer.route.stack[0].handle;
return new Promise((resolve) => {
const headers = {};
const req = { query, user: { id: userId, role: 'user' } };
const res = {
statusCode: 200,
setHeader(k, v) { headers[k] = v; },
status(c) { this.statusCode = c; return this; },
json(d) { resolve({ status: this.statusCode, headers, json: d }); },
send(body) { resolve({ status: this.statusCode, headers, body }); },
};
handler(req, res);
});
}
test.before(() => {
const db = getDb();
userId = db.prepare("INSERT INTO users (username, password_hash, role, active) VALUES ('export-user','x','user',1)").run().lastInsertRowid;
billId = db.prepare("INSERT INTO bills (user_id, name, due_day, expected_amount, active) VALUES (?, 'Rent', 1, 120000, 1)").run(userId).lastInsertRowid;
const pay = db.prepare("INSERT INTO payments (bill_id, amount, paid_date, payment_source) VALUES (?, ?, ?, 'manual')");
pay.run(billId, 8500, '2025-06-15'); // prior year
pay.run(billId, 9000, '2026-01-10'); // in range
pay.run(billId, 9500, '2026-07-20'); // out of range, same year
});
test.after(() => {
closeDb();
for (const s of ['', '-wal', '-shm']) { try { fs.unlinkSync(dbPath + s); } catch {} }
});
test('getUserExportData assembles user data with money in dollars', () => {
const data = getUserExportData(userId);
assert.equal(data.bills.length, 1);
assert.equal(data.bills[0].expected_amount, 1200, 'expected_amount is dollars (fromCents)');
assert.equal(data.payments.length, 3);
const amounts = data.payments.map(p => p.amount).sort((a, b) => a - b);
assert.deepEqual(amounts, [85, 90, 95], 'payment amounts in dollars');
assert.equal(data.metadata.counts.payments, 3);
});
test('payments export by year returns that year only', async () => {
const { status, json } = await callExport({ year: '2026', format: 'json' });
assert.equal(status, 200);
assert.equal(json.count, 2, 'both 2026 payments');
assert.equal(json.range, '2026');
});
test('payments export by date range filters to the range', async () => {
const { status, json } = await callExport({ from: '2026-01-01', to: '2026-06-30', format: 'json' });
assert.equal(status, 200);
assert.equal(json.count, 1, 'only the Jan 10 payment');
assert.equal(json.payments[0].paid_amount, 90);
});
test('CSV export sets a filename derived from the range', async () => {
const { status, headers, body } = await callExport({ from: '2026-01-01', to: '2026-12-31', format: 'csv' });
assert.equal(status, 200);
assert.match(headers['Content-Disposition'], /bills-2026-01-01_to_2026-12-31\.csv/);
assert.match(body, /^Date,Bill,Category/);
});
test('invalid date range is rejected', async () => {
const bad = await callExport({ from: '2026-12-31', to: '2026-01-01' });
assert.equal(bad.status, 400);
const badFmt = await callExport({ from: 'nope', to: '2026-01-01' });
assert.equal(badFmt.status, 400);
});