feat: subscription badges + status improvements + dailyWorker fix (batch 0.33.8.3)

- Sub badge (indigo) in all 4 locations, toggleable in Bills prefs
- SimpleFIN Sync card on Status page
- dailyWorker.start() now called on startup
- tracker.overdue_count uses real SQL query
- Status page accuracy: dynamic headers, Degraded state, worker last_error check
- Removed SimpleFIN prefix from Recommendations title
- Bump v0.33.8.2 -> v0.33.8.3
This commit is contained in:
null 2026-05-29 19:21:46 -05:00
parent 6e9fd6873f
commit 0f9f48e255
11 changed files with 179 additions and 19 deletions

View File

@ -1,5 +1,25 @@
# Bill Tracker — Changelog
## v0.33.8.3
### 🚀 Features
- **Subscription badge (indigo)**`Sub` badge in all four locations (desktop tracker, mobile tracker, desktop bills table, mobile bills row), toggleable via Bills page display preferences.
- **SimpleFIN Sync status card** — New card in Operations grid on Status page showing connections, accounts, last sync, next check, interval, and any errors.
- **Daily worker now starts**`dailyWorker.start()` was never called; autopay marking, notifications, session pruning, and scheduled cleanup are now active.
### 🐛 Bug Fixes
- **Tracker overdue count**`overdue_count` now runs a real SQL query: active monthly bills where due_day < today, no payment this month, and not skipped.
- **Status page accuracy** — Header subtitle reflects `data.ok` ("All systems operational" / "One or more systems need attention"). Application card shows "Degraded" in red when not ok. Worker status pill now checks `last_error` — a running worker with errors shows "Error" red instead of "Running" green. "Stopped" renamed to "Error" for accuracy.
- **SimpleFIN Recommendations title** — Removed redundant "SimpleFIN" prefix from card title on Subscriptions page.
### 🛠 Internal
- `routes/status.js``bank_sync` block returns config, worker state, DB aggregates.
---
## v0.33.8.2
### 🐛 Bug Fixes

View File

