From db5f765d847f8a411de2a1913f6edf38cec0e0b0 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 30 May 2026 13:04:27 -0500 Subject: [PATCH] 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 --- client/pages/RoadmapPage.jsx | 54 ++----- services/backupScheduler.js | 1 + services/cleanupService.js | 36 +++-- tests/backupAndCleanup.test.js | 285 +++++++++++++++++++++++++++++++++ 4 files changed, 323 insertions(+), 53 deletions(-) create mode 100644 tests/backupAndCleanup.test.js 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 => )} -
- - {/* Desktop: balanced three-column view for admin shell widths */} -
-
- {grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane => - - )} -
-
- {grouped.filter(l => l.key === 'medium' || l.key === 'low').map(lane => - - )} -
-
- {grouped.filter(l => l.key === 'niceToHave').map(lane => - - )} -
-
- - {/* Tablet: 2 columns */} -
-
- {grouped.filter(l => l.key === 'critical' || l.key === 'high').map(lane => - - )} -
-
- {grouped.filter(l => l.key === 'medium' || l.key === 'low' || l.key === 'niceToHave').map(lane => - - )} -
-
- - {/* Mobile: single column */} -
- {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 {} +});