feat(ui): show relative timestamps on Dashboard and Downloads tables

Converts the Source Health last-check column and the Downloads created-at
column from locale date strings to human-relative times ("6 hours ago").
The absolute timestamp is preserved as a native browser tooltip so full
precision is still one hover away; detail modals keep the absolute form.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:07:19 -04:00
parent 9f8ce67084
commit ae140dbb0a
4 changed files with 45 additions and 9 deletions
+3 -1
View File
@@ -128,10 +128,12 @@ async def retry_download(download_id: int):
if download.status != DownloadStatus.FAILED: if download.status != DownloadStatus.FAILED:
return jsonify({"error": "Can only retry failed downloads"}), 400 return jsonify({"error": "Can only retry failed downloads"}), 400
# Reset status to queued # Reset status to queued and clear prior-run timestamps
download.status = DownloadStatus.QUEUED download.status = DownloadStatus.QUEUED
download.error_type = None download.error_type = None
download.error_message = None download.error_message = None
download.started_at = None
download.completed_at = None
await session.commit() await session.commit()
# Queue Celery task to retry the download # Queue Celery task to retry the download
+1
View File
@@ -265,6 +265,7 @@ async def _download_source_async(source_id: int, download_id: int = None) -> dic
download.status = DownloadStatus.RUNNING download.status = DownloadStatus.RUNNING
download.started_at = utcnow() download.started_at = utcnow()
download.completed_at = None
else: else:
logger.warning(f"Download {download_id} not found, creating new record") logger.warning(f"Download {download_id} not found, creating new record")
download = None download = None
+1 -1
View File
@@ -317,7 +317,7 @@
<td> <td>
<v-chip color="error" size="small">{{ source.error_count }}</v-chip> <v-chip color="error" size="small">{{ source.error_count }}</v-chip>
</td> </td>
<td>{{ formatDate(source.last_check) }}</td> <td><span :title="formatDate(source.last_check)">{{ source.last_check ? formatRelativeTime(source.last_check) : 'Never' }}</span></td>
<td> <td>
<v-btn <v-btn
size="small" size="small"
+40 -7
View File
@@ -148,7 +148,7 @@
</template> </template>
<template v-slot:item.source="{ item }"> <template v-slot:item.source="{ item }">
<span class="text-caption">{{ getSourceLabel(item.source_id) }}</span> <span class="text-caption">{{ getSourceLabel(item) }}</span>
</template> </template>
<template v-slot:item.url="{ item }"> <template v-slot:item.url="{ item }">
@@ -175,7 +175,7 @@
</template> </template>
<template v-slot:item.created_at="{ item }"> <template v-slot:item.created_at="{ item }">
{{ formatDate(item.created_at) }} <span :title="formatDate(item.created_at)">{{ formatRelativeTime(item.created_at) }}</span>
</template> </template>
<template v-slot:item.duration="{ item }"> <template v-slot:item.duration="{ item }">
@@ -234,7 +234,7 @@
<tbody> <tbody>
<tr> <tr>
<td class="font-weight-bold" width="150">Source</td> <td class="font-weight-bold" width="150">Source</td>
<td>{{ getSourceLabel(selectedDownload.source_id) }}</td> <td>{{ getSourceLabel(selectedDownload) }}</td>
</tr> </tr>
<tr> <tr>
<td class="font-weight-bold">URL</td> <td class="font-weight-bold">URL</td>
@@ -444,14 +444,36 @@ function formatDate(dateStr) {
return new Date(dateStr).toLocaleString() return new Date(dateStr).toLocaleString()
} }
function formatRelativeTime(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
const now = new Date()
const diffMs = now - date
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Just now'
if (diffMins < 60) return `${diffMins} min${diffMins !== 1 ? 's' : ''} ago`
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`
if (diffDays < 7) return `${diffDays} day${diffDays !== 1 ? 's' : ''} ago`
return date.toLocaleDateString()
}
function formatDuration(download) { function formatDuration(download) {
if (!download.started_at || !download.completed_at) return '-' if (!download.started_at) return '-'
const start = new Date(download.started_at) const start = new Date(download.started_at)
const end = new Date(download.completed_at) // Running rows elapse against "now"; terminal rows need a valid completed_at.
const end = download.status === 'running' || !download.completed_at
? new Date()
: new Date(download.completed_at)
const seconds = Math.round((end - start) / 1000) const seconds = Math.round((end - start) / 1000)
if (seconds < 0) return '-'
if (seconds < 60) return `${seconds}s` if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60) const minutes = Math.floor(seconds / 60)
return `${minutes}m ${seconds % 60}s` if (minutes < 60) return `${minutes}m ${seconds % 60}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
} }
function formatErrorType(errorType) { function formatErrorType(errorType) {
@@ -465,7 +487,18 @@ function truncateUrl(url) {
return url.substring(0, 37) + '...' return url.substring(0, 37) + '...'
} }
function getSourceLabel(sourceId) { function getSourceLabel(item) {
// Prefer the subscription_name/platform embedded in the download record —
// these survive source deletion and don't require the sources store to be populated.
if (item && typeof item === 'object') {
if (item.subscription_name && item.platform) {
return `${item.subscription_name}:${item.platform}`
}
if (item.platform) {
return `(unnamed):${item.platform}`
}
}
const sourceId = typeof item === 'number' ? item : item?.source_id
if (!sourceId) return 'N/A' if (!sourceId) return 'N/A'
const source = sourcesStore.sources.find(s => s.id === sourceId) const source = sourcesStore.sources.find(s => s.id === sourceId)
if (source) { if (source) {