From a15436b6375fcca5fa625aa458db3ca0ba458d32 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 29 May 2026 19:58:52 -0500 Subject: [PATCH] feat: 90-day backfill + auto-seed + status page redesign (batch 0.33.8.4) - New backfillDataSource export and POST route for 89-day history pull - Auto-seed 89 days on first connect, 30 days for routine syncs - sinceEpoch() replaced with sinceEpochDays(days) with explicit param - Status page errorRow now filters AND status = 'error' - Full status page redesign: colored top borders, card icons, section labels, health banner with glowing dot, consistent spacing - Bump v0.33.8.3 -> v0.33.8.4 --- HISTORY.md | 25 + client/api.js | 1 + client/components/data/BankSyncSection.jsx | 34 +- client/pages/StatusPage.jsx | 758 +++++++++++---------- package.json | 2 +- routes/dataSources.js | 24 +- routes/status.js | 3 +- services/bankSyncService.js | 43 +- 8 files changed, 505 insertions(+), 385 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index fc39941..83ad5d4 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,30 @@ # Bill Tracker β€” Changelog +## v0.33.8.4 + +### πŸš€ Features + +- **90-day backfill button** β€” New "90d Backfill" button per SimpleFIN connection pulls up to 89 days of transaction history. Available alongside Sync Now; both disable while either is in-flight. +- **Auto-seed on first connect** β€” New connections automatically get an 89-day seed sync on first connect (vs 30-day routine syncs). + +### πŸ› Bug Fixes + +- **Status page error logic** β€” `errorRow` query now adds `AND status = 'error'`. Erlist advisories stored in `last_error` on `status = 'active'` sources no longer show "Error" on the SimpleFIN Sync card. + +### 🎨 Design + +- **Status page redesign** β€” Cards now have colored top borders matching their tone, icons in headers, organized into Infrastructure/Services/App Health/Software/Errors sections with section labels and dividers. Health banner with glowing dot indicator. Consistent padding, text sizing, and spacing throughout. + +### πŸ›  Internal + +- `sinceEpoch()` replaced with `sinceEpochDays(days)` β€” explicit parameter instead of reading config. +- `runSync()` now accepts `{ days }` option; detects first sync (89 days) vs routine (30 days). +- New `backfillDataSource()` export β€” forces 89 days regardless. +- New `POST /api/data-sources/:id/backfill` route. +- New `api.backfillDataSource(id)` client method. + +--- + ## v0.33.8.3 ### πŸš€ Features diff --git a/client/api.js b/client/api.js index 2e6c7b0..48bd432 100644 --- a/client/api.js +++ b/client/api.js @@ -317,6 +317,7 @@ export const api = { simplefinStatus: () => get('/data-sources/simplefin/status'), connectSimplefin: (setupToken) => post('/data-sources/simplefin/connect', { setupToken }), syncDataSource: (id) => post(`/data-sources/${id}/sync`), + backfillDataSource: (id) => post(`/data-sources/${id}/backfill`), deleteDataSource: (id) => del(`/data-sources/${id}`), dataSourceAccounts: (sourceId) => get(`/data-sources/${sourceId}/accounts`), setAccountMonitored: (sourceId, accountId, monitored) => put(`/data-sources/${sourceId}/accounts/${accountId}`, { monitored }), diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index e624796..e21a57e 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { toast } from 'sonner'; import { AlertTriangle, Building2, ChevronDown, ChevronRight, - Eye, EyeOff, ExternalLink, Link2Off, Loader2, RefreshCw, Unlink, + Eye, EyeOff, ExternalLink, History, Link2Off, Loader2, RefreshCw, Unlink, } from 'lucide-react'; import { api } from '@/api'; import { cn } from '@/lib/utils'; @@ -296,6 +296,7 @@ export default function BankSyncSection({ onConnectionChange }) { const [setupToken, setSetupToken] = useState(''); const [connecting, setConnecting] = useState(false); const [syncing, setSyncing] = useState(null); + const [backfilling, setBackfilling] = useState(null); const [disconnectTarget, setDisconnectTarget] = useState(null); const [disconnecting, setDisconnecting] = useState(false); const [expandedAccount, setExpandedAccount] = useState(null); @@ -426,6 +427,24 @@ export default function BankSyncSection({ onConnectionChange }) { } }; + const handleBackfill = async (id) => { + setBackfilling(id); + try { + const result = await api.backfillDataSource(id); + if (result.errlist) { + toast.warning(`Backfill complete β€” ${result.transactionsNew} new transaction(s). Some connections need attention: ${result.errlist}`); + } else { + toast.success(`Backfill complete β€” ${result.transactionsNew} new transaction(s) pulled from the last 90 days.`); + } + await load(); + } catch (err) { + toast.error(err.message || 'Backfill failed'); + await load(); + } finally { + setBackfilling(null); + } + }; + const handleDisconnect = async () => { if (!disconnectTarget) return; setDisconnecting(true); @@ -551,13 +570,24 @@ export default function BankSyncSection({ onConnectionChange }) { + ); } -// ─── Update Card ───────────────────────────────────────────────────────────── +// ─── Release Notes Card ─────────────────────────────────────────────────────── -function UpdateCard({ update, onCheckNow, checking }) { - const hasUpdate = !!update.has_update; - const isKnown = update.up_to_date !== null && update.up_to_date !== undefined; - const hasError = !!update.error; +function ReleaseNotesCard({ version, historyMeta }) { + if (!version) return ; - const tone = hasUpdate ? 'warn' : isKnown && !hasError ? 'good' : 'muted'; - const status = hasUpdate ? 'Update Available' - : hasError ? 'Check Failed' - : isKnown ? 'Up to Date' - : 'Unknown'; + const grouped = CATEGORY_ORDER.reduce((acc, cat) => { + const notes = version.notes?.filter(n => n.category === cat) ?? []; + if (notes.length) acc[cat] = notes; + return acc; + }, {}); + version.notes?.forEach(n => { + if (!grouped[n.category]) grouped[n.category] = [...(grouped[n.category] ?? []), n]; + }); - const Icon = hasUpdate ? ArrowUpCircle - : hasError ? AlertCircle - : CheckCircle2; - - const iconCls = hasUpdate ? 'text-amber-500' - : hasError ? 'text-red-500' - : isKnown ? 'text-emerald-500' - : 'text-muted-foreground'; + const categories = Object.keys(grouped); + const preview = categories.length ? grouped[categories[0]]?.[0]?.text : null; return ( - -
- - - {hasUpdate - ? `v${update.latest_version} is available` - : isKnown && !hasError - ? 'Running the latest version' - : hasError - ? 'Could not reach update server' - : 'Status unknown'} - + + + +
+

