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:
parent
bd1eee00b0
commit
314e4ff45e
|
|
@ -1,31 +1,37 @@
|
|||
import React, { useState } from 'react';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { cn } from '@/lib/utils';
|
||||
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 }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(endpoint, { credentials: 'include' });
|
||||
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);
|
||||
await downloadFile(endpoint, filename);
|
||||
toast.success(`${title} downloaded.`);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Download failed.');
|
||||
|
|
@ -33,37 +39,91 @@ function ExportCard({ icon: Icon, title, description, filename, endpoint }) {
|
|||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disabled = !USER_EXPORTS_AVAILABLE || loading;
|
||||
return (
|
||||
<div className="px-6 py-5 flex items-start justify-between gap-6">
|
||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||
<div className="mt-0.5 h-9 w-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<div className="flex items-start justify-between gap-6 px-6 py-5">
|
||||
<div className="flex min-w-0 flex-1 items-start gap-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||
<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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="mb-0.5 text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 pt-0.5">
|
||||
<Button size="sm" variant="outline" disabled={disabled}
|
||||
onClick={USER_EXPORTS_AVAILABLE ? handleDownload : undefined}>
|
||||
{loading ? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Downloading…</>
|
||||
: <><Download className="h-3.5 w-3.5 mr-1.5" />{USER_EXPORTS_AVAILABLE ? 'Download' : 'Not Available Yet'}</>}
|
||||
<Button size="sm" variant="outline" disabled={loading} onClick={handleDownload}>
|
||||
{loading ? <><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />Downloading…</>
|
||||
: <><Download className="mr-1.5 h-3.5 w-3.5" />Download</>}
|
||||
</Button>
|
||||
</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 = {} }) {
|
||||
return (
|
||||
<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."
|
||||
{...cardProps}
|
||||
>
|
||||
<ExportCard icon={Database} title="SQLite Data Export"
|
||||
description="Download a portable SQLite database containing your bill tracker data."
|
||||
<ExportCard icon={Database} title="SQLite backup"
|
||||
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" />
|
||||
<ExportCard icon={FileSpreadsheet} title="Excel Databook"
|
||||
description="Download an Excel workbook with sheets for bills, payments, categories, monthly state, and summary data."
|
||||
<ExportCard icon={FileSpreadsheet} title="Excel databook"
|
||||
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" />
|
||||
<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">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5" />
|
||||
<ExportCard icon={FileJson} title="JSON export"
|
||||
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>
|
||||
</div>
|
||||
<div className="px-6 py-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's included</p>
|
||||
<div className="grid grid-cols-1 gap-3 px-6 py-5 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 p-4">
|
||||
<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">
|
||||
{['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">
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted/40 border border-border/60 p-4">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2.5">What's not included</p>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/40 p-4">
|
||||
<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">
|
||||
{['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">
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -9,14 +9,34 @@ const xlsx = require('xlsx');
|
|||
const { getDb } = require('../db/database');
|
||||
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) => {
|
||||
const db = getDb();
|
||||
const year = parseInt(req.query.year || new Date().getFullYear(), 10);
|
||||
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)
|
||||
return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year'));
|
||||
// Filter by an explicit date range (both bounds required) or by a single 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(`
|
||||
SELECT
|
||||
|
|
@ -32,12 +52,12 @@ router.get('/', (req, res) => {
|
|||
FROM payments p
|
||||
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
|
||||
WHERE strftime('%Y', p.paid_date) = ?
|
||||
WHERE ${where}
|
||||
AND b.user_id = ?
|
||||
AND b.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
ORDER BY p.paid_date ASC, b.name ASC
|
||||
`).all(String(year), req.user.id);
|
||||
`).all(...whereParams, req.user.id);
|
||||
|
||||
const mbsStmt = db.prepare(
|
||||
'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');
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +106,7 @@ router.get('/', (req, res) => {
|
|||
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) {
|
||||
|
|
@ -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.buildUserDbExportFile = buildUserDbExportFile;
|
||||
module.exports.getUserExportData = getUserExportData;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue