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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user