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:
parent
6b0c86b73c
commit
db5f765d84
|
|
@ -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() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Wide desktop: full five-lane view */}
|
||||
<div className="hidden min-[1400px]:grid min-[1400px]:grid-cols-5 gap-4">
|
||||
{grouped.map(lane => <PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />)}
|
||||
</div>
|
||||
|
||||
{/* 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} />)}
|
||||
{/* Size the board to its populated lanes so sparse roadmaps stay readable. */}
|
||||
<div className={`grid gap-4 ${laneGridClass}`}>
|
||||
{visibleLanes.map(lane => (
|
||||
<PriorityLane key={lane.key} lane={lane} items={lane.items} {...laneProps} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ function start() {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
computeNextRun,
|
||||
getScheduleStatus,
|
||||
reloadSchedule,
|
||||
runScheduledBackupNow,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
});
|
||||
Loading…
Reference in New Issue