personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

convey: canonical relative-time helper (Python + JS) and audited rollout

Add `relative_time(seconds)` to `convey/utils.py` and a JS counterpart at
`convey/static/relative-time.js` exposing `window.relativeTime(ms)`. Both
implement the canon ladder from Founder UX review T4 (≤60s seconds; ≤1h
minutes; ≤1d hours; ≤1w days; ≤4w weeks; 4w–2mo "1 month"; ≥2mo months).

Convert the audited rolled-up read sites — observer last-seen, support
ticket ages, stats recency, home routine last-run, paired device ages,
status_pane wsLastMessageRaw, health connection-health indicators — to
route through the canonical helpers. Delete now-redundant per-file
helpers (`formatTimeAgo`, `fmtRelativeTime`, `timeAgo`). The existing
`time_since(epoch)` is preserved as a thin wrapper around
`relative_time`; the `apps/link/call.py` `_relative_time(iso)` wrapper
likewise preserves its public signature and "never" / parse-fallback
semantics, but drops the non-canon "just now" tier.

Live-operation duration counters (active agent / import elapsed,
ws uptime, app.js notification helper, status_pane "connected for X"
strings) are explicitly out of scope and untouched.

Test plan:
- `.venv/bin/pytest tests/test_convey_utils.py -v` — 5 passed (existing
`test_time_since` plus new `test_relative_time` covering full canon
table including 28-day and 60-day boundary triplets).
- `.venv/bin/ruff check .` and `.venv/bin/ruff format --check .` pass.
- `scripts/check_layer_hygiene.py` and `scripts/gate_agents_rename.py`
pass.
- `grep -rn 'formatTimeAgo|fmtRelativeTime|\btimeAgo\b' apps/ convey/`
is empty.
- Manual JS test page at `convey/static/tests/relative-time.html`
mirrors the canon table for the JS helper.

Note: `make ci` blocked by pre-existing `onnxruntime` import in
`observe.transcribe.overlap` — unrelated to this change. Component
gates run directly all pass.

