@@ -2152,6 +2167,19 @@ export default function TrackerPage() {
onSave={() => { setEditStartingOpen(false); refetch(); }}
/>
+ {/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */}
+ {commandCenterPayRow && (
+
setCommandCenterPayRow(null)}
+ onSaved={() => { setCommandCenterPayRow(null); refetch(); }}
+ />
+ )}
+
);
}
diff --git a/db/database.js b/db/database.js
index b466e26..02d1df9 100644
--- a/db/database.js
+++ b/db/database.js
@@ -2417,6 +2417,14 @@ function runMigrations() {
run: function() {
runSubscriptionCatalogV2Migration(db);
}
+ },
+ {
+ version: 'v0.70',
+ description: 'monthly_bill_state: add snoozed_until for overdue command center',
+ dependsOn: ['v0.69'],
+ run: function() {
+ db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT');
+ }
}
];
diff --git a/db/schema.sql b/db/schema.sql
index 03020c9..9641315 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -219,6 +219,7 @@ CREATE TABLE IF NOT EXISTS monthly_bill_state (
actual_amount REAL, -- NULL = use bill.expected_amount for this month
notes TEXT, -- month-specific notes, NULL = no notes
is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only
+ snoozed_until TEXT, -- ISO date: hide from overdue command center until this date
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(bill_id, year, month)
diff --git a/package.json b/package.json
index 58e4ad8..1e38248 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.33.8.7",
+ "version": "0.34.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/bills.js b/routes/bills.js
index b6a167c..cab8931 100644
--- a/routes/bills.js
+++ b/routes/bills.js
@@ -178,7 +178,7 @@ router.put('/:id/monthly-state', (req, res) => {
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
- const { year, month, actual_amount, notes, is_skipped } = req.body;
+ const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body;
const y = parseInt(year, 10);
const m = parseInt(month, 10);
@@ -193,19 +193,26 @@ router.put('/:id/monthly-state', (req, res) => {
return res.status(400).json(standardizeError('actual_amount must be a non-negative number or null', 'VALIDATION_ERROR', 'actual_amount'));
}
- const amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(actual_amount)) : null;
- const noteVal = notes !== undefined ? (notes || null) : null;
- const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : 0;
+ if (snoozed_until !== undefined && snoozed_until !== null) {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until))
+ return res.status(400).json(standardizeError('snoozed_until must be an ISO date string (YYYY-MM-DD) or null', 'VALIDATION_ERROR', 'snoozed_until'));
+ }
+
+ const amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(actual_amount)) : null;
+ const noteVal = notes !== undefined ? (notes || null) : null;
+ const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : 0;
+ const snoozeVal = snoozed_until !== undefined ? (snoozed_until || null) : null;
db.prepare(`
- INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
+ INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped, snoozed_until, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(bill_id, year, month) DO UPDATE SET
actual_amount = excluded.actual_amount,
notes = excluded.notes,
is_skipped = excluded.is_skipped,
+ snoozed_until = excluded.snoozed_until,
updated_at = datetime('now')
- `).run(billId, y, m, amt, noteVal, skipVal);
+ `).run(billId, y, m, amt, noteVal, skipVal, snoozeVal);
const saved = db.prepare(
'SELECT * FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
@@ -218,6 +225,7 @@ router.put('/:id/monthly-state', (req, res) => {
actual_amount: saved.actual_amount,
notes: saved.notes,
is_skipped: !!saved.is_skipped,
+ snoozed_until: saved.snoozed_until ?? null,
created_at: saved.created_at,
updated_at: saved.updated_at,
});
diff --git a/routes/tracker.js b/routes/tracker.js
index d283687..c5af6fc 100644
--- a/routes/tracker.js
+++ b/routes/tracker.js
@@ -1,6 +1,11 @@
const express = require('express');
const router = express.Router();
-const { getTracker, getUpcomingBills } = require('../services/trackerService');
+const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService');
+
+// GET /api/tracker/overdue-count — lightweight count for sidebar badge
+router.get('/overdue-count', (req, res) => {
+ res.json(getOverdueCount(req.user.id));
+});
// GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => {
diff --git a/services/trackerService.js b/services/trackerService.js
index 1ee68f8..a6642e9 100644
--- a/services/trackerService.js
+++ b/services/trackerService.js
@@ -54,7 +54,7 @@ function fetchMonthlyStates(db, billIds, year, month) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(`
- SELECT bill_id, actual_amount, notes, is_skipped
+ SELECT bill_id, actual_amount, notes, is_skipped, snoozed_until
FROM monthly_bill_state
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
`).all(...billIds, year, month);
@@ -268,6 +268,7 @@ function getTracker(userId, query = {}, now = new Date()) {
row.actual_amount = mbs?.actual_amount ?? null;
row.monthly_notes = mbs?.notes ?? null;
row.is_skipped = !!(mbs?.is_skipped);
+ row.snoozed_until = mbs?.snoozed_until ?? null;
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
@@ -387,8 +388,52 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
return { days, today: todayStr, upcoming };
}
+function getOverdueCount(userId, now = new Date()) {
+ const db = getDb();
+ const todayStr = now.toISOString().slice(0, 10);
+ const year = now.getFullYear();
+ const month = now.getMonth() + 1;
+ const monthStr = String(month).padStart(2, '0');
+ const rangeStart = `${year}-${monthStr}-01`;
+ const lastDay = new Date(year, month, 0).getDate();
+ const rangeEnd = `${year}-${monthStr}-${String(lastDay).padStart(2, '0')}`;
+
+ const bills = db.prepare(`
+ SELECT b.id, b.due_day, b.override_due_date, b.expected_amount,
+ b.billing_cycle, b.cycle_type, b.cycle_day,
+ b.autopay_enabled, b.autodraft_status,
+ mbs.actual_amount, mbs.is_skipped, mbs.snoozed_until,
+ COALESCE(SUM(p.amount), 0) AS total_paid
+ FROM bills b
+ LEFT JOIN monthly_bill_state mbs
+ ON mbs.bill_id = b.id AND mbs.year = ? AND mbs.month = ?
+ LEFT JOIN payments p
+ ON p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL
+ WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
+ GROUP BY b.id
+ `).all(year, month, rangeStart, rangeEnd, userId);
+
+ let count = 0;
+ for (const bill of bills) {
+ if (bill.is_skipped) continue;
+ if (bill.snoozed_until && bill.snoozed_until > todayStr) continue;
+ if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') continue;
+
+ const dueDate = resolveDueDate(bill, year, month);
+ if (!dueDate || dueDate > todayStr) continue;
+
+ const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount;
+ if (threshold > 0 && bill.total_paid >= threshold) continue;
+
+ count++;
+ }
+
+ return { count, month, year, today: todayStr };
+}
+
module.exports = {
getTracker,
getUpcomingBills,
validateTrackerMonth,
+ getOverdueCount,
};