@ -71,6 +71,11 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
2FA
</span>
)}
{prefs.showSubscription && !!bill.is_subscription && (
<span className="shrink-0 rounded bg-indigo-500/15 px-1.5 py-0.5 text-[11px] font-semibold text-indigo-600 dark:text-indigo-300">
Sub
</span>
)}
{hasHistory && (
<span
className="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-sky-500/25 bg-sky-500/10 text-sky-500"

View File

@ -70,6 +70,9 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
{bill.has_2fa && (
<span className="rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-300">2FA</span>
)}
{bill.is_subscription && (
<span className="rounded bg-indigo-500/15 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300">Sub</span>
)}
</div>
</div>

View File

@ -195,6 +195,14 @@ export const MobileTrackerRow = React.memo(function MobileTrackerRow({ row, year
AP
</span>
)}
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
Sub
</span>
)}
</div>
{row.monthly_notes && (
<p className="mt-1 line-clamp-2 text-xs italic text-amber-500/80" title={row.monthly_notes}>

View File

@ -188,6 +188,7 @@ const PREFS_DEFAULTS = {
showMinPayment: true,
showAutopay: true,
show2fa: true,
showSubscription: true,
};
const PREFS_LABELS = [
@ -200,6 +201,7 @@ const PREFS_LABELS = [
['showMinPayment', 'Min payment'],
['showAutopay', 'Autopay badge'],
['show2fa', '2FA badge'],
['showSubscription', 'Subscription badge'],
];
function useDisplayPrefs() {

View File

@ -124,7 +124,7 @@ function ReleaseNotesSection({ version, historyMeta }) {
return (
<StatusCard title="Release Notes" status={`v${version.version}`} tone="good">
<StatRow label="Latest Release" value={categories[0] ?? null} />
<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>
@ -285,14 +285,26 @@ export default function StatusPage() {
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.running ?? worker.enabled ?? null;
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 bankSyncEnabled = bankSync.enabled ?? false;
const bankSyncStatus = !bankSyncEnabled ? 'Disabled'
: bankSync.running ? 'Syncing'
: bankSync.last_error ? 'Error'
: bankSync.source_count > 0 ? 'Connected'
: 'No Sources';
const bankSyncTone = !bankSyncEnabled ? 'muted'
: bankSync.last_error ? 'warn'
: bankSync.source_count > 0 ? 'good'
: 'warn';
return (
<div>
@ -301,7 +313,7 @@ export default function StatusPage() {
<div>
<h1 className="text-2xl font-bold tracking-tight">Server Status</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{data ? 'All systems operational' : 'Loading…'}
{!data ? 'Loading…' : data.ok ? 'All systems operational' : 'One or more systems need attention'}
</p>
</div>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
@ -322,7 +334,7 @@ export default function StatusPage() {
<div className="grid gap-4 mb-6 md:grid-cols-2">
{/* Application */}
<StatusCard title="Application" status="Online" tone="good">
<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
@ -384,7 +396,7 @@ export default function StatusPage() {
<StatusCard
title="Daily Worker"
status={workerOk === null ? 'Pending' : workerOk ? 'Running' : 'Stopped'}
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'} />
@ -393,6 +405,19 @@ export default function StatusPage() {
<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'}

View File

@ -459,7 +459,7 @@ export default function SubscriptionsPage() {
<CardHeader className="px-4 pb-3 sm:px-6">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
<CardTitle className="text-base">SimpleFIN Recommendations</CardTitle>
<CardTitle className="text-base">Recommendations</CardTitle>
</div>
<CardDescription>Recurring unmatched bank charges that look like subscriptions.</CardDescription>
</CardHeader>

View File

@ -1044,6 +1044,14 @@ function Row({ row, year, month, refresh, index, onEditBill }) {
AP
</span>
)}
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"
title="Subscription"
>
Sub
</span>
)}
<Button
size="icon" variant="ghost"
className="h-7 w-7 opacity-100 transition-opacity text-muted-foreground hover:text-foreground hover:bg-accent lg:opacity-0 lg:group-hover:opacity-100"

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.33.8.2",
"version": "0.33.8.3",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -7,6 +7,8 @@ const { getStatusRuntime, recordError } = require('../services/statusRuntime');
const { listBackups } = require('../services/backupService');
const { getScheduleStatus } = require('../services/backupScheduler');
const { checkForUpdates } = require('../services/updateCheckService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const startTime = Date.now();
let pkg;
@ -188,6 +190,7 @@ router.get('/', async (req, res) => {
try {
const range = monthRange(now);
const todayDay = now.getDate();
const billCount = db.prepare('SELECT COUNT(*) AS n FROM bills WHERE active = 1').get().n;
const paymentCount = db.prepare(
'SELECT COUNT(*) AS n FROM payments WHERE paid_date BETWEEN ? AND ? AND deleted_at IS NULL'
@ -195,6 +198,20 @@ router.get('/', async (req, res) => {
const skippedCount = db.prepare(
'SELECT COUNT(*) AS n FROM monthly_bill_state WHERE year = ? AND month = ? AND is_skipped = 1'
).get(range.year, range.month).n;
const overdueCount = db.prepare(`
SELECT COUNT(*) AS n FROM bills b
WHERE b.active = 1
AND b.billing_cycle = 'monthly'
AND CAST(b.due_day AS INTEGER) < ?
AND NOT EXISTS (
SELECT 1 FROM payments p
WHERE p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM monthly_bill_state mbs
WHERE mbs.bill_id = b.id AND mbs.year = ? AND mbs.month = ? AND mbs.is_skipped = 1
)
`).get(todayDay, range.start, range.end, range.year, range.month).n;
tracker = {
ok: true,
@ -204,7 +221,7 @@ router.get('/', async (req, res) => {
payment_count: paymentCount,
bills_this_month: billCount,
payments_this_month: paymentCount,
overdue_count: null,
overdue_count: overdueCount,
skipped_count: skippedCount,
last_error: null,
};
@ -218,6 +235,69 @@ router.get('/', async (req, res) => {
}
}
// Bank sync (SimpleFIN) status
let bankSync = {
enabled: false,
running: false,
source_count: 0,
account_count: 0,
transaction_count: 0,
interval_hours: null,
sync_days: null,
last_sync_at: null,
next_run_at: null,
last_error: null,
};
try {
const config = getBankSyncConfig();
const workerStatus = getBankSyncWorkerStatus();
bankSync = {
enabled: config.enabled,
running: workerStatus.running,
interval_hours: config.sync_interval_hours,
sync_days: config.sync_days,
source_count: 0,
account_count: 0,
transaction_count: 0,
last_sync_at: null,
next_run_at: workerStatus.next_run_at,
last_error: null,
};
if (db) {
const sourceRow = db.prepare(`
SELECT COUNT(*) AS source_count, MAX(last_sync_at) AS last_sync_at
FROM data_sources WHERE type = 'provider_sync' AND provider = 'simplefin'
`).get();
const accountRow = db.prepare(`
SELECT COUNT(fa.id) AS account_count
FROM financial_accounts fa
INNER JOIN data_sources ds ON ds.id = fa.data_source_id
WHERE ds.type = 'provider_sync' AND ds.provider = 'simplefin'
`).get();
const txRow = db.prepare(`
SELECT COUNT(t.id) AS transaction_count
FROM transactions t
INNER JOIN data_sources ds ON ds.id = t.data_source_id
WHERE ds.type = 'provider_sync' AND ds.provider = 'simplefin'
`).get();
const errorRow = db.prepare(`
SELECT last_error FROM data_sources
WHERE type = 'provider_sync' AND provider = 'simplefin' AND last_error IS NOT NULL
ORDER BY updated_at DESC LIMIT 1
`).get();
bankSync = {
...bankSync,
source_count: sourceRow.source_count ?? 0,
account_count: accountRow.account_count ?? 0,
transaction_count: txRow.transaction_count ?? 0,
last_sync_at: sourceRow.last_sync_at || null,
last_error: errorRow?.last_error || null,
};
}
} catch (err) {
recordError('Bank Sync Status', err);
}
// Cleanup status — safe read-only summary from settings, no paths or secrets
let cleanup = { ok: true, last_run_at: null, last_result: null };
try {
@ -285,6 +365,8 @@ router.get('/', async (req, res) => {
backup: backups,
server,
tracker,
bank_sync: bankSync,
simplefin: bankSync,
cleanup,
update,
recent_errors: recentErrors,

View File

@ -238,6 +238,13 @@ async function main() {
} catch (err) {
console.error('[bankSync] Failed to start auto-sync worker:', err.message);
}
// Start daily worker (autopay marking, notifications, session pruning, cleanup)
try {
require('./workers/dailyWorker').start();
} catch (err) {
console.error('[dailyWorker] Failed to start daily worker:', err.message);
}
});
}