diff --git a/client/pages/RoadmapPage.jsx b/client/pages/RoadmapPage.jsx
index 0172323..987f700 100644
--- a/client/pages/RoadmapPage.jsx
+++ b/client/pages/RoadmapPage.jsx
@@ -300,6 +300,14 @@ export default function RoadmapPage() {
...lane,
items: items.filter(item => laneForPriority(item.priority) === lane.key),
}));
+ const visibleLanes = grouped.filter(lane => lane.items.length > 0);
+ const laneGridClass = {
+ 1: 'grid-cols-1',
+ 2: 'grid-cols-1 lg:grid-cols-2',
+ 3: 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-3',
+ 4: 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-4',
+ 5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 min-[1400px]:grid-cols-5',
+ }[visibleLanes.length] || 'grid-cols-1';
const defaultOpenCards = isDesktop && allExpanded;
const laneProps = { defaultOpenCards, forceKey };
@@ -361,47 +369,11 @@ export default function RoadmapPage() {
- {/* Wide desktop: full five-lane view */}
-
- {grouped.map(lane =>
)}
+ {/* Size the board to its populated lanes so sparse roadmaps stay readable. */}
+
+ {visibleLanes.map(lane => (
+
+ ))}
>
)}
diff --git a/services/backupScheduler.js b/services/backupScheduler.js
index 7053b26..bfb93c4 100644
--- a/services/backupScheduler.js
+++ b/services/backupScheduler.js
@@ -155,6 +155,7 @@ function start() {
}
module.exports = {
+ computeNextRun,
getScheduleStatus,
reloadSchedule,
runScheduledBackupNow,
diff --git a/services/cleanupService.js b/services/cleanupService.js
index 0fb8791..9534d2f 100644
--- a/services/cleanupService.js
+++ b/services/cleanupService.js
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path');
const { getDb, getSetting, setSetting } = require('../db/database');
+const { BACKUP_DIR } = require('./backupService');
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -33,16 +34,23 @@ function pruneExpiredImportSessions() {
}
/**
- * Remove stale SQLite export temp files from the OS temp directory.
- * The user-db export writes bill-tracker-user-{id}-{ts}.sqlite to os.tmpdir()
- * and deletes it in the download callback, but a server crash can leave orphans.
+ * Remove stale export temp files from the OS temp directory.
+ *
+ * SQLite exports (user-db): bill-tracker-user-{id}-{ts}.sqlite
+ * Written by the /export/user-db route, deleted in the download callback.
+ * Server crash can leave orphans → swept with maxAgeHours.
+ *
+ * Excel exports (user-excel): bill-tracker-user-{id}-{ts}.xlsx
+ * Currently streamed in-memory (no disk file), but guard exists so that
+ * if the code path ever changes, xlsx files are always removed within 24 h.
*/
function pruneStaleExportFiles(maxAgeHours) {
- const tmpDir = os.tmpdir();
- const cutoff = maxAgeHours * 60 * 60 * 1000;
- const now = Date.now();
- let removed = 0;
- let errors = 0;
+ const tmpDir = os.tmpdir();
+ const sqliteCutoff = maxAgeHours * 60 * 60 * 1000;
+ const xlsxCutoff = 24 * 60 * 60 * 1000; // xlsx files must not outlive 24 h
+ const now = Date.now();
+ let removed = 0;
+ let errors = 0;
let entries;
try {
@@ -53,8 +61,13 @@ function pruneStaleExportFiles(maxAgeHours) {
}
for (const name of entries) {
- if (!name.startsWith('bill-tracker-user-') || !name.endsWith('.sqlite')) continue;
- const full = path.join(tmpDir, name);
+ if (!name.startsWith('bill-tracker-user-')) continue;
+ const isXlsx = name.endsWith('.xlsx');
+ const isSqlite = name.endsWith('.sqlite');
+ if (!isXlsx && !isSqlite) continue;
+
+ const cutoff = isXlsx ? xlsxCutoff : sqliteCutoff;
+ const full = path.join(tmpDir, name);
try {
const stat = fs.statSync(full);
if (now - stat.mtimeMs > cutoff) {
@@ -192,8 +205,7 @@ async function runAllCleanup() {
}
if (settings.backup_partials_enabled) {
- const backupDir = getSetting('backup_path') || path.join(__dirname, '..', 'backups');
- tasks.backup_partials = pruneOrphanedBackupPartials(backupDir);
+ tasks.backup_partials = pruneOrphanedBackupPartials(BACKUP_DIR);
}
if (settings.import_history_enabled) {
diff --git a/tests/backupAndCleanup.test.js b/tests/backupAndCleanup.test.js
new file mode 100644
index 0000000..f0c409f
--- /dev/null
+++ b/tests/backupAndCleanup.test.js
@@ -0,0 +1,285 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+
+// ── Test environment ─────────────────────────────────────────────────────────
+// Set env vars before any require so modules pick up the right paths.
+const testId = `backup-cleanup-test-${process.pid}`;
+const dbPath = path.join(os.tmpdir(), `${testId}.sqlite`);
+const backupPath = path.join(os.tmpdir(), `${testId}-backups`);
+
+process.env.DB_PATH = dbPath;
+process.env.BACKUP_PATH = backupPath;
+
+const { closeDb, setSetting } = require('../db/database');
+const {
+ BACKUP_DIR,
+ createBackup,
+ deleteBackup,
+ listBackups,
+ applyRetention,
+ importBackupBuffer,
+ checksumFile,
+} = require('../services/backupService');
+const {
+ validateScheduleSettings,
+ computeNextRun,
+} = require('../services/backupScheduler');
+const {
+ pruneOrphanedBackupPartials,
+ pruneStaleExportFiles,
+} = require('../services/cleanupService');
+
+// ── Teardown ─────────────────────────────────────────────────────────────────
+test.after(() => {
+ closeDb();
+ try { fs.unlinkSync(dbPath); } catch {}
+ try { fs.rmSync(backupPath, { recursive: true, force: true }); } catch {}
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// backupService
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('BACKUP_DIR resolves to the BACKUP_PATH env var', () => {
+ assert.equal(BACKUP_DIR, path.resolve(backupPath));
+});
+
+test('createBackup writes a valid .sqlite file and returns metadata', async () => {
+ const meta = await createBackup('bill-tracker-backup');
+ assert.ok(meta.id, 'has id');
+ assert.ok(meta.filename.endsWith('.sqlite'));
+ assert.equal(meta.type, 'manual');
+ assert.ok(meta.size_bytes > 0);
+ assert.match(meta.checksum, /^[a-f0-9]{64}$/);
+ assert.ok(fs.existsSync(path.join(BACKUP_DIR, meta.id)));
+});
+
+test('listBackups returns the created backup sorted newest-first', async () => {
+ const list = listBackups();
+ assert.ok(list.length >= 1);
+ assert.ok(list[0].id.startsWith('bill-tracker-backup-') || list[0].id.startsWith('scheduled-backup-'));
+});
+
+test('deleteBackup removes the file', async () => {
+ const meta = await createBackup('bill-tracker-backup');
+ const filePath = path.join(BACKUP_DIR, meta.id);
+ assert.ok(fs.existsSync(filePath));
+ deleteBackup(meta.id);
+ assert.ok(!fs.existsSync(filePath));
+});
+
+test('applyRetention keeps only the requested number of backups', async () => {
+ // Raise retention so createBackup's own internal pruning doesn't interfere.
+ setSetting('backup_schedule_retention_count', '100');
+
+ for (const b of listBackups()) { try { deleteBackup(b.id); } catch {} }
+
+ await createBackup('bill-tracker-backup');
+ await createBackup('bill-tracker-backup');
+ await createBackup('bill-tracker-backup');
+ assert.equal(listBackups().length, 3);
+
+ const { deleted } = applyRetention(1);
+ assert.equal(deleted.length, 2);
+ assert.equal(listBackups().length, 1);
+
+ setSetting('backup_schedule_retention_count', '2');
+});
+
+test('importBackupBuffer accepts a valid SQLite buffer', async () => {
+ // Create a real backup, then re-import its bytes
+ const src = await createBackup('bill-tracker-backup');
+ const buf = fs.readFileSync(path.join(BACKUP_DIR, src.id));
+ const meta = await importBackupBuffer(buf);
+ assert.equal(meta.type, 'imported');
+ assert.ok(fs.existsSync(path.join(BACKUP_DIR, meta.id)));
+});
+
+test('importBackupBuffer rejects non-SQLite data', async () => {
+ const buf = Buffer.from('this is not a sqlite database at all');
+ await assert.rejects(
+ () => importBackupBuffer(buf),
+ err => {
+ assert.ok(err.status === 400);
+ return true;
+ }
+ );
+});
+
+test('importBackupBuffer rejects a checksum mismatch', async () => {
+ const src = await createBackup('bill-tracker-backup');
+ const buf = fs.readFileSync(path.join(BACKUP_DIR, src.id));
+ await assert.rejects(
+ () => importBackupBuffer(buf, { expectedChecksum: 'a'.repeat(64) }),
+ err => {
+ assert.ok(err.status === 400);
+ assert.match(err.message, /checksum/i);
+ return true;
+ }
+ );
+});
+
+test('checksumFile returns a 64-char hex string', async () => {
+ const src = await createBackup('bill-tracker-backup');
+ const sum = checksumFile(path.join(BACKUP_DIR, src.id));
+ assert.match(sum, /^[a-f0-9]{64}$/);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// backupScheduler
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('validateScheduleSettings accepts valid daily settings', () => {
+ const s = validateScheduleSettings({ enabled: true, frequency: 'daily', time: '03:30', retention_count: '7' });
+ assert.equal(s.enabled, true);
+ assert.equal(s.frequency, 'daily');
+ assert.equal(s.time, '03:30');
+ assert.equal(s.retention_count, 7);
+});
+
+test('validateScheduleSettings rejects bad frequency', () => {
+ assert.throws(
+ () => validateScheduleSettings({ enabled: true, frequency: 'hourly', time: '02:00', retention_count: '2' }),
+ /frequency/i
+ );
+});
+
+test('validateScheduleSettings rejects bad time format', () => {
+ assert.throws(
+ () => validateScheduleSettings({ enabled: true, frequency: 'daily', time: '25:00', retention_count: '2' }),
+ /time/i
+ );
+});
+
+test('validateScheduleSettings rejects retention_count out of range', () => {
+ assert.throws(
+ () => validateScheduleSettings({ enabled: true, frequency: 'daily', time: '02:00', retention_count: '0' }),
+ /retention/i
+ );
+});
+
+test('computeNextRun returns null when disabled', () => {
+ const result = computeNextRun({ enabled: false, frequency: 'daily', time: '02:00' });
+ assert.equal(result, null);
+});
+
+test('computeNextRun returns a future ISO string when enabled', () => {
+ const from = new Date('2026-05-29T10:00:00Z');
+ const result = computeNextRun({ enabled: true, frequency: 'daily', time: '02:00' }, from);
+ assert.ok(typeof result === 'string');
+ assert.ok(new Date(result) > from);
+});
+
+test('computeNextRun advances to next day when time has already passed today', () => {
+ const from = new Date('2026-05-29T15:00:00Z'); // 3 PM UTC
+ const result = computeNextRun({ enabled: true, frequency: 'daily', time: '02:00' }, from);
+ const next = new Date(result);
+ // Next run should be 2 AM the following day, so > 24h away
+ assert.ok(next.getTime() - from.getTime() > 12 * 60 * 60 * 1000);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// cleanupService — pruneOrphanedBackupPartials
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('pruneOrphanedBackupPartials removes .partial files older than 2 hours', () => {
+ fs.mkdirSync(backupPath, { recursive: true });
+
+ const oldPartial = path.join(backupPath, 'old.partial');
+ const newPartial = path.join(backupPath, 'new.partial');
+
+ fs.writeFileSync(oldPartial, 'data');
+ fs.writeFileSync(newPartial, 'data');
+
+ // Back-date the old file's mtime by 3 hours
+ const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
+ fs.utimesSync(oldPartial, old, old);
+
+ const result = pruneOrphanedBackupPartials(backupPath);
+ assert.equal(result.removed, 1);
+ assert.equal(result.errors, 0);
+ assert.ok(!fs.existsSync(oldPartial));
+ assert.ok(fs.existsSync(newPartial));
+
+ // cleanup
+ try { fs.unlinkSync(newPartial); } catch {}
+});
+
+test('pruneOrphanedBackupPartials is a no-op for a missing directory', () => {
+ const result = pruneOrphanedBackupPartials('/nonexistent/path');
+ assert.equal(result.removed, 0);
+ assert.equal(result.errors, 0);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// cleanupService — pruneStaleExportFiles
+// ─────────────────────────────────────────────────────────────────────────────
+
+test('pruneStaleExportFiles removes old .sqlite export files', () => {
+ const tmpDir = os.tmpdir();
+ const oldFile = path.join(tmpDir, `bill-tracker-user-1-${Date.now()}.sqlite`);
+ const newFile = path.join(tmpDir, `bill-tracker-user-2-${Date.now()}.sqlite`);
+
+ fs.writeFileSync(oldFile, 'fake');
+ fs.writeFileSync(newFile, 'fake');
+
+ const old = new Date(Date.now() - 3 * 60 * 60 * 1000); // 3 h ago
+ fs.utimesSync(oldFile, old, old);
+
+ const result = pruneStaleExportFiles(2);
+ assert.ok(result.removed >= 1);
+ assert.ok(!fs.existsSync(oldFile));
+
+ try { fs.unlinkSync(newFile); } catch {}
+});
+
+test('pruneStaleExportFiles removes .xlsx files older than 24 hours', () => {
+ const tmpDir = os.tmpdir();
+ const oldXlsx = path.join(tmpDir, `bill-tracker-user-3-${Date.now()}.xlsx`);
+ const newXlsx = path.join(tmpDir, `bill-tracker-user-4-${Date.now()}.xlsx`);
+
+ fs.writeFileSync(oldXlsx, 'fake');
+ fs.writeFileSync(newXlsx, 'fake');
+
+ const old = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 h ago
+ fs.utimesSync(oldXlsx, old, old);
+
+ const result = pruneStaleExportFiles(2);
+ assert.ok(result.removed >= 1);
+ assert.ok(!fs.existsSync(oldXlsx));
+
+ try { fs.unlinkSync(newXlsx); } catch {}
+});
+
+test('pruneStaleExportFiles does not remove .xlsx files younger than 24 hours', () => {
+ const tmpDir = os.tmpdir();
+ const recentXlsx = path.join(tmpDir, `bill-tracker-user-5-${Date.now()}.xlsx`);
+
+ fs.writeFileSync(recentXlsx, 'fake');
+ // mtime stays at now — well within 24 h
+
+ const result = pruneStaleExportFiles(2);
+ assert.ok(fs.existsSync(recentXlsx));
+
+ try { fs.unlinkSync(recentXlsx); } catch {}
+});
+
+test('pruneStaleExportFiles ignores non-bill-tracker files in tmpdir', () => {
+ const tmpDir = os.tmpdir();
+ const other = path.join(tmpDir, `some-other-tool-export-${Date.now()}.sqlite`);
+
+ fs.writeFileSync(other, 'fake');
+ const old = new Date(Date.now() - 48 * 60 * 60 * 1000);
+ fs.utimesSync(other, old, old);
+
+ const result = pruneStaleExportFiles(2);
+ assert.ok(fs.existsSync(other), 'should not have deleted unrelated file');
+ assert.equal(result.errors, 0);
+
+ try { fs.unlinkSync(other); } catch {}
+});