feat(roadmap): size grid from populated lanes + db cleanup fixes

- Roadmap grid now adapts columns based on how many priority lanes have items
- With only LOW items, lane uses full width instead of narrow 5-column slot
- cleanupService: use BACKUP_DIR import, handle .xlsx export file cleanup
- backupScheduler: export computeNextRun for external use
- Added backupAndCleanup.test.js for coverage
This commit is contained in:
null 2026-05-30 13:04:27 -05:00
parent 6b0c86b73c
commit db5f765d84
4 changed files with 323 additions and 53 deletions

View File

@ -300,6 +300,14 @@ export default function RoadmapPage() {
...lane, ...lane,
items: items.filter(item => laneForPriority(item.priority) === lane.key), 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 defaultOpenCards = isDesktop && allExpanded;
const laneProps = { defaultOpenCards, forceKey }; const laneProps = { defaultOpenCards, forceKey };
@ -361,47 +369,11 @@ export default function RoadmapPage() {
</Button> </Button>
</div> </div>
{/* Wide desktop: full five-lane view */} {/* Size the board to its populated lanes so sparse roadmaps stay readable. */}
<div className="hidden min-[1400px]:grid min-[1400px]:grid-cols-5 gap-4"> <div className={`grid gap-4 ${laneGridClass}`}>
{grouped.map(lane => <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />)} {visibleLanes.map(lane => (
</div> <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
))}
{/* Desktop: balanced three-column view for admin shell widths */}
<div className="hidden lg:grid lg:grid-cols-3 min-[1400px]:hidden gap-4">
<div className="space-y-4">
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
<div className="space-y-4">
{grouped.filter(l => l.key === 'medium' || l.key === 'low').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
<div className="space-y-4">
{grouped.filter(l => l.key === 'niceToHave').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
</div>
{/* Tablet: 2 columns */}
<div className="hidden sm:grid sm:grid-cols-2 lg:hidden gap-4">
<div className="space-y-4">
{grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
<div className="space-y-4">
{grouped.filter(l => l.key === 'medium' || l.key === 'low' || l.key === 'niceToHave').map(lane =>
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
)}
</div>
</div>
{/* Mobile: single column */}
<div className="sm:hidden space-y-3">
{grouped.map(lane => <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />)}
</div> </div>
</> </>
)} )}

View File

@ -155,6 +155,7 @@ function start() {
} }
module.exports = { module.exports = {
computeNextRun,
getScheduleStatus, getScheduleStatus,
reloadSchedule, reloadSchedule,
runScheduledBackupNow, runScheduledBackupNow,

View File

@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { getDb, getSetting, setSetting } = require('../db/database'); const { getDb, getSetting, setSetting } = require('../db/database');
const { BACKUP_DIR } = require('./backupService');
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@ -33,16 +34,23 @@ function pruneExpiredImportSessions() {
} }
/** /**
* Remove stale SQLite export temp files from the OS temp directory. * Remove stale 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. * 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) { function pruneStaleExportFiles(maxAgeHours) {
const tmpDir = os.tmpdir(); const tmpDir = os.tmpdir();
const cutoff = maxAgeHours * 60 * 60 * 1000; const sqliteCutoff = maxAgeHours * 60 * 60 * 1000;
const now = Date.now(); const xlsxCutoff = 24 * 60 * 60 * 1000; // xlsx files must not outlive 24 h
let removed = 0; const now = Date.now();
let errors = 0; let removed = 0;
let errors = 0;
let entries; let entries;
try { try {
@ -53,8 +61,13 @@ function pruneStaleExportFiles(maxAgeHours) {
} }
for (const name of entries) { for (const name of entries) {
if (!name.startsWith('bill-tracker-user-') || !name.endsWith('.sqlite')) continue; if (!name.startsWith('bill-tracker-user-')) continue;
const full = path.join(tmpDir, name); 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 { try {
const stat = fs.statSync(full); const stat = fs.statSync(full);
if (now - stat.mtimeMs > cutoff) { if (now - stat.mtimeMs > cutoff) {
@ -192,8 +205,7 @@ async function runAllCleanup() {
} }
if (settings.backup_partials_enabled) { if (settings.backup_partials_enabled) {
const backupDir = getSetting('backup_path') || path.join(__dirname, '..', 'backups'); tasks.backup_partials = pruneOrphanedBackupPartials(BACKUP_DIR);
tasks.backup_partials = pruneOrphanedBackupPartials(backupDir);
} }
if (settings.import_history_enabled) { if (settings.import_history_enabled) {

View File

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