feat(downloads-tab): A+B dashboard improvements — row restyle + date-grouped sections with failed-pinned

Operator-flagged 2026-05-27: the Downloads subtab "doesn't feel like a
dashboard" — status was a tiny mdi icon at the far left, platform chip
was neutral-tonal, errors were plain orange text floating on the right,
and all 28 rows from the same hour visually had the same priority.

**Row restyle (A):**
- 4px colored left-edge bar by status (success/error/info/warning/grey)
  — visually scannable at the edge without parsing the chip text
- Status chip with text label (Completed/Failed/Running/Queued/Skipped)
  + leading icon, tonal-colored. Replaces the bare mdi-icon.
- Platform chip swapped to the color-coded subscriptions/PlatformChip
  (Patreon=red mdi-patreon, SubscribeStar=amber, HentaiFoundry=purple,
  Discord=indigo, Pixiv=blue, DeviantArt=green).
- File count: tonal info chip when > 0, dim middle-dot when 0 (so
  scheduled "no-change" scans don't dominate the column visually).
- Error: red tonal pill chip with leading icon, truncated to 60 chars
  with full text in the title tooltip. Replaces plain text.
- Per-row actions (hidden at 50% opacity, fade to full on row hover):
  Retry (only when status=error AND source_id known — hits
  POST /api/sources/<id>/check via the existing sources.checkNow),
  Details (opens the detail modal), Open artist (navigates to the
  artist page). Clicks stop-propagation so they don't bubble to the
  row click.

**Date-grouped sections (B):**
- Events are bucketed into four sections: Today / Yesterday /
  Last 7 days / Earlier. Empty buckets are skipped. Buckets boundaries
  are computed against the operator's local-time start-of-day so
  "Today" matches their intuition.
- Each section has a collapsible header with a row-count chip + a
  red "failed in this section" chip when any failures are in scope.
- Within each section, status='error' rows are pinned to the top
  (operator's eye lands on failures first; successful scans flow
  below).
- Collapsed state persists across refresh within the SubscriptionsView
  lifetime (reactive object, default all-expanded).

DownloadEventRow grid widened to accommodate the status chip + actions
column. PolyMasonry-style ellipsis on the artist link prevents long
names from breaking the layout.

No new endpoints; the Retry path reuses the existing /api/sources/<id>/check
flow (the source-check endpoint was already in place, just not wired
into a per-row button).
This commit is contained in:
2026-05-27 22:18:02 -04:00
parent df6d89cb59
commit b1b129ce9f
2 changed files with 292 additions and 42 deletions
@@ -20,15 +20,44 @@
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="store.events.length === 0" class="fc-dl__empty">
<div v-else-if="filteredEvents.length === 0" class="fc-dl__empty">
<p>No download events match the current filter.</p>
</div>
<div v-else>
<DownloadEventRow
v-for="e in filteredEvents" :key="e.id" :event="e"
@open="openDetail"
/>
<section
v-for="g in groups" :key="g.key"
class="fc-dl__group"
>
<header
class="fc-dl__group-head"
role="button" tabindex="0"
@click="toggle(g.key)" @keydown.enter="toggle(g.key)"
>
<v-icon size="small" class="fc-dl__group-chev">
{{ collapsed[g.key] ? 'mdi-chevron-right' : 'mdi-chevron-down' }}
</v-icon>
<span class="fc-dl__group-label">{{ g.label }}</span>
<span class="fc-dl__group-counts">
<v-chip
v-if="g.failedCount > 0"
size="x-small" color="error" variant="tonal"
prepend-icon="mdi-alert-circle"
>{{ g.failedCount }}</v-chip>
<v-chip size="x-small" variant="tonal">
{{ g.items.length }}
{{ g.items.length === 1 ? 'event' : 'events' }}
</v-chip>
</span>
</header>
<div v-if="!collapsed[g.key]" class="fc-dl__group-body">
<DownloadEventRow
v-for="e in g.items" :key="e.id" :event="e"
@open="openDetail"
/>
</div>
</section>
<div class="fc-dl__sentinel">
<v-btn v-if="store.hasMore" variant="text" @click="store.loadMore()" :loading="store.loading">
Load more
@@ -45,7 +74,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useDownloadsStore } from '../../stores/downloads.js'
@@ -59,6 +88,16 @@ const route = useRoute()
const store = useDownloadsStore()
const filterModel = ref({ ...store.filter })
// Each group's collapsed state persists across refreshes for the
// lifetime of the SubscriptionsView (operator-friendly default: all
// expanded; collapse what you don't care about right now).
const collapsed = reactive({
today: false, yesterday: false, week: false, earlier: false,
})
function toggle(key) {
collapsed[key] = !collapsed[key]
}
async function refresh() {
await Promise.all([
store.loadFirst(),
@@ -91,6 +130,48 @@ const filteredEvents = computed(() => {
return arr
})
// Group events by relative date bucket and pin failed runs to the
// top of each bucket. Buckets boundaries are computed against the
// operator's local-time start-of-day so "Today" matches their
// intuition regardless of the event's stored UTC timestamp.
const groups = computed(() => {
const now = new Date()
const startOfToday = new Date(
now.getFullYear(), now.getMonth(), now.getDate(),
).getTime()
const startOfYesterday = startOfToday - 24 * 3600 * 1000
const startOfWeek = startOfToday - 7 * 24 * 3600 * 1000
const buckets = { today: [], yesterday: [], week: [], earlier: [] }
for (const e of filteredEvents.value) {
const t = new Date(e.started_at).getTime()
if (t >= startOfToday) buckets.today.push(e)
else if (t >= startOfYesterday) buckets.yesterday.push(e)
else if (t >= startOfWeek) buckets.week.push(e)
else buckets.earlier.push(e)
}
function withFailedPinned(items) {
const fail = items.filter((e) => e.status === 'error')
const rest = items.filter((e) => e.status !== 'error')
return [...fail, ...rest]
}
const meta = [
{ key: 'today', label: 'Today' },
{ key: 'yesterday', label: 'Yesterday' },
{ key: 'week', label: 'Last 7 days' },
{ key: 'earlier', label: 'Earlier' },
]
return meta
.map(({ key, label }) => {
const items = withFailedPinned(buckets[key])
const failedCount = items.filter((e) => e.status === 'error').length
return { key, label, items, failedCount }
})
.filter((g) => g.items.length > 0)
})
watch(filterModel, async (m) => {
await store.applyFilter({
status: m.status,
@@ -120,4 +201,29 @@ async function openDetail(id) {
.fc-dl__sentinel {
display: flex; justify-content: center; padding: 1rem 0;
}
.fc-dl__group { margin-bottom: 12px; }
.fc-dl__group-head {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
background: rgb(var(--v-theme-on-surface) / 0.04);
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.fc-dl__group-head:hover {
background: rgb(var(--v-theme-on-surface) / 0.08);
}
.fc-dl__group-chev {
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-dl__group-label {
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
flex: 1;
}
.fc-dl__group-counts {
display: flex; gap: 6px; align-items: center;
}
.fc-dl__group-body { margin-top: 4px; }
</style>