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:
parent
6e9fd6873f
commit
0f9f48e255
20
HISTORY.md
20
HISTORY.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue