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
This commit is contained in:
null 2026-05-29 19:58:52 -05:00
parent 0f9f48e255
commit a15436b637
8 changed files with 505 additions and 385 deletions

View File

@ -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

View File

@ -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 }),

View File

@ -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 }) {
<Button
size="sm" variant="outline"
onClick={() => handleSync(conn.id)}
disabled={syncing === conn.id}
disabled={syncing === conn.id || backfilling === conn.id}
className="h-8 text-xs gap-1.5"
>
{syncing === conn.id
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Syncing</>
: <><RefreshCw className="h-3.5 w-3.5" />Sync Now</>}
</Button>
<Button
size="sm" variant="outline"
onClick={() => handleBackfill(conn.id)}
disabled={syncing === conn.id || backfilling === conn.id}
className="h-8 text-xs gap-1.5 text-muted-foreground"
title="Pull up to 90 days of transaction history"
>
{backfilling === conn.id
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Backfilling</>
: <><History className="h-3.5 w-3.5" />90d Backfill</>}
</Button>
<Button
size="sm" variant="ghost"
onClick={() => setDisconnectTarget(conn)}

View File

@ -1,46 +1,17 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { RefreshCw, ArrowUpCircle, CheckCircle2, AlertCircle } from 'lucide-react';
import {
Activity, AlertCircle, ArrowUpCircle, BarChart3, Bell, CalendarClock,
CheckCircle2, Clock, Cpu, Database, Globe, HardDrive, Landmark,
RefreshCw, ScrollText, Wrench,
} from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmtUptime, fmtBytes } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { MarkdownText } from '@/components/MarkdownText';
// Skeleton Card
function SkeletonCard() {
return (
<div className="bg-card rounded-xl border border-border shadow-sm p-6 animate-pulse">
<div className="flex items-center justify-between mb-4">
<div className="h-3 w-20 bg-muted rounded" />
<div className="h-3 w-12 bg-muted rounded" />
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0">
<div className="h-3 w-24 bg-muted rounded" />
<div className="h-3 w-16 bg-muted rounded" />
</div>
))}
</div>
</div>
);
}
// Stat Row
function StatRow({ label, value, last }) {
return (
<div className={cn(
'flex items-center justify-between py-2',
!last && 'border-b border-border/50',
)}>
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium tabular-nums">{value ?? '—'}</span>
</div>
);
}
// Helpers
function formatMemory(runtime) {
if (runtime.memory_used_bytes ?? runtime.memory?.used ?? runtime.memory) {
@ -61,17 +32,41 @@ function formatDateTime(value) {
return date.toLocaleString();
}
// Design primitives
function SectionLabel({ children }) {
return (
<div className="flex items-center gap-3 mb-3 mt-8 first:mt-0">
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/50 whitespace-nowrap">
{children}
</span>
<div className="flex-1 h-px bg-border/40" />
</div>
);
}
function StatRow({ label, value, last }) {
return (
<div className={cn('flex items-center justify-between py-1.5 gap-3', !last && 'border-b border-border/40')}>
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className="text-xs font-medium tabular-nums text-right truncate max-w-[60%]">
{value ?? <span className="text-muted-foreground/50"></span>}
</span>
</div>
);
}
function StatusPill({ tone = 'muted', children }) {
const cls = {
good: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/25',
warn: 'bg-amber-500/10 text-amber-500 border-amber-500/25',
bad: 'bg-red-500/10 text-red-500 border-red-500/25',
muted: 'bg-muted text-muted-foreground border-border',
}[tone];
good: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25',
warn: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-400/30',
bad: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25',
muted: 'bg-muted/60 text-muted-foreground border-border',
}[tone] ?? 'bg-muted/60 text-muted-foreground border-border';
return (
<span className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
'inline-flex items-center shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
cls,
)}>
{children}
@ -79,143 +74,146 @@ function StatusPill({ tone = 'muted', children }) {
);
}
function StatusCard({ title, status, tone = 'muted', children }) {
function StatusCard({ title, icon: Icon, status, tone = 'muted', children, className }) {
const accentCls = {
good: 'border-t-emerald-500/60',
warn: 'border-t-amber-400/70',
bad: 'border-t-red-500/60',
muted: 'border-t-border/60',
}[tone] ?? 'border-t-border/60';
return (
<div className="bg-card rounded-xl border border-border shadow-sm p-6">
<div className="flex items-center justify-between gap-3 mb-4">
<h3 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h3>
{status && <StatusPill tone={tone}>{status}</StatusPill>}
<div className={cn(
'flex flex-col bg-card rounded-xl border border-border border-t-[3px] shadow-sm',
accentCls,
className,
)}>
<div className="flex items-center justify-between gap-2 px-5 pt-4 pb-3 border-b border-border/30">
<div className="flex items-center gap-2 min-w-0">
{Icon && <Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />}
<h3 className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground/80 truncate">
{title}
</h3>
</div>
{status != null && <StatusPill tone={tone}>{status}</StatusPill>}
</div>
<div className="space-y-0">{children}</div>
<div className="px-5 py-3 flex-1">{children}</div>
</div>
);
}
function SkeletonCard() {
return (
<div className="flex flex-col bg-card rounded-xl border border-border border-t-[3px] border-t-border/60 shadow-sm animate-pulse">
<div className="flex items-center justify-between gap-2 px-5 pt-4 pb-3 border-b border-border/30">
<div className="flex items-center gap-2">
<div className="h-3.5 w-3.5 rounded bg-muted/70" />
<div className="h-3 w-20 rounded bg-muted/70" />
</div>
<div className="h-4 w-14 rounded bg-muted/50" />
</div>
<div className="px-5 py-3 space-y-0">
{[1, 2, 3].map(i => (
<div key={i} className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
<div className="h-3 w-20 rounded bg-muted/60" />
<div className="h-3 w-14 rounded bg-muted/50" />
</div>
))}
</div>
</div>
);
}
// Update Card
const CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
// Release Notes Section
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 ReleaseNotesSection({ version, historyMeta }) {
if (!version) {
return (
<div className="mt-4">
<div className="h-28 rounded-lg bg-muted/50 animate-pulse" />
</div>
);
}
const tone = hasUpdate ? 'warn' : isKnown && !hasError ? 'good' : 'muted';
const status = hasUpdate ? 'Update Available'
: hasError ? 'Check Failed'
: isKnown ? 'Up to Date'
: 'Unknown';
// Group notes by category, preserving CATEGORY_ORDER priority
const grouped = CATEGORY_ORDER.reduce((acc, cat) => {
const notes = version.notes?.filter((n) => n.category === cat) ?? [];
if (notes.length) acc[cat] = notes;
return acc;
}, {});
// Catch any categories not in CATEGORY_ORDER
version.notes?.forEach((n) => {
if (!grouped[n.category]) {
grouped[n.category] = [...(grouped[n.category] ?? []), n];
}
});
const categories = Object.keys(grouped);
const preview = categories.length ? grouped[categories[0]]?.[0]?.text : null;
const StatusIcon = hasUpdate ? ArrowUpCircle : hasError ? AlertCircle : CheckCircle2;
const iconCls = hasUpdate ? 'text-amber-500' : hasError ? 'text-red-500' : isKnown ? 'text-emerald-500' : 'text-muted-foreground';
return (
<StatusCard title="Release Notes" status={`v${version.version}`} tone="good">
<StatRow label="Total Changes" value={version.notes?.length ? `${version.notes.length} items` : null} />
<StatRow label="Last Updated" value={formatDateTime(historyMeta?.updated_at)} />
<div className="py-2 border-b border-border/50">
<p className="text-sm text-muted-foreground">Preview</p>
<p className="text-sm font-medium mt-1">
<MarkdownText text={preview ?? 'No release notes available.'} />
</p>
<StatusCard title="Software Update" icon={RefreshCw} status={status} tone={tone}>
<div className="flex items-center gap-2 py-1.5 border-b border-border/40">
<StatusIcon className={cn('h-3.5 w-3.5 shrink-0', iconCls)} />
<span className="text-xs font-medium">
{hasUpdate ? `v${update.latest_version} is available`
: isKnown && !hasError ? 'Running the latest version'
: hasError ? 'Could not reach update server'
: 'Update status unknown'}
</span>
</div>
<StatRow label="Running" value={update.current_version ? `v${update.current_version}` : null} />
<div className="flex items-center justify-between py-1.5 border-b border-border/40 gap-3">
<span className="text-xs text-muted-foreground shrink-0">Latest</span>
{update.latest_version ? (
update.latest_release_url ? (
<a href={update.latest_release_url} target="_blank" rel="noopener noreferrer"
className={cn('text-xs font-medium underline-offset-2 hover:underline truncate', hasUpdate ? 'text-amber-500' : '')}>
v{update.latest_version}
</a>
) : (
<span className="text-xs font-medium">v{update.latest_version}</span>
)
) : (
<span className="text-xs text-muted-foreground/50"></span>
)}
</div>
<StatRow label="Last Checked" value={formatDateTime(update.last_checked_at)} />
{update.error && (
<p className="text-[11px] text-red-400 leading-relaxed pt-1.5">{update.error}</p>
)}
<div className="pt-3">
<Button asChild variant="outline" size="sm">
<Link to="/release-notes">View Full Release Notes</Link>
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
className="h-7 text-xs gap-1.5">
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
{checking ? 'Checking…' : 'Check Now'}
</Button>
</div>
</StatusCard>
);
}
// 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 <SkeletonCard />;
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 (
<StatusCard title="Software Update" status={status} tone={tone}>
<div className="flex items-center gap-2 py-2 border-b border-border/50">
<Icon className={cn('h-4 w-4 shrink-0', iconCls)} />
<span className="text-sm font-medium">
{hasUpdate
? `v${update.latest_version} is available`
: isKnown && !hasError
? 'Running the latest version'
: hasError
? 'Could not reach update server'
: 'Status unknown'}
</span>
<StatusCard title="Release Notes" icon={ScrollText} status={`v${version.version}`} tone="good">
<StatRow label="Total Changes" value={version.notes?.length ? `${version.notes.length} items` : null} />
<StatRow label="Last Updated" value={formatDateTime(historyMeta?.updated_at)} />
<div className="py-1.5 border-b border-border/40">
<p className="text-[11px] text-muted-foreground/60 uppercase tracking-wide mb-1">Preview</p>
<p className="text-xs font-medium line-clamp-2 leading-relaxed">
<MarkdownText text={preview ?? 'No release notes available.'} />
</p>
</div>
<StatRow label="Running" value={update.current_version ? `v${update.current_version}` : null} />
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-sm text-muted-foreground">Latest Release</span>
{update.latest_version ? (
update.latest_release_url ? (
<a
href={update.latest_release_url}
target="_blank"
rel="noopener noreferrer"
className={cn(
'text-sm font-medium underline-offset-2 hover:underline',
hasUpdate ? 'text-amber-500' : '',
)}
>
v{update.latest_version}
</a>
) : (
<span className="text-sm font-medium">v{update.latest_version}</span>
)
) : (
<span className="text-sm font-medium text-muted-foreground"></span>
)}
</div>
<StatRow label="Last Checked" value={formatDateTime(update.last_checked_at)} />
{update.error && (
<div className="py-2 border-b border-border/50">
<p className="text-xs text-red-400 leading-relaxed">{update.error}</p>
</div>
)}
<div className="pt-3">
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
className="gap-1.5">
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
{checking ? 'Checking…' : 'Check Now'}
<Button asChild variant="outline" size="sm" className="h-7 text-xs">
<Link to="/release-notes">View Full Notes</Link>
</Button>
</div>
</StatusCard>
@ -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 (
<div>
{/* Page header — flat on background */}
<div className="flex flex-col gap-3 mb-8 sm:flex-row sm:items-center sm:justify-between">
{/* ── Page header ──────────────────────────────────────────────── */}
<div className="flex flex-col gap-3 mb-6 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{!data ? 'Loading…' : data.ok ? 'All systems operational' : 'One or more systems need attention'}
</p>
<p className="text-sm text-muted-foreground mt-0.5">Health and operational status</p>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<Button variant="outline" size="sm" onClick={load} disabled={loading} className="self-start">
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', loading && 'animate-spin')} />
Refresh
</Button>
</div>
{/* 2x2 grid of stat cards */}
{loading ? (
<div className="grid gap-4 mb-6 md:grid-cols-2">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
) : (
<div className="grid gap-4 mb-6 md:grid-cols-2">
{/* Application */}
<StatusCard title="Application" status={data?.ok ? 'Online' : 'Degraded'} tone={data?.ok ? 'good' : 'bad'}>
<StatRow label="Version" value={(app.version ?? data?.version) ? `v${app.version ?? data?.version}` : null} />
<StatRow label="Environment" value={app.environment ?? app.env ?? data?.environment ?? data?.env} />
<StatRow
label="Uptime"
value={fmtUptime(app.uptime_seconds ?? app.uptime ?? data?.uptime_seconds ?? 0)}
last
/>
</StatusCard>
{/* Runtime */}
<StatusCard title="Runtime">
<StatRow label="Node.js" value={rt.node_version ?? rt.node ?? data?.node_version} />
<StatRow label="Platform" value={rt.platform ?? data?.platform} />
<StatRow label="Architecture" value={rt.arch ?? data?.arch} />
<StatRow label="Memory" value={formatMemory(rt)} last />
</StatusCard>
{/* Database */}
<StatusCard title="Database" status={dbOk ? 'Connected' : 'Error'} tone={dbOk ? 'good' : 'bad'}>
<StatRow label="Size" value={formatBytesMaybe(db.size_bytes ?? db.size)} />
<StatRow label="Last Modified" value={formatDateTime(db.last_modified ?? db.modified_at)} />
<StatRow label="Free Disk" value={formatBytesMaybe(db.free_disk_bytes ?? db.disk_free_bytes)} />
<div className="flex items-start justify-between py-2">
<span className="text-sm text-muted-foreground">File path</span>
<code className="text-xs bg-muted px-1.5 py-0.5 rounded font-mono max-w-[55%] break-all text-right">
{db.file ?? db.path ?? db.filename ?? '—'}
</code>
</div>
</StatusCard>
{/* Statistics */}
<StatusCard title="Statistics">
<StatRow label="Active Bills" value={stats.active_bills ?? stats.bills ?? stats.active_bill_count} />
<StatRow label="Total Payments" value={stats.total_payments ?? stats.payments} />
<StatRow label="Users" value={stats.users ?? stats.user_count} />
<StatRow label="Sessions" value={stats.active_sessions ?? stats.sessions} last />
</StatusCard>
{/* ── Health banner ────────────────────────────────────────────── */}
{!loading && data && (
<div className={cn(
'flex items-center gap-3 px-4 py-2.5 rounded-lg border mb-6',
data.ok
? 'bg-emerald-500/5 border-emerald-500/20'
: 'bg-red-500/5 border-red-500/20',
)}>
<span className={cn(
'h-2 w-2 rounded-full shrink-0',
data.ok
? 'bg-emerald-500 shadow-[0_0_0_3px_rgb(34_197_94/0.15)]'
: 'bg-red-500 shadow-[0_0_0_3px_rgb(239_68_68/0.15)]',
)} />
<span className={cn(
'text-sm font-semibold',
data.ok ? 'text-emerald-700 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
)}>
{data.ok ? 'All systems operational' : 'One or more systems need attention'}
</span>
<div className="ml-auto hidden sm:flex items-center gap-4 text-xs text-muted-foreground tabular-nums">
{(app.version ?? data?.version) && <span>v{app.version ?? data?.version}</span>}
<span>{fmtUptime(app.uptime_seconds ?? data?.uptime_seconds ?? 0)} uptime</span>
</div>
</div>
)}
{!loading && (
{/* ── Loading skeleton ─────────────────────────────────────────── */}
{loading ? (
<>
<div className="mb-3">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
Operations
</h2>
{[3, 5].map((count, si) => (
<React.Fragment key={si}>
<div className={cn('flex items-center gap-3 mb-3', si > 0 && 'mt-8')}>
<div className="h-2.5 w-24 rounded bg-muted/60 animate-pulse" />
<div className="flex-1 h-px bg-border/40" />
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 mb-2">
{Array.from({ length: count }).map((_, i) => <SkeletonCard key={i} />)}
</div>
</React.Fragment>
))}
</>
) : (
<>
{/* ── Infrastructure ─────────────────────────────────────────── */}
<SectionLabel>Infrastructure</SectionLabel>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<StatusCard title="Application" icon={Globe}
status={data?.ok ? 'Online' : 'Degraded'}
tone={data?.ok ? 'good' : 'bad'}
>
<StatRow label="Version" value={(app.version ?? data?.version) ? `v${app.version ?? data?.version}` : null} />
<StatRow label="Environment" value={app.environment ?? app.env ?? data?.environment ?? data?.env} />
<StatRow label="Uptime" value={fmtUptime(app.uptime_seconds ?? app.uptime ?? data?.uptime_seconds ?? 0)} last />
</StatusCard>
<StatusCard title="Database" icon={Database}
status={dbOk ? 'Connected' : 'Error'}
tone={dbOk ? 'good' : 'bad'}
>
<StatRow label="Size" value={formatBytesMaybe(db.size_bytes ?? db.size)} />
<StatRow label="Last Modified" value={formatDateTime(db.last_modified ?? db.modified_at)} />
{dbOk
? <StatRow label="Health" value="Healthy" last />
: <StatRow label="Error" value={db.last_error} last />}
</StatusCard>
<StatusCard title="Runtime" icon={Cpu}>
<StatRow label="Node.js" value={rt.node_version ?? rt.node ?? data?.node_version} />
<StatRow label="Platform" value={rt.platform ?? data?.platform} />
<StatRow label="Architecture" value={rt.arch ?? data?.arch} />
<StatRow label="Memory" value={formatMemory(rt)} last />
</StatusCard>
</div>
<div className="grid gap-4 mb-6 md:grid-cols-2 xl:grid-cols-3">
{/* ── Services ───────────────────────────────────────────────── */}
<SectionLabel>Services</SectionLabel>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<StatusCard title="Daily Worker" icon={CalendarClock}
status={workerOk === null ? 'Pending' : workerOk ? 'Running' : 'Error'}
tone={workerOk === null ? 'muted' : workerOk ? 'good' : 'bad'}
>
<StatRow label="Enabled" value={worker.enabled === undefined ? null : worker.enabled ? 'Yes' : 'No'} />
<StatRow label="Last Run" value={formatDateTime(worker.last_run_at ?? worker.last_success_at)} />
<StatRow label="Next Run" value={formatDateTime(worker.next_run_at)} />
<StatRow label="Last Error" value={worker.last_error} last />
</StatusCard>
<StatusCard title="SimpleFIN Sync" icon={Landmark}
status={bankSyncStatus}
tone={bankSyncTone}
>
<StatRow label="Connections" value={bankSyncEnabled ? (bankSync.source_count ?? 0) : null} />
<StatRow label="Accounts" value={bankSyncEnabled ? (bankSync.account_count ?? 0) : null} />
<StatRow label="Last Sync" value={bankSyncEnabled ? formatDateTime(bankSync.last_sync_at) : null} />
<StatRow label="Next Check" value={bankSyncEnabled ? formatDateTime(bankSync.next_run_at) : null} />
<StatRow label="Interval" value={bankSyncEnabled && bankSync.interval_hours ? `Every ${bankSync.interval_hours}h` : null} />
<StatRow label="Last Error" value={bankSync.last_error} last />
</StatusCard>
<StatusCard title="Notifications" icon={Bell}
status={notificationsConfigured === null ? 'Pending' : notificationsConfigured ? 'Configured' : 'Missing'}
tone={notificationsConfigured === null ? 'muted' : notificationsConfigured ? 'good' : 'warn'}
>
<StatRow label="Enabled" value={notifications.enabled === undefined ? null : notifications.enabled ? 'Yes' : 'No'} />
<StatRow label="SMTP" value={notificationsConfigured == null ? null : notificationsConfigured ? 'Configured' : 'Not configured'} />
<StatRow label="Last Sent" value={formatDateTime(notifications.last_sent_at)} />
<StatRow label="Failures" value={notifications.failures ?? notifications.failed_count} last />
</StatusCard>
<StatusCard title="Backups" icon={HardDrive}
status={backupsEnabled === null ? 'Pending' : backupsEnabled ? 'Enabled' : 'Disabled'}
tone={backupsEnabled === null ? 'muted' : backupsEnabled ? 'good' : 'warn'}
>
<StatRow label="Last Backup" value={formatDateTime(backups.last_backup_at)} />
<StatRow label="Count" value={backups.count ?? backups.backup_count} />
<StatRow label="Retention" value={backups.keep_count ?? backups.retention_count} />
<StatRow label="Last Error" value={backups.last_error} last />
</StatusCard>
<StatusCard title="Maintenance" icon={Wrench}
status={cleanup.last_run_at ? 'OK' : 'Pending'}
tone={cleanup.last_run_at ? 'good' : 'muted'}
>
<StatRow label="Last Run" value={formatDateTime(cleanup.last_run_at)} />
<StatRow label="Import Sessions" value={cleanup.last_result?.import_sessions?.pruned != null ? `${cleanup.last_result.import_sessions.pruned} pruned` : null} />
<StatRow label="Temp Files" value={cleanup.last_result?.temp_export_files?.removed != null ? `${cleanup.last_result.temp_export_files.removed} removed` : null} />
<StatRow label="Backup Partials" value={cleanup.last_result?.backup_partials?.removed != null ? `${cleanup.last_result.backup_partials.removed} removed` : null} last />
</StatusCard>
</div>
{/* ── App Health ─────────────────────────────────────────────── */}
<SectionLabel>App Health</SectionLabel>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<StatusCard title="Tracker" icon={Activity}>
<StatRow label="Bills This Month" value={tracker.bills_this_month} />
<StatRow label="Payments This Month" value={tracker.payments_this_month} />
<StatRow label="Overdue" value={tracker.overdue_count} />
<StatRow label="Skipped" value={tracker.skipped_count} last />
</StatusCard>
<StatusCard title="Statistics" icon={BarChart3}>
<StatRow label="Active Bills" value={stats.active_bills ?? stats.bills ?? stats.active_bill_count} />
<StatRow label="Total Payments" value={stats.total_payments ?? stats.payments} />
<StatRow label="Users" value={stats.users ?? stats.user_count} />
<StatRow label="Sessions" value={stats.active_sessions ?? stats.sessions} last />
</StatusCard>
<StatusCard title="Server Clock" icon={Clock}>
<StatRow label="Server Time" value={formatDateTime(server.now ?? data?.server_time)} />
<StatRow label="Date" value={server.today ?? data?.today} />
<StatRow label="Timezone" value={server.timezone ?? data?.timezone} />
<StatRow label="UTC Offset" value={utcOffset} last />
</StatusCard>
</div>
{/* ── Software ───────────────────────────────────────────────── */}
<SectionLabel>Software</SectionLabel>
<div className="grid gap-4 sm:grid-cols-2">
{updateData && (
<UpdateCard
update={updateData}
@ -393,128 +486,45 @@ export default function StatusPage() {
checking={updateChecking}
/>
)}
<StatusCard
title="Daily Worker"
status={workerOk === null ? 'Pending' : workerOk ? 'Running' : 'Error'}
tone={workerOk === null ? 'muted' : workerOk ? 'good' : 'bad'}
>
<StatRow label="Enabled" value={worker.enabled === undefined ? null : worker.enabled ? 'Yes' : 'No'} />
<StatRow label="Last Run" value={formatDateTime(worker.last_run_at ?? worker.last_success_at)} />
<StatRow label="Next Run" value={formatDateTime(worker.next_run_at)} />
<StatRow label="Last Error" value={worker.last_error} last />
</StatusCard>
<StatusCard
title="SimpleFIN Sync"
status={bankSyncStatus}
tone={bankSyncTone}
>
<StatRow label="Connections" value={bankSyncEnabled ? (bankSync.source_count ?? 0) : null} />
<StatRow label="Accounts" value={bankSyncEnabled ? (bankSync.account_count ?? 0) : null} />
<StatRow label="Last Sync" value={bankSyncEnabled ? formatDateTime(bankSync.last_sync_at) : null} />
<StatRow label="Next Check" value={bankSyncEnabled ? formatDateTime(bankSync.next_run_at) : null} />
<StatRow label="Interval" value={bankSyncEnabled && bankSync.interval_hours ? `Every ${bankSync.interval_hours}h` : null} />
<StatRow label="Last Error" value={bankSync.last_error} last />
</StatusCard>
<StatusCard
title="Notifications"
status={notificationsConfigured === null ? 'Pending' : notificationsConfigured ? 'Configured' : 'Missing'}
tone={notificationsConfigured === null ? 'muted' : notificationsConfigured ? 'good' : 'warn'}
>
<StatRow label="Enabled" value={notifications.enabled === undefined ? null : notifications.enabled ? 'Yes' : 'No'} />
<StatRow label="SMTP" value={notificationsConfigured === undefined || notificationsConfigured === null ? null : notificationsConfigured ? 'Configured' : 'Not configured'} />
<StatRow label="Last Sent" value={formatDateTime(notifications.last_sent_at)} />
<StatRow label="Failures" value={notifications.failures ?? notifications.failed_count} last />
</StatusCard>
<StatusCard
title="Backups"
status={backupsEnabled === null ? 'Pending' : backupsEnabled ? 'Enabled' : 'Disabled'}
tone={backupsEnabled === null ? 'muted' : backupsEnabled ? 'good' : 'warn'}
>
<StatRow label="Last Backup" value={formatDateTime(backups.last_backup_at)} />
<StatRow label="Backup Count" value={backups.count ?? backups.backup_count} />
<StatRow label="Retention" value={backups.keep_count ?? backups.retention_count} />
<StatRow label="Last Error" value={backups.last_error} last />
</StatusCard>
<StatusCard
title="Maintenance"
status={cleanup.last_run_at ? 'OK' : 'Pending'}
tone={cleanup.last_run_at ? 'good' : 'muted'}
>
<StatRow label="Last Run" value={formatDateTime(cleanup.last_run_at)} />
<StatRow
label="Import Sessions"
value={cleanup.last_result?.import_sessions?.pruned != null
? `${cleanup.last_result.import_sessions.pruned} pruned`
: null}
/>
<StatRow
label="Temp Export Files"
value={cleanup.last_result?.temp_export_files?.removed != null
? `${cleanup.last_result.temp_export_files.removed} removed`
: null}
/>
<StatRow
label="Backup Partials"
value={cleanup.last_result?.backup_partials?.removed != null
? `${cleanup.last_result.backup_partials.removed} removed`
: null}
last
/>
</StatusCard>
<StatusCard title="Server Clock">
<StatRow label="Server Time" value={formatDateTime(server.now ?? data?.server_time)} />
<StatRow label="Server Date" value={server.today ?? data?.today} />
<StatRow label="Timezone" value={server.timezone ?? data?.timezone} />
<StatRow label="UTC Offset" value={server.utc_offset} last />
</StatusCard>
<StatusCard title="Tracker Health">
<StatRow label="Bills This Month" value={tracker.bills_this_month} />
<StatRow label="Payments This Month" value={tracker.payments_this_month} />
<StatRow label="Overdue" value={tracker.overdue_count} />
<StatRow label="Skipped" value={tracker.skipped_count} last />
</StatusCard>
<StatusCard
title="Recent Errors"
status={recentErrors.length ? `${recentErrors.length} Open` : 'None'}
tone={recentErrors.length ? 'bad' : 'good'}
>
{recentErrors.length ? (
recentErrors.slice(0, 4).map((err, index) => (
<div
key={`${err.timestamp ?? err.message ?? index}`}
className={cn(
'py-2',
index !== Math.min(recentErrors.length, 4) - 1 && 'border-b border-border/50',
)}
>
<p className="text-sm font-medium leading-tight">{err.source ?? err.type ?? 'Application'}</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{err.message ?? String(err)}
</p>
</div>
))
) : (
<>
<StatRow label="Last Error" value={null} />
<StatRow label="Open Incidents" value="0" last />
</>
)}
</StatusCard>
<ReleaseNotesCard version={version} historyMeta={historyMeta} />
</div>
{/* ── Errors ─────────────────────────────────────────────────── */}
<SectionLabel>Errors</SectionLabel>
<StatusCard
title="Recent Errors"
icon={AlertCircle}
status={recentErrors.length ? `${recentErrors.length} Open` : 'None'}
tone={recentErrors.length ? 'bad' : 'good'}
>
{recentErrors.length ? (
recentErrors.slice(0, 5).map((err, i) => (
<div
key={`${err.timestamp ?? err.message ?? i}`}
className={cn('py-1.5', i !== Math.min(recentErrors.length, 5) - 1 && 'border-b border-border/40')}
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium leading-tight truncate">
{err.source ?? err.type ?? 'Application'}
</p>
{err.timestamp && (
<p className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
{formatDateTime(err.timestamp)}
</p>
)}
</div>
<p className="text-[11px] text-muted-foreground mt-0.5 line-clamp-1">
{err.message ?? String(err)}
</p>
</div>
))
) : (
<p className="text-xs text-muted-foreground py-0.5">No recent errors recorded.</p>
)}
</StatusCard>
</>
)}
{/* Release Notes */}
<ReleaseNotesSection version={version} historyMeta={historyMeta} />
</div>
);
}

View File

@ -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": {

View File

@ -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) => {

View File

@ -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 = {

View File

@ -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 };