Preview

+

+ +

- - - -
- Latest Release - {update.latest_version ? ( - update.latest_release_url ? ( - - v{update.latest_version} β†— - - ) : ( - v{update.latest_version} - ) - ) : ( - β€” - )} -
- - - - {update.error && ( -
-

{update.error}

-
- )} -
-
@@ -225,11 +223,11 @@ function UpdateCard({ update, onCheckNow, checking }) { // ─── StatusPage ─────────────────────────────────────────────────────────────── export default function StatusPage() { - const [data, setData] = useState(null); - const [version, setVersion] = useState(null); - const [historyMeta, setHistoryMeta] = useState(null); - const [loading, setLoading] = useState(true); - const [updateData, setUpdateData] = useState(null); + const [data, setData] = useState(null); + const [version, setVersion] = useState(null); + const [historyMeta, setHistoryMeta] = useState(null); + const [loading, setLoading] = useState(true); + const [updateData, setUpdateData] = useState(null); const [updateChecking, setUpdateChecking] = useState(false); const load = useCallback(async () => { @@ -237,22 +235,14 @@ export default function StatusPage() { setVersion(null); setHistoryMeta(null); try { - const [statusData, versionData] = await Promise.all([ - api.status(), - api.version(), - ]); + const [statusData, versionData] = await Promise.all([api.status(), api.version()]); setData(statusData); setUpdateData(statusData?.update ?? null); setVersion(versionData); try { const historyData = await api.releaseHistory(); - setHistoryMeta({ - version: historyData.version, - updated_at: historyData.updated_at, - }); - } catch { - setHistoryMeta(null); - } + setHistoryMeta({ version: historyData.version, updated_at: historyData.updated_at }); + } catch { setHistoryMeta(null); } } catch (err) { toast.error(err.message || 'Failed to load status.'); } finally { @@ -265,8 +255,7 @@ export default function StatusPage() { const handleCheckNow = useCallback(async () => { setUpdateChecking(true); try { - const result = await api.checkForUpdates(); - setUpdateData(result); + setUpdateData(await api.checkForUpdates()); } catch (err) { toast.error(err.message || 'Update check failed'); } finally { @@ -274,118 +263,222 @@ export default function StatusPage() { } }, []); - // Flatten nested status shape gracefully - const app = data?.application ?? data?.app ?? {}; - const rt = data?.runtime ?? {}; - const db = data?.database ?? data?.db ?? {}; - const stats = data?.statistics ?? data?.stats ?? {}; - const worker = data?.worker ?? data?.jobs ?? {}; + // Normalize the nested response shape + const app = data?.application ?? data?.app ?? {}; + const rt = data?.runtime ?? {}; + const db = data?.database ?? data?.db ?? {}; + const stats = data?.statistics ?? data?.stats ?? {}; + const worker = data?.worker ?? data?.jobs ?? {}; const notifications = data?.notifications ?? data?.email ?? {}; - const backups = data?.backups ?? data?.backup ?? {}; - const server = data?.server ?? data?.clock ?? {}; - const tracker = data?.tracker ?? data?.tracker_health ?? {}; - const cleanup = data?.cleanup ?? {}; - const bankSync = data?.bank_sync ?? data?.simplefin ?? {}; - const errors = data?.errors ?? data?.recent_errors ?? []; + const backups = data?.backups ?? data?.backup ?? {}; + const server = data?.server ?? data?.clock ?? {}; + const tracker = data?.tracker ?? data?.tracker_health ?? {}; + const cleanup = data?.cleanup ?? {}; + const bankSync = data?.bank_sync ?? data?.simplefin ?? {}; + const errors = data?.errors ?? data?.recent_errors ?? []; - const dbOk = db.connected ?? (db.status === 'connected') ?? true; - const workerOk = !worker.enabled ? null : worker.last_error ? false : true; + const dbOk = db.connected ?? (db.status === 'connected') ?? true; + const workerOk = !worker.enabled ? null : worker.last_error ? false : true; const notificationsConfigured = notifications.configured ?? notifications.smtp_configured ?? null; - const backupsEnabled = backups.enabled ?? null; - const recentErrors = Array.isArray(errors) ? errors : []; + const backupsEnabled = backups.enabled ?? null; + const recentErrors = Array.isArray(errors) ? errors : []; const bankSyncEnabled = bankSync.enabled ?? false; - const bankSyncStatus = !bankSyncEnabled ? 'Disabled' - : bankSync.running ? 'Syncing' - : bankSync.last_error ? 'Error' - : bankSync.source_count > 0 ? 'Connected' + const bankSyncStatus = !bankSyncEnabled ? 'Disabled' + : bankSync.running ? 'Syncing' + : bankSync.last_error ? 'Error' + : (bankSync.source_count ?? 0) > 0 ? 'Connected' : 'No Sources'; - const bankSyncTone = !bankSyncEnabled ? 'muted' - : bankSync.last_error ? 'warn' - : bankSync.source_count > 0 ? 'good' + const bankSyncTone = !bankSyncEnabled ? 'muted' + : bankSync.last_error ? 'warn' + : (bankSync.source_count ?? 0) > 0 ? 'good' : 'warn'; + const utcOffset = server.utc_offset != null + ? `UTC${server.utc_offset >= 0 ? '+' : ''}${server.utc_offset}` + : null; + return (
- {/* Page header β€” flat on background */} -
+ {/* ── Page header ──────────────────────────────────────────────── */} +

Server Status

-

- {!data ? 'Loading…' : data.ok ? 'All systems operational' : 'One or more systems need attention'} -

+

Health and operational status

-
- {/* 2x2 grid of stat cards */} - {loading ? ( -
- - - - -
- ) : ( -
- - {/* Application */} - - - - - - - {/* Runtime */} - - - - - - - - {/* Database */} - - - - -
- File path - - {db.file ?? db.path ?? db.filename ?? 'β€”'} - -
-
- - {/* Statistics */} - - - - - - - + {/* ── Health banner ────────────────────────────────────────────── */} + {!loading && data && ( +
+ + + {data.ok ? 'All systems operational' : 'One or more systems need attention'} + +
+ {(app.version ?? data?.version) && v{app.version ?? data?.version}} + {fmtUptime(app.uptime_seconds ?? data?.uptime_seconds ?? 0)} uptime +
)} - {!loading && ( + {/* ── Loading skeleton ─────────────────────────────────────────── */} + {loading ? ( <> -
-

- Operations -

+ {[3, 5].map((count, si) => ( + +
0 && 'mt-8')}> +
+
+
+
+ {Array.from({ length: count }).map((_, i) => )} +
+ + ))} + + ) : ( + <> + + {/* ── Infrastructure ─────────────────────────────────────────── */} + Infrastructure +
+ + + + + + + + + + + {dbOk + ? + : } + + + + + + + + +
-
+ {/* ── Services ───────────────────────────────────────────────── */} + Services +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + {/* ── App Health ─────────────────────────────────────────────── */} + App Health +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + {/* ── Software ───────────────────────────────────────────────── */} + Software +
{updateData && ( )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {recentErrors.length ? ( - recentErrors.slice(0, 4).map((err, index) => ( -
-

{err.source ?? err.type ?? 'Application'}

-

- {err.message ?? String(err)} -

-
- )) - ) : ( - <> - - - - )} -
+
+ + {/* ── Errors ─────────────────────────────────────────────────── */} + Errors + + {recentErrors.length ? ( + recentErrors.slice(0, 5).map((err, i) => ( +
+
+

+ {err.source ?? err.type ?? 'Application'} +

+ {err.timestamp && ( +

+ {formatDateTime(err.timestamp)} +

+ )} +
+

+ {err.message ?? String(err)} +

+
+ )) + ) : ( +

No recent errors recorded.

+ )} +
+ )} - - {/* Release Notes */} - -
); } diff --git a/package.json b/package.json index 4d26949..12bcf9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.33.8.3", + "version": "0.33.8.4", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/dataSources.js b/routes/dataSources.js index ba075fc..0546f2b 100644 --- a/routes/dataSources.js +++ b/routes/dataSources.js @@ -4,7 +4,7 @@ const router = require('express').Router(); const { getDb } = require('../db/database'); const { standardizeError } = require('../middleware/errorFormatter'); const { decorateDataSource, ensureManualDataSource } = require('../services/transactionService'); -const { connectSimplefin, syncDataSource, disconnectDataSource } = require('../services/bankSyncService'); +const { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource } = require('../services/bankSyncService'); const { sanitizeErrorMessage } = require('../services/simplefinService'); const { getBankSyncConfig } = require('../services/bankSyncConfigService'); @@ -189,6 +189,28 @@ router.post('/:id/sync', async (req, res) => { } }); +// ─── POST /api/data-sources/:id/backfill ───────────────────────────────────── + +router.post('/:id/backfill', async (req, res) => { + if (!getBankSyncConfig().enabled) { + return res.status(503).json(standardizeError('Bank sync is not enabled on this server', 'BANK_SYNC_DISABLED')); + } + + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) { + return res.status(400).json(standardizeError('Invalid data source id', 'VALIDATION_ERROR', 'id')); + } + + try { + const db = getDb(); + const result = await backfillDataSource(db, req.user.id, id); + res.json(result); + } catch (err) { + const { msg, status } = safeError(err, 'Backfill failed'); + res.status(status).json(standardizeError(msg, err?.code || 'SIMPLEFIN_ERROR')); + } +}); + // ─── DELETE /api/data-sources/:id ──────────────────────────────────────────── router.delete('/:id', (req, res) => { diff --git a/routes/status.js b/routes/status.js index 746ee5d..6bd2fe4 100644 --- a/routes/status.js +++ b/routes/status.js @@ -282,7 +282,8 @@ router.get('/', async (req, res) => { `).get(); const errorRow = db.prepare(` SELECT last_error FROM data_sources - WHERE type = 'provider_sync' AND provider = 'simplefin' AND last_error IS NOT NULL + WHERE type = 'provider_sync' AND provider = 'simplefin' + AND status = 'error' AND last_error IS NOT NULL ORDER BY updated_at DESC LIMIT 1 `).get(); bankSync = { diff --git a/services/bankSyncService.js b/services/bankSyncService.js index 11424c0..47fc4f6 100644 --- a/services/bankSyncService.js +++ b/services/bankSyncService.js @@ -12,9 +12,11 @@ const { getBankSyncConfig } = require('./bankSyncConfigService'); const { decorateDataSource } = require('./transactionService'); const { applyMerchantRules } = require('./billMerchantRuleService'); -function sinceEpoch() { - const { sync_days } = getBankSyncConfig(); - return Math.floor((Date.now() - sync_days * 86400 * 1000) / 1000); +const SEED_SYNC_DAYS = 89; // Initial connect / explicit backfill (SimpleFIN Bridge ~90-day cap) +const ROUTINE_SYNC_DAYS = 30; // Auto-sync and manual "Sync Now" + +function sinceEpochDays(days) { + return Math.floor((Date.now() - days * 86400 * 1000) / 1000); } function safeErrorMessage(err) { @@ -78,9 +80,11 @@ function insertTransactionIfNew(db, txRow) { } } -async function runSync(db, userId, dataSource) { +async function runSync(db, userId, dataSource, { days } = {}) { const accessUrl = decryptSecret(dataSource.encrypted_secret); - const since = sinceEpoch(); + const isFirstSync = !dataSource.last_sync_at; + const syncDays = days ?? (isFirstSync ? SEED_SYNC_DAYS : ROUTINE_SYNC_DAYS); + const since = sinceEpochDays(syncDays); const raw = await fetchAccountsAndTransactions(accessUrl, since); const accounts = Array.isArray(raw.accounts) ? raw.accounts : []; @@ -184,6 +188,33 @@ async function syncDataSource(db, userId, dataSourceId) { return { dataSource: decorateDataSource(fresh), ...syncResult }; } +async function backfillDataSource(db, userId, dataSourceId) { + assertEncryptionReady(); + + const dataSource = db.prepare(` + SELECT * FROM data_sources + WHERE id = ? AND user_id = ? AND type = 'provider_sync' AND provider = 'simplefin' + `).get(dataSourceId, userId); + + if (!dataSource) throw Object.assign(new Error('SimpleFIN connection not found'), { status: 404 }); + if (!dataSource.encrypted_secret) throw new Error('No stored credentials for this connection'); + + let syncResult; + try { + syncResult = await runSync(db, userId, dataSource, { days: SEED_SYNC_DAYS }); + } catch (err) { + const msg = safeErrorMessage(err); + db.prepare(` + UPDATE data_sources SET last_error = ?, status = 'error', updated_at = datetime('now') + WHERE id = ? + `).run(msg, dataSourceId); + throw err; + } + + const fresh = db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(dataSourceId, userId); + return { dataSource: decorateDataSource(fresh), ...syncResult }; +} + function disconnectDataSource(db, userId, dataSourceId) { const row = db.prepare(` SELECT id FROM data_sources WHERE id = ? AND user_id = ? AND provider = 'simplefin' @@ -195,4 +226,4 @@ function disconnectDataSource(db, userId, dataSourceId) { db.prepare('DELETE FROM data_sources WHERE id = ? AND user_id = ?').run(dataSourceId, userId); } -module.exports = { connectSimplefin, syncDataSource, disconnectDataSource }; +module.exports = { connectSimplefin, syncDataSource, backfillDataSource, disconnectDataSource };