+215 -90
+7 -6
apps/health/workspace.html
··· 1552 1552 } 1553 1553 1554 1554 if (state.lastAgentFinishTs) { 1555 - const ago = formatElapsed(Math.floor((Date.now() - state.lastAgentFinishTs) / 1000)); 1555 + const ago = relativeTime(Date.now() - state.lastAgentFinishTs); 1556 1556 const el = ensureChild(idx++); 1557 1557 el.textContent = 'last agent finished ' + ago + ' ago'; 1558 1558 el.style.color = ''; ··· 1655 1655 } 1656 1656 1657 1657 const icon = e.type === 'agent' ? '⚙' : e.type === 'import' ? '↓' : '⚠'; 1658 - const ago = formatElapsed(Math.floor((Date.now() - e.ts) / 1000)); 1658 + const ago = relativeTime(Date.now() - e.ts); 1659 1659 const info = row.children[0]; 1660 1660 info.textContent = ''; 1661 1661 info.appendChild(document.createTextNode(icon + ' ')); ··· 2301 2301 if (!observer.last_seen) return 'never seen'; 2302 2302 const deltaMs = Date.now() - observer.last_seen; 2303 2303 if (deltaMs < 0) return 'last seen from future'; 2304 - return `last seen ${formatElapsed(Math.floor(deltaMs / 1000))} ago`; 2304 + return `last seen ${relativeTime(deltaMs)} ago`; 2305 2305 } 2306 2306 2307 2307 function renderRegisteredObservers(observers) { ··· 3122 3122 return; 3123 3123 } 3124 3124 const ago = Math.floor((Date.now() - state.lastEventTs) / 1000); 3125 + const agoText = relativeTime(ago * 1000); 3125 3126 if (ago >= 60) { 3126 - el.textContent = `⚠ Disconnected (${formatElapsed(ago)})`; 3127 + el.textContent = `⚠ Disconnected (${agoText})`; 3127 3128 el.className = 'connection-indicator disconnected'; 3128 3129 } else if (ago >= 30) { 3129 - el.textContent = `Stale (${formatElapsed(ago)})`; 3130 + el.textContent = `Stale (${agoText})`; 3130 3131 el.className = 'connection-indicator stale'; 3131 3132 } else { 3132 - el.textContent = `Updated ${formatElapsed(ago)} ago`; 3133 + el.textContent = `Updated ${agoText} ago`; 3133 3134 el.className = 'connection-indicator'; 3134 3135 } 3135 3136 }
+3 -15
apps/home/routes.py
··· 19 19 20 20 from convey.apps import _resolve_attention 21 21 from convey.bridge import get_cached_state 22 - from convey.utils import DATE_RE, format_date 22 + from convey.utils import DATE_RE, format_date, relative_time 23 23 from think import skills as think_skills 24 24 from think.awareness import get_current 25 25 from think.capture_health import get_capture_health ··· 1095 1095 continue 1096 1096 1097 1097 delta = now - last_run_dt 1098 - if delta.total_seconds() < 60: 1099 - run_time_display = "just now" 1100 - elif delta.total_seconds() < 3600: 1101 - run_time_display = f"{int(delta.total_seconds() / 60)}m ago" 1102 - else: 1103 - run_time_display = f"{int(delta.total_seconds() / 3600)}h ago" 1098 + run_time_display = f"{relative_time(delta.total_seconds())} ago" 1104 1099 1105 1100 routine_id = value.get("id", "") 1106 1101 output_dir = journal / "routines" / routine_id ··· 1299 1294 if last_observe_ts: 1300 1295 try: 1301 1296 delta = now - datetime.fromtimestamp(last_observe_ts) 1302 - if delta.total_seconds() < 60: 1303 - last_observe_relative = "just now" 1304 - elif delta.total_seconds() < 3600: 1305 - mins = int(delta.total_seconds() / 60) 1306 - last_observe_relative = f"{mins}m ago" 1307 - else: 1308 - hours = int(delta.total_seconds() / 3600) 1309 - last_observe_relative = f"{hours}h ago" 1297 + last_observe_relative = f"{relative_time(delta.total_seconds())} ago" 1310 1298 except Exception: 1311 1299 logger.warning( 1312 1300 "home: failed to compute last_observe_relative", exc_info=True
+3 -10
apps/link/call.py
··· 14 14 15 15 import typer 16 16 17 + from convey.utils import relative_time 17 18 from think.link.auth import AuthorizedClients 18 19 from think.link.ca import generate_nonce, load_or_generate_ca 19 20 from think.link.nonces import NONCE_TTL_SECONDS, NonceStore ··· 65 66 except ValueError: 66 67 return iso 67 68 now = dt.datetime.now(dt.UTC) 68 - delta = (now - then).total_seconds() 69 - if delta < 15: 70 - return "just now" 71 - if delta < 60: 72 - return f"{int(delta)} seconds ago" 73 - if delta < 3600: 74 - return f"{int(delta // 60)} minutes ago" 75 - if delta < 86400: 76 - return f"{int(delta // 3600)} hours ago" 77 - return f"{int(delta // 86400)} days ago" 69 + delta_seconds = max(0, (now - then).total_seconds()) 70 + return f"{relative_time(delta_seconds)} ago" 78 71 79 72 80 73 @app.command()
+14 -15
apps/observer/workspace.html
··· 582 582 } 583 583 584 584 function statsHTML(observer, statusClass) { 585 + const lastSeenBase = observer.last_seen ? `${relativeTime(Date.now() - observer.last_seen)} ago` : 'never'; 586 + const lastSeen = statusClass === 'stale' 587 + ? `${lastSeenBase} — stale` 588 + : statusClass === 'disconnected' 589 + ? `${lastSeenBase} — offline` 590 + : lastSeenBase; 585 591 return `<dl> 586 - <div><dt>Last seen</dt><dd data-last-seen="${observer.last_seen || ''}" data-state="${statusClass}">${formatTimeAgo(observer.last_seen, statusClass)}</dd></div> 592 + <div><dt>Last seen</dt><dd data-last-seen="${observer.last_seen || ''}" data-state="${statusClass}">${lastSeen}</dd></div> 587 593 <div><dt>Segments (5-min chunks)</dt><dd>${observer.stats?.segments_received ?? 0}</dd></div> 588 594 <div><dt>Data</dt><dd>${formatBytes(observer.stats?.bytes_received || 0)}</dd></div> 589 595 </dl>`; ··· 721 727 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; 722 728 } 723 729 724 - function formatTimeAgo(timestamp, state) { 725 - if (!timestamp) return 'never'; 726 - const seconds = Math.floor((Date.now() - timestamp) / 1000); 727 - let text; 728 - if (seconds < 60) text = 'just now'; 729 - else if (seconds < 3600) text = `${Math.floor(seconds / 60)} min ago`; 730 - else if (seconds < 86400) text = `${Math.floor(seconds / 3600)} hours ago`; 731 - else text = `${Math.floor(seconds / 86400)} days ago`; 732 - if (state === 'stale') return `${text} — stale`; 733 - if (state === 'disconnected') return `${text} — offline`; 734 - return text; 735 - } 736 - 737 730 async function loadObservers() { 738 731 lastPollTime = Date.now(); 739 732 if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } ··· 797 790 for (const span of spans) { 798 791 const lastSeen = parseInt(span.getAttribute('data-last-seen'), 10); 799 792 if (!lastSeen) continue; 800 - span.textContent = formatTimeAgo(lastSeen, span.getAttribute('data-state')); 793 + const text = `${relativeTime(Date.now() - lastSeen)} ago`; 794 + const state = span.getAttribute('data-state'); 795 + span.textContent = state === 'stale' 796 + ? `${text} — stale` 797 + : state === 'disconnected' 798 + ? `${text} — offline` 799 + : text; 801 800 } 802 801 } 803 802
+1 -12
apps/stats/static/dashboard.js
··· 50 50 return String(Math.round(value)); 51 51 } 52 52 53 - function fmtRelativeTime(isoString) { 54 - const seconds = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); 55 - if (seconds < 60) return 'just now'; 56 - const minutes = Math.floor(seconds / 60); 57 - if (minutes < 60) return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago'); 58 - const hours = Math.floor(minutes / 60); 59 - if (hours < 24) return hours + (hours === 1 ? ' hour ago' : ' hours ago'); 60 - const days = Math.floor(hours / 24); 61 - return days + (days === 1 ? ' day ago' : ' days ago'); 62 - } 63 - 64 53 function fmtDay(raw) { 65 54 const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; 66 55 if (raw.length === 8) { ··· 591 580 const freshnessEl = document.getElementById('statsFreshness'); 592 581 if (freshnessEl) { 593 582 freshnessEl.textContent = stats.generated_at 594 - ? 'Stats generated ' + fmtRelativeTime(stats.generated_at) 583 + ? 'Stats generated ' + relativeTime(Date.now() - new Date(stats.generated_at).getTime()) + ' ago' 595 584 : ''; 596 585 const refreshLink = el('a', { 597 586 className: 'stats-refresh',
+12 -14
apps/support/workspace.html
··· 648 648 649 649 list.innerHTML = tickets.map(t => { 650 650 const statusClass = 'support-status-' + (t.status || 'open').replace(/[^a-z-]/g, ''); 651 + const createdAt = t.created_at 652 + ? `${relativeTime(Date.now() - new Date(t.created_at + (t.created_at.includes('Z') ? '' : 'Z')).getTime())} ago` 653 + : ''; 651 654 return `<div class="support-ticket" data-id="${t.id}" tabindex="0" role="button"> 652 655 <div class="support-ticket-header"> 653 656 <span class="support-ticket-subject">${esc(t.subject || 'Untitled')}</span> ··· 656 659 <div class="support-ticket-meta"> 657 660 <span class="support-ticket-id">#${t.id}</span> &middot; 658 661 ${esc(t.product || '')} &middot; 659 - ${timeAgo(t.created_at)} 662 + ${createdAt} 660 663 </div> 661 664 </div>`; 662 665 }).join(''); ··· 711 714 try { 712 715 const t = await window.apiJson('/app/support/api/tickets/' + encodeURIComponent(id)); 713 716 const statusClass = 'support-status-' + (t.status || 'open').replace(/[^a-z-]/g, ''); 717 + const createdAt = t.created_at 718 + ? `${relativeTime(Date.now() - new Date(t.created_at + (t.created_at.includes('Z') ? '' : 'Z')).getTime())} ago` 719 + : ''; 714 720 715 721 let html = ` 716 722 <button class="support-detail-back" id="back-to-list">&larr; back to tickets</button> 717 723 <h2>${esc(t.subject || 'Untitled')} <span class="support-status ${statusClass}">${esc(t.status || 'open')}</span></h2> 718 - <div class="support-ticket-meta">#${t.id} &middot; ${esc(t.product || '')} &middot; ${esc(t.severity || '')} &middot; ${timeAgo(t.created_at)}</div> 724 + <div class="support-ticket-meta">#${t.id} &middot; ${esc(t.product || '')} &middot; ${esc(t.severity || '')} &middot; ${createdAt}</div> 719 725 <div class="support-message" style="margin-top:1rem;"> 720 726 <p>${esc(t.description || '')}</p> 721 727 </div>`; ··· 724 730 if (msgs.length) { 725 731 html += '<h3 style="margin-top:1.5rem;">thread</h3>'; 726 732 msgs.forEach(m => { 733 + const messageCreatedAt = m.created_at 734 + ? `${relativeTime(Date.now() - new Date(m.created_at + (m.created_at.includes('Z') ? '' : 'Z')).getTime())} ago` 735 + : ''; 727 736 let attachHtml = ''; 728 737 const atts = m.attachments || []; 729 738 if (atts.length) { ··· 734 743 attachHtml += '</div>'; 735 744 } 736 745 html += `<div class="support-message"> 737 - <div class="support-message-meta">${esc(m.handle || 'unknown')} &middot; ${timeAgo(m.created_at)}</div> 746 + <div class="support-message-meta">${esc(m.handle || 'unknown')} &middot; ${messageCreatedAt}</div> 738 747 <p>${esc(m.content || '')}</p> 739 748 ${attachHtml} 740 749 </div>`; ··· 1064 1073 el.textContent = msg; 1065 1074 el.className = 'support-status-msg ' + type; 1066 1075 } 1067 - } 1068 - 1069 - function timeAgo(dateStr) { 1070 - if (!dateStr) return ''; 1071 - const now = Date.now(); 1072 - const then = new Date(dateStr + (dateStr.includes('Z') ? '' : 'Z')).getTime(); 1073 - const s = Math.floor((now - then) / 1000); 1074 - if (s < 60) return 'just now'; 1075 - if (s < 3600) return Math.floor(s / 60) + 'm ago'; 1076 - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; 1077 - return Math.floor(s / 86400) + 'd ago'; 1078 1076 } 1079 1077 1080 1078 // Diagnostics
+8 -2
convey/static/pairing.js
··· 100 100 101 101 const details = document.createElement("p"); 102 102 details.className = "pairing-device-details"; 103 + const pairedAt = device.paired_at 104 + ? `${relativeTime(Date.now() - new Date(device.paired_at).getTime())} ago` 105 + : "unknown"; 106 + const lastSeenAt = device.last_seen_at 107 + ? `${relativeTime(Date.now() - new Date(device.last_seen_at).getTime())} ago` 108 + : "never"; 103 109 details.textContent = [ 104 110 device.platform || "unknown", 105 - `paired ${device.paired_at || "unknown"}`, 106 - `last seen ${device.last_seen_at || "never"}`, 111 + `paired ${pairedAt}`, 112 + `last seen ${lastSeenAt}`, 107 113 ].join(" · "); 108 114 meta.appendChild(details); 109 115
+43
convey/static/relative-time.js
··· 1 + // SPDX-License-Identifier: AGPL-3.0-only 2 + // Copyright (c) 2026 sol pbc 3 + 4 + function relativeTime(ms) { 5 + let seconds = Math.floor(ms / 1000); 6 + if (!Number.isFinite(seconds) || seconds < 0) seconds = 0; 7 + 8 + let value; 9 + let unit; 10 + if (seconds < 60) { 11 + value = seconds; 12 + unit = 'second'; 13 + } else { 14 + const minutes = Math.floor(seconds / 60); 15 + if (minutes < 60) { 16 + value = minutes; 17 + unit = 'minute'; 18 + } else { 19 + const hours = Math.floor(minutes / 60); 20 + if (hours < 24) { 21 + value = hours; 22 + unit = 'hour'; 23 + } else { 24 + const days = Math.floor(hours / 24); 25 + if (days < 7) { 26 + value = days; 27 + unit = 'day'; 28 + } else if (days < 28) { 29 + value = Math.floor(days / 7); 30 + unit = 'week'; 31 + } else if (days < 60) { 32 + return '1 month'; 33 + } else { 34 + value = Math.floor(days / 30); 35 + unit = 'month'; 36 + } 37 + } 38 + } 39 + } 40 + return `${value} ${unit}${value === 1 ? '' : 's'}`; 41 + } 42 + 43 + window.relativeTime = relativeTime;
+1
convey/static/tests/README.md
··· 1 1 Open `convey/static/tests/api.html` in a browser; each assertion reports pass/fail inline. 2 + Open `convey/static/tests/relative-time.html` in a browser; each assertion reports pass/fail inline. 2 3 Open `convey/static/tests/surface-state.html` in a browser; each assertion reports pass/fail inline. 3 4 Open `convey/static/tests/ws-listen.html` in a browser; each assertion reports pass/fail inline. 4 5 Open `convey/static/tests/register-task.html` in a browser; each assertion reports pass/fail inline.
+58
convey/static/tests/relative-time.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <meta charset="utf-8"> 4 + <title>relative-time.js smoke</title> 5 + <style> 6 + body { font: 14px/1.4 sans-serif; padding: 16px; } 7 + .pass { color: #166534; } 8 + .fail { color: #b91c1c; } 9 + </style> 10 + <h1>relative-time.js smoke</h1> 11 + <div id="results"></div> 12 + <script src="../relative-time.js"></script> 13 + <script> 14 + const results = document.getElementById('results'); 15 + let failures = 0; 16 + 17 + function assert(name, condition, detail) { 18 + const row = document.createElement('div'); 19 + row.className = condition ? 'pass' : 'fail'; 20 + row.textContent = `${condition ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`; 21 + results.appendChild(row); 22 + if (!condition) failures += 1; 23 + } 24 + 25 + function check(ms, expected) { 26 + const actual = window.relativeTime(ms); 27 + assert(`${ms}ms`, actual === expected, `expected "${expected}", got "${actual}"`); 28 + } 29 + 30 + function run() { 31 + check(-1_000, '0 seconds'); 32 + check(Number.POSITIVE_INFINITY, '0 seconds'); 33 + check(0, '0 seconds'); 34 + check(1_000, '1 second'); 35 + check(59_000, '59 seconds'); 36 + check(60_000, '1 minute'); 37 + check(119_000, '1 minute'); 38 + check(120_000, '2 minutes'); 39 + check(3_599_000, '59 minutes'); 40 + check(3_600_000, '1 hour'); 41 + check(7_199_000, '1 hour'); 42 + check(7_200_000, '2 hours'); 43 + check(86_399_000, '23 hours'); 44 + check(86_400_000, '1 day'); 45 + check(604_799_000, '6 days'); 46 + check(604_800_000, '1 week'); 47 + check(1_209_600_000, '2 weeks'); 48 + check(2_419_199_000, '3 weeks'); 49 + check(2_419_200_000, '1 month'); 50 + check(5_183_999_000, '1 month'); 51 + check(5_184_000_000, '2 months'); 52 + check(31_536_000_000, '12 months'); 53 + assert('summary', failures === 0, failures ? `${failures} failure(s)` : 'all checks passed'); 54 + } 55 + 56 + run(); 57 + </script> 58 + </html>
+1
convey/templates/app.html
··· 8 8 9 9 <!-- Error handling FIRST - catches all subsequent errors --> 10 10 <script src="{{ url_for('root.static', filename='error-handler.js') }}"></script> 11 + <script src="{{ url_for('root.static', filename='relative-time.js') }}"></script> 11 12 12 13 <!-- Embed facets data for immediate client-side access --> 13 14 <script>
+1
convey/templates/pairing.html
··· 66 66 </main> 67 67 68 68 <script src="{{ url_for('root.static', filename='pairing-qr.js') }}"></script> 69 + <script src="{{ url_for('root.static', filename='relative-time.js') }}"></script> 69 70 <script src="{{ url_for('root.static', filename='pairing.js') }}"></script> 70 71 </body> 71 72 </html>
+6 -6
convey/templates/status_pane.html
··· 163 163 if (wsLastMessageRaw) { 164 164 if (metrics.lastMessageMs !== null) { 165 165 const seconds = Math.floor(metrics.lastMessageMs / 1000); 166 - wsLastMessageRaw.textContent = seconds === 0 ? 'just now' : `${formatDuration(seconds)} ago`; 166 + wsLastMessageRaw.textContent = `${relativeTime(seconds * 1000)} ago`; 167 167 } else if (metrics.connected) { 168 168 wsLastMessageRaw.textContent = 'no messages yet'; 169 169 } else { ··· 283 283 284 284 const escape = window.AppServices.escapeHtml; 285 285 list.innerHTML = notifs.map(n => { 286 - const timeAgo = window.AppServices.notifications._getRelativeTime(n.ts); 286 + const relativeAge = window.AppServices.notifications._getRelativeTime(n.ts); 287 287 const src = escape(n.source || ''); 288 288 const msg = escape((n.message || '').slice(0, 120)); 289 289 return `<div style="display: flex; align-items: center; gap: 8px; padding: 4px 0;"> 290 - <span style="color: #9ca3af; font-size: 11px; flex-shrink: 0;">${timeAgo}</span> 290 + <span style="color: #9ca3af; font-size: 11px; flex-shrink: 0;">${relativeAge}</span> 291 291 <code style="flex-shrink: 0; font-size: 11px;">${src}</code> 292 292 <span style="color: #fca5a5; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${msg}</span> 293 293 </div>`; ··· 309 309 } 310 310 311 311 container.innerHTML = history.map(n => { 312 - const timeAgo = window.AppServices.notifications._getRelativeTime(n.timestamp); 312 + const relativeAge = window.AppServices.notifications._getRelativeTime(n.timestamp); 313 313 314 314 if (n.action) { 315 315 return `<a href="${escape(n.action)}" class="status-pane-history-item" style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; margin: 0 -8px; border-radius: 4px; text-decoration: none; color: inherit;" 316 316 ${n.facet ? `onclick="window.selectFacet && window.selectFacet('${escape(n.facet)}')"` : ''}> 317 317 <span style="font-size: 16px; flex-shrink: 0;">${escape(n.icon)}</span> 318 318 <span style="font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escape(n.title)}</span> 319 - <span style="color: #9ca3af; font-size: 11px; flex-shrink: 0;">${timeAgo}</span> 319 + <span style="color: #9ca3af; font-size: 11px; flex-shrink: 0;">${relativeAge}</span> 320 320 </a>`; 321 321 } else { 322 322 return `<div style="display: flex; align-items: center; gap: 8px; padding: 6px 8px; margin: 0 -8px;"> 323 323 <span style="font-size: 16px; flex-shrink: 0;">${escape(n.icon)}</span> 324 324 <span style="font-weight: 500; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escape(n.title)}</span> 325 - <span style="color: #9ca3af; font-size: 11px; flex-shrink: 0;">${timeAgo}</span> 325 + <span style="color: #9ca3af; font-size: 11px; flex-shrink: 0;">${relativeAge}</span> 326 326 </div>`; 327 327 } 328 328 }).join('');
+25 -9
convey/utils.py
··· 2 2 # Copyright (c) 2026 sol pbc 3 3 4 4 import json 5 + import math 5 6 import re 6 7 import time 7 8 from datetime import datetime ··· 64 65 return date_str 65 66 66 67 67 - def time_since(epoch: int) -> str: 68 - """Return short human readable age for ``epoch`` seconds.""" 69 - seconds = int(time.time() - epoch) 68 + def _plural(value: int, unit: str) -> str: 69 + return f"{value} {unit}{'s' if value != 1 else ''}" 70 + 71 + 72 + def relative_time(seconds: int | float) -> str: 73 + """Return canonical human readable duration for ``seconds``.""" 74 + if not math.isfinite(seconds) or seconds < 0: 75 + seconds = 0 76 + seconds = int(seconds) 70 77 if seconds < 60: 71 - return f"{seconds} seconds ago" 78 + return _plural(seconds, "second") 72 79 minutes = seconds // 60 73 80 if minutes < 60: 74 - return f"{minutes} minute{'s' if minutes != 1 else ''} ago" 81 + return _plural(minutes, "minute") 75 82 hours = minutes // 60 76 83 if hours < 24: 77 - return f"{hours} hour{'s' if hours != 1 else ''} ago" 84 + return _plural(hours, "hour") 78 85 days = hours // 24 79 86 if days < 7: 80 - return f"{days} day{'s' if days != 1 else ''} ago" 81 - weeks = days // 7 82 - return f"{weeks} week{'s' if weeks != 1 else ''} ago" 87 + return _plural(days, "day") 88 + if days < 28: 89 + return _plural(days // 7, "week") 90 + if days < 60: 91 + return "1 month" 92 + return _plural(days // 30, "month") 93 + 94 + 95 + def time_since(epoch: int) -> str: 96 + """Return short human readable age for ``epoch`` seconds.""" 97 + delta_seconds = max(0, time.time() - epoch) 98 + return f"{relative_time(delta_seconds)} ago" 83 99 84 100 85 101 def spawn_agent(
+32 -1
tests/test_convey_utils.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - from convey.utils import format_date, format_date_short, time_since 4 + import math 5 + 6 + from convey.utils import format_date, format_date_short, relative_time, time_since 5 7 from think.utils import day_path 6 8 7 9 ··· 48 50 def test_time_since(monkeypatch): 49 51 monkeypatch.setattr("time.time", lambda: 120) 50 52 assert time_since(60) == "1 minute ago" 53 + 54 + 55 + def test_relative_time(): 56 + cases = [ 57 + (-1, "0 seconds"), 58 + (math.inf, "0 seconds"), 59 + (0, "0 seconds"), 60 + (1, "1 second"), 61 + (59, "59 seconds"), 62 + (60, "1 minute"), 63 + (119, "1 minute"), 64 + (120, "2 minutes"), 65 + (3599, "59 minutes"), 66 + (3600, "1 hour"), 67 + (7199, "1 hour"), 68 + (7200, "2 hours"), 69 + (86399, "23 hours"), 70 + (86400, "1 day"), 71 + (604799, "6 days"), 72 + (604800, "1 week"), 73 + (1209600, "2 weeks"), 74 + (2419199, "3 weeks"), 75 + (2419200, "1 month"), 76 + (5183999, "1 month"), 77 + (5184000, "2 months"), 78 + (31536000, "12 months"), 79 + ] 80 + for seconds, expected in cases: 81 + assert relative_time(seconds) == expected 51 82 52 83 53 84 def test_list_day_folders(tmp_path, monkeypatch):