diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 5d34852f2..555d52b2e 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -1045,7 +1045,15 @@ async function runCommand(command, args, cwd, raw, defaultValue) { core.output(intel.intelQuery(term, planningDir), raw); } else if (subcommand === 'status') { const planningDir = path.join(cwd, '.planning'); - core.output(intel.intelStatus(planningDir), raw); + const status = intel.intelStatus(planningDir); + if (!raw && status.files) { + for (const file of Object.values(status.files)) { + if (file.updated_at) { + file.updated_at = core.timeAgo(new Date(file.updated_at)); + } + } + } + core.output(status, raw); } else if (subcommand === 'diff') { const planningDir = path.join(cwd, '.planning'); core.output(intel.intelDiff(planningDir), raw); diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index ebc969ca8..c1a637b24 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -1560,6 +1560,32 @@ function atomicWriteFileSync(filePath, content, encoding = 'utf-8') { } } +/** + * Format a Date as a fuzzy relative time string (e.g. "5 minutes ago"). + * @param {Date} date + * @returns {string} + */ +function timeAgo(date) { + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds} seconds ago`; + const minutes = Math.floor(seconds / 60); + if (minutes === 1) return '1 minute ago'; + if (minutes < 60) return `${minutes} minutes ago`; + const hours = Math.floor(minutes / 60); + if (hours === 1) return '1 hour ago'; + if (hours < 24) return `${hours} hours ago`; + const days = Math.floor(hours / 24); + if (days === 1) return '1 day ago'; + if (days < 30) return `${days} days ago`; + const months = Math.floor(days / 30); + if (months === 1) return '1 month ago'; + if (months < 12) return `${months} months ago`; + const years = Math.floor(days / 365); + if (years === 1) return '1 year ago'; + return `${years} years ago`; +} + module.exports = { output, error, @@ -1607,4 +1633,5 @@ module.exports = { getAgentsDir, checkAgentsInstalled, atomicWriteFileSync, + timeAgo, }; diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 6550c8811..39ba9e2a2 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -31,6 +31,7 @@ const { findProjectRoot, detectSubRepos, planningDir, + timeAgo, } = require('../get-shit-done/bin/lib/core.cjs'); // ─── loadConfig ──────────────────────────────────────────────────────────────── @@ -1750,3 +1751,103 @@ describe('planningDir', () => { ); }); }); + +// ─── timeAgo ────────────────────────────────────────────────────────────────── + +describe('timeAgo', () => { + const now = () => Date.now(); + const dateAt = (msAgo) => new Date(now() - msAgo); + + // ─── seconds boundary ─── + test('returns "just now" for dates under 5 seconds old', () => { + assert.strictEqual(timeAgo(dateAt(0)), 'just now'); + assert.strictEqual(timeAgo(dateAt(4_000)), 'just now'); + }); + + test('returns "N seconds ago" between 5 and 59 seconds', () => { + assert.strictEqual(timeAgo(dateAt(5_000)), '5 seconds ago'); + assert.strictEqual(timeAgo(dateAt(30_000)), '30 seconds ago'); + assert.strictEqual(timeAgo(dateAt(59_000)), '59 seconds ago'); + }); + + // ─── minutes boundary ─── + test('transitions to minutes at 60 seconds', () => { + assert.strictEqual(timeAgo(dateAt(60_000)), '1 minute ago'); + }); + + test('uses singular "1 minute ago" for exactly one minute', () => { + assert.strictEqual(timeAgo(dateAt(60_000)), '1 minute ago'); + assert.strictEqual(timeAgo(dateAt(119_000)), '1 minute ago'); + }); + + test('uses plural "N minutes ago" for 2-59 minutes', () => { + assert.strictEqual(timeAgo(dateAt(120_000)), '2 minutes ago'); + assert.strictEqual(timeAgo(dateAt(5 * 60_000)), '5 minutes ago'); + assert.strictEqual(timeAgo(dateAt(59 * 60_000)), '59 minutes ago'); + }); + + // ─── hours boundary ─── + test('transitions to hours at 60 minutes', () => { + assert.strictEqual(timeAgo(dateAt(60 * 60_000)), '1 hour ago'); + }); + + test('uses singular "1 hour ago" for exactly one hour', () => { + assert.strictEqual(timeAgo(dateAt(60 * 60_000)), '1 hour ago'); + assert.strictEqual(timeAgo(dateAt(119 * 60_000)), '1 hour ago'); + }); + + test('uses plural "N hours ago" for 2-23 hours', () => { + assert.strictEqual(timeAgo(dateAt(2 * 3600_000)), '2 hours ago'); + assert.strictEqual(timeAgo(dateAt(23 * 3600_000)), '23 hours ago'); + }); + + // ─── days boundary ─── + test('transitions to days at 24 hours', () => { + assert.strictEqual(timeAgo(dateAt(24 * 3600_000)), '1 day ago'); + }); + + test('uses singular "1 day ago" for exactly one day', () => { + assert.strictEqual(timeAgo(dateAt(24 * 3600_000)), '1 day ago'); + }); + + test('uses plural "N days ago" for 2-29 days', () => { + assert.strictEqual(timeAgo(dateAt(2 * 86400_000)), '2 days ago'); + assert.strictEqual(timeAgo(dateAt(29 * 86400_000)), '29 days ago'); + }); + + // ─── months boundary ─── + test('transitions to months at 30 days', () => { + assert.strictEqual(timeAgo(dateAt(30 * 86400_000)), '1 month ago'); + }); + + test('uses singular "1 month ago" for exactly one month (30 days)', () => { + assert.strictEqual(timeAgo(dateAt(30 * 86400_000)), '1 month ago'); + assert.strictEqual(timeAgo(dateAt(59 * 86400_000)), '1 month ago'); + }); + + test('uses plural "N months ago" for 2-11 months', () => { + assert.strictEqual(timeAgo(dateAt(60 * 86400_000)), '2 months ago'); + assert.strictEqual(timeAgo(dateAt(180 * 86400_000)), '6 months ago'); + }); + + // ─── years boundary ─── + test('transitions to years at 365 days', () => { + assert.strictEqual(timeAgo(dateAt(365 * 86400_000)), '1 year ago'); + }); + + test('uses singular "1 year ago" for exactly one year', () => { + assert.strictEqual(timeAgo(dateAt(365 * 86400_000)), '1 year ago'); + }); + + test('uses plural "N years ago" for 2+ years', () => { + assert.strictEqual(timeAgo(dateAt(2 * 365 * 86400_000)), '2 years ago'); + assert.strictEqual(timeAgo(dateAt(10 * 365 * 86400_000)), '10 years ago'); + }); + + // ─── edge cases ─── + test('handles future dates as "just now" (negative elapsed)', () => { + // A date 5 seconds in the future has negative elapsed time, which floors to a negative + // number of seconds and hits the "under 5 seconds" branch. + assert.strictEqual(timeAgo(new Date(Date.now() + 5_000)), 'just now'); + }); +});