From 42ddac999688698d07a334e5b8459ecd08a4355c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 18:13:19 -0400 Subject: [PATCH 01/16] fix(subscriptions): fixed-height hub so only the tab content scrolls The whole view scrolled instead of just the subscription list. Made the hub a viewport-height flex column (tabs stay fixed) with the v-window as the single internal scroll container; the per-tab sticky control bars now pin to the window top (top:48px -> 0). Operator-flagged 2026-05-28. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/subscriptions/DownloadsTab.vue | 2 +- .../subscriptions/SubscriptionsTab.vue | 9 +++++---- frontend/src/views/SubscriptionsView.vue | 16 +++++++++++++++- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue index 36c2718..88e8da6 100644 --- a/frontend/src/components/subscriptions/DownloadsTab.vue +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -334,7 +334,7 @@ async function openDetail(id) { background so rows don't bleed through. */ .fc-dl__sticky { position: sticky; - top: 48px; + top: 0; z-index: 3; background: rgb(var(--v-theme-background)); padding: 8px 0 10px; diff --git a/frontend/src/components/subscriptions/SubscriptionsTab.vue b/frontend/src/components/subscriptions/SubscriptionsTab.vue index 2fe5cc8..bbd89a8 100644 --- a/frontend/src/components/subscriptions/SubscriptionsTab.vue +++ b/frontend/src/components/subscriptions/SubscriptionsTab.vue @@ -509,11 +509,12 @@ async function bulkDelete() { .fc-subs__bar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; - /* Sticky below the top nav so the filter/search controls stay - reachable while scrolling a long subscription list. Opaque bg so - table rows don't bleed through. Operator-flagged 2026-05-28. */ + /* Sticky to the top of the hub's scroll window so the filter/search + controls stay reachable while scrolling a long subscription list. + Opaque bg so table rows don't bleed through. Operator-flagged + 2026-05-28. */ position: sticky; - top: 48px; + top: 0; z-index: 3; background: rgb(var(--v-theme-background)); padding: 8px 0 1rem; diff --git a/frontend/src/views/SubscriptionsView.vue b/frontend/src/views/SubscriptionsView.vue index 0f2098b..318e8e8 100644 --- a/frontend/src/views/SubscriptionsView.vue +++ b/frontend/src/views/SubscriptionsView.vue @@ -21,7 +21,7 @@ - + @@ -52,8 +52,22 @@ const { tab } = useTabQuery(VALID_TABS, 'subscriptions') .fc-subs-shell { max-width: 1600px; margin-inline: auto; + /* Fixed-height hub: the tabs (and each tab's sticky control bar) stay + put while ONLY the tab content scrolls — previously the whole view + scrolled instead of just the subscription list (operator-flagged + 2026-05-28). 64px = the TopNav height (AppShell .fc-content pad-top). */ + height: calc(100vh - 64px); + display: flex; + flex-direction: column; + overflow: hidden; } .fc-subs-tabs { + flex: 0 0 auto; border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18); } +.fc-subs-window { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} From 75b6b8056e9f944255104126655925a50db784e4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 18:18:34 -0400 Subject: [PATCH 02/16] feat(posts): plain bold titles; reserve View-original to the post card; provenance links to the posts feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Post titles arrived as stored HTML (e.g. ) rendering as literal markup. New toPlainText() strips tags; titles render plain + bold (ProvenancePanel and PostCard). - Removed "View original post" from the provenance panel (modal) — the open-original button lives on the PostCard (the post view). - Provenance "View post" now navigates to the /posts feed (post_id query), not the gallery image grid. (Feed in-context landing lands next.) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/modal/ProvenancePanel.vue | 20 +++++++++++-------- frontend/src/components/posts/PostCard.vue | 18 ++++++++++------- frontend/src/utils/htmlSanitize.js | 11 ++++++++++ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/modal/ProvenancePanel.vue b/frontend/src/components/modal/ProvenancePanel.vue index 507a9e6..b4d641e 100644 --- a/frontend/src/components/modal/ProvenancePanel.vue +++ b/frontend/src/components/modal/ProvenancePanel.vue @@ -19,7 +19,7 @@ {{ postDate(e) }}
- {{ e.post.title || `Post ${e.post.external_post_id}` }} + {{ postTitle(e) }}
@@ -30,12 +30,8 @@
-

- {{ post.post_title }} +

+ {{ plainTitle }}

Post {{ post.external_post_id }} @@ -80,8 +80,8 @@
-

- {{ post.post_title }} +

+ {{ plainTitle }}

Post {{ post.external_post_id }} @@ -125,7 +125,7 @@ import { computed, ref } from 'vue' import { RouterLink } from 'vue-router' import { usePostsStore } from '../../stores/posts.js' -import { sanitizeHtml } from '../../utils/htmlSanitize.js' +import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js' import PostEmptyThumbs from './PostEmptyThumbs.vue' import PostImageGrid from './PostImageGrid.vue' @@ -149,6 +149,10 @@ const merged = computed(() => detail.value || props.post) const images = computed(() => merged.value.thumbnails || []) const attachments = computed(() => merged.value.attachments || []) +// Titles can arrive as stored HTML (e.g. ""); render as +// plain text — the CSS makes the title bold. +const plainTitle = computed(() => toPlainText(props.post.post_title)) + // Compact-view hero+rail derived from the feed-shape (capped 6). const hero = computed(() => props.post.thumbnails?.[0]) const rail = computed(() => (props.post.thumbnails || []).slice(1, 4)) @@ -312,7 +316,7 @@ function formatBytes (n) { .fc-post-card__title { font-family: 'Fraunces', Georgia, serif; - font-size: 18px; font-weight: 500; + font-size: 18px; font-weight: 700; margin: 0 0 8px 0; color: rgb(var(--v-theme-on-surface)); display: -webkit-box; @@ -369,7 +373,7 @@ function formatBytes (n) { .fc-post-card__title-full { font-family: 'Fraunces', Georgia, serif; font-size: 22px; - font-weight: 500; + font-weight: 700; margin: 0; color: rgb(var(--v-theme-on-surface)); } diff --git a/frontend/src/utils/htmlSanitize.js b/frontend/src/utils/htmlSanitize.js index 7978fca..5733c52 100644 --- a/frontend/src/utils/htmlSanitize.js +++ b/frontend/src/utils/htmlSanitize.js @@ -29,6 +29,17 @@ export function sanitizeHtml (html) { return doc.body.innerHTML } +// Strip all tags + decode entities to plain text. Used for titles that +// arrive as stored HTML (e.g. "Edelgard at the Sex-Arcade") +// which must render as text, not literal markup. DOMParser yields an inert +// document (no script execution, no resource loads), so reading textContent +// is safe. +export function toPlainText (html) { + if (typeof html !== 'string' || !html) return '' + const doc = new DOMParser().parseFromString(html, 'text/html') + return (doc.body.textContent || '').trim() +} + function _scrubNode (node) { const children = Array.from(node.children) for (const child of children) { From 972d9014ced7ec18fab3fd10973b423eb00b6ee4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 18:58:05 -0400 Subject: [PATCH 03/16] feat(showcase): IR-parity hover animation on masonry thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FC already matched IR's staggered entry fade-in; this adds the missing piece — hover zoom (scale 1.03) + brighten (1.1) on each thumbnail, with overflow:hidden so the scaled image clips to the rounded card. Disabled under prefers-reduced-motion. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/discovery/MasonryGrid.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/discovery/MasonryGrid.vue b/frontend/src/components/discovery/MasonryGrid.vue index c482f2a..ecc43dc 100644 --- a/frontend/src/components/discovery/MasonryGrid.vue +++ b/frontend/src/components/discovery/MasonryGrid.vue @@ -100,10 +100,17 @@ onUnmounted(() => observer && observer.disconnect()) .fc-masonry__item { display: block; padding: 0; border: 0; background: none; cursor: pointer; width: 100%; + overflow: hidden; border-radius: 4px; } .fc-masonry__item img { - width: 100%; height: auto; display: block; border-radius: 4px; + width: 100%; height: auto; display: block; background: rgb(var(--v-theme-surface-light)); + /* IR-parity hover: zoom + brighten the thumbnail (style.css ~1772). */ + transition: transform 0.3s ease, filter 0.3s ease; +} +.fc-masonry__item:hover img { + transform: scale(1.03); + filter: brightness(1.1); } .fc-masonry__sentinel { display: flex; justify-content: center; padding: 32px 0; min-height: 60px; @@ -127,5 +134,7 @@ onUnmounted(() => observer && observer.disconnect()) animation: none; opacity: 1; } + .fc-masonry__item img { transition: none; } + .fc-masonry__item:hover img { transform: none; } } From 8c3900b9980cbdef20840be0d5bcd1993376cab7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 19:01:04 -0400 Subject: [PATCH 04/16] =?UTF-8?q?feat(showcase):=20dramatic=203D=20cascade?= =?UTF-8?q?=20entry=20=E2=80=94=20tiles=20tip=20back=20then=20settle=20int?= =?UTF-8?q?o=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaced the subtle 12px fade with a perspective rotateX(-28deg) tilt that flips up and settles flat with a slight overshoot, staggered 70ms so tiles cascade in one at a time. Makes the showcase read as an experience rather than a quiet fade. Tunable (tilt/stagger/duration); reduced-motion safe. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/discovery/MasonryGrid.vue | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/discovery/MasonryGrid.vue b/frontend/src/components/discovery/MasonryGrid.vue index ecc43dc..7375be6 100644 --- a/frontend/src/components/discovery/MasonryGrid.vue +++ b/frontend/src/components/discovery/MasonryGrid.vue @@ -117,22 +117,35 @@ onUnmounted(() => observer && observer.disconnect()) } .fc-masonry__end { text-align: center; padding: 32px 0; } -/* IR-parity stagger fade-in for showcase entry / shuffle. 60ms between - items, 250ms each — matches IR's `itemFadeIn` keyframe (style.css - ~line 1834). Honors prefers-reduced-motion. */ +/* Cascade entry: each tile flips up out of a backward tilt and settles + into place, one at a time — more pronounced than a plain fade so the + showcase reads as an "experience" (operator-flagged 2026-05-28). The + `both` fill holds the hidden/tilted 0% state until each tile's staggered + turn; the cubic-bezier overshoots slightly past flat then settles. + Honors prefers-reduced-motion. Tunables: tilt (-28deg), stagger (70ms), + duration (0.6s). */ @keyframes fc-masonry-item-in { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } + 0% { + opacity: 0; + transform: perspective(1000px) rotateX(-28deg) translateY(26px) scale(0.95); + } + 55% { opacity: 1; } + 100% { + opacity: 1; + transform: perspective(1000px) rotateX(0deg) translateY(0) scale(1); + } } .fc-masonry__item--anim { - animation: fc-masonry-item-in 0.25s ease forwards; - animation-delay: calc(var(--stagger-index, 0) * 60ms); - opacity: 0; + transform-origin: center top; + backface-visibility: hidden; + animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both; + animation-delay: calc(var(--stagger-index, 0) * 70ms); } @media (prefers-reduced-motion: reduce) { .fc-masonry__item--anim { animation: none; opacity: 1; + transform: none; } .fc-masonry__item img { transition: none; } .fc-masonry__item:hover img { transform: none; } From c87e8e0932e95c77843aee72f2f4b7c6287e36f2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 19:36:42 -0400 Subject: [PATCH 05/16] fix(ui): render absolute timestamps in the viewer's local timezone Download event times showed raw UTC wall-clock (iso.slice). Added formatDateTime()/formatLocalDate() (local tz, robust to naive vs tz-aware ISO) and applied them to the download row + detail modal datetimes and the credential/artist date displays. formatPostDate stays UTC (date-only, locale-stable, unit-tested). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/artist/ArtistHeader.vue | 3 +- .../credentials/PlatformCredentialRow.vue | 5 ++-- .../downloads/DownloadDetailModal.vue | 3 +- .../components/downloads/DownloadEventRow.vue | 5 ++-- .../subscriptions/CredentialCard.vue | 4 +-- frontend/src/utils/date.js | 29 +++++++++++++++++++ 6 files changed, 40 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/artist/ArtistHeader.vue b/frontend/src/components/artist/ArtistHeader.vue index 93f4062..a2b1348 100644 --- a/frontend/src/components/artist/ArtistHeader.vue +++ b/frontend/src/components/artist/ArtistHeader.vue @@ -35,6 +35,7 @@ diff --git a/frontend/src/components/downloads/DownloadDetailModal.vue b/frontend/src/components/downloads/DownloadDetailModal.vue index e8c4697..260bf5e 100644 --- a/frontend/src/components/downloads/DownloadDetailModal.vue +++ b/frontend/src/components/downloads/DownloadDetailModal.vue @@ -7,7 +7,7 @@

- {{ event.started_at }} → {{ event.finished_at || '(running)' }} + {{ formatDateTime(event.started_at) }} → {{ event.finished_at ? formatDateTime(event.finished_at) : '(running)' }} ({{ fmtDuration(event.summary?.duration_seconds) }})

@@ -105,6 +105,7 @@ import { toast } from '../../utils/toast.js' import { computed } from 'vue' import { copyText } from '../../utils/clipboard.js' +import { formatDateTime } from '../../utils/date.js' const props = defineProps({ event: { type: Object, default: null } }) const emit = defineEmits(['close']) diff --git a/frontend/src/components/downloads/DownloadEventRow.vue b/frontend/src/components/downloads/DownloadEventRow.vue index c18b873..8a5a6fb 100644 --- a/frontend/src/components/downloads/DownloadEventRow.vue +++ b/frontend/src/components/downloads/DownloadEventRow.vue @@ -93,6 +93,7 @@ import { RouterLink } from 'vue-router' import PlatformChip from '../subscriptions/PlatformChip.vue' import { useSourcesStore } from '../../stores/sources.js' import { downloadStatusColor, downloadStatusIcon, downloadStatusLabel } from '../../utils/downloadStatus.js' +import { formatDateTime } from '../../utils/date.js' const props = defineProps({ event: { type: Object, required: true } }) defineEmits(['open']) @@ -105,9 +106,7 @@ const statusIcon = computed(() => downloadStatusIcon(props.event.status)) const statusLabel = computed(() => downloadStatusLabel(props.event.status)) function fmtTime(iso) { - if (!iso) return '—' - // 2026-05-27 23:36 — second granularity is in the row's title attr - return iso.slice(0, 16).replace('T', ' ') + return iso ? formatDateTime(iso) : '—' } function fmtDuration(sec) { if (sec == null) return '—' diff --git a/frontend/src/components/subscriptions/CredentialCard.vue b/frontend/src/components/subscriptions/CredentialCard.vue index 182b09e..ed2be30 100644 --- a/frontend/src/components/subscriptions/CredentialCard.vue +++ b/frontend/src/components/subscriptions/CredentialCard.vue @@ -102,6 +102,7 @@ import { toast } from '../../utils/toast.js' import { computed, ref } from 'vue' import PlatformChip from './PlatformChip.vue' import { useCredentialsStore } from '../../stores/credentials.js' +import { formatLocalDate } from '../../utils/date.js' const props = defineProps({ platform: { type: Object, required: true }, @@ -195,8 +196,7 @@ const howToFallback = computed(() => { }) function fmtDate(iso) { - if (!iso) return '—' - return iso.slice(0, 10) + return iso ? formatLocalDate(iso) : '—' } diff --git a/frontend/src/utils/date.js b/frontend/src/utils/date.js index 16f6fd3..48e9ddc 100644 --- a/frontend/src/utils/date.js +++ b/frontend/src/utils/date.js @@ -32,3 +32,32 @@ export function formatRelative(iso, opts = {}) { if (future) return diff <= 0 ? 'imminent' : `in ${body}` return `${body} ago` } + +// Parse a backend timestamp as a UTC instant. Backend serializes tz-aware +// UTC (…+00:00); if a value ever arrives without a tz designator we treat +// it as UTC so it still converts to the viewer's zone correctly. +function _parseUtc (iso) { + if (typeof iso !== 'string' || !iso) return null + const hasTz = /([zZ])$|([+-]\d{2}:?\d{2})$/.test(iso) + const d = new Date(hasTz ? iso : `${iso}Z`) + return isNaN(d.getTime()) ? null : d +} + +// Local-timezone date + time, e.g. "May 28, 7:30 PM". Use for absolute +// timestamps that were previously shown as raw UTC ISO. +export function formatDateTime (iso) { + const d = _parseUtc(iso) + if (!d) return '' + return d.toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }) +} + +// Local-timezone date only, e.g. "May 28, 2026". +export function formatLocalDate (iso) { + const d = _parseUtc(iso) + if (!d) return '' + return d.toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + }) +} From 9e74c80e2f827acef9f10c54dd0a72cd9501f203 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 19:36:49 -0400 Subject: [PATCH 06/16] feat(downloads): front-and-center "active now" panel with live elapsed timers New ActiveDownloadsPanel pinned at the top of the Downloads tab shows what's running (pulsing dot + live mm:ss timer counting from started_at) and what's queued, so the operator can see activity at a glance without the running filter. Backed by store.loadActive() which fetches running + pending independent of the feed filter; refreshed every 4s by the existing live poll. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../subscriptions/ActiveDownloadsPanel.vue | 133 ++++++++++++++++++ .../components/subscriptions/DownloadsTab.vue | 11 +- frontend/src/stores/downloads.js | 17 ++- 3 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/subscriptions/ActiveDownloadsPanel.vue diff --git a/frontend/src/components/subscriptions/ActiveDownloadsPanel.vue b/frontend/src/components/subscriptions/ActiveDownloadsPanel.vue new file mode 100644 index 0000000..e35abd6 --- /dev/null +++ b/frontend/src/components/subscriptions/ActiveDownloadsPanel.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue index 88e8da6..a1f5a15 100644 --- a/frontend/src/components/subscriptions/DownloadsTab.vue +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -43,6 +43,8 @@

+ + {{ String(store.error) }} @@ -124,6 +126,7 @@ import DownloadDetailModal from '../downloads/DownloadDetailModal.vue' import DownloadStatChips from './DownloadStatChips.vue' import DownloadActivitySparkline from './DownloadActivitySparkline.vue' import FailingSourcesCard from './FailingSourcesCard.vue' +import ActiveDownloadsPanel from './ActiveDownloadsPanel.vue' import MaintenanceMenu from './MaintenanceMenu.vue' import DownloadsFilterPopover from './DownloadsFilterPopover.vue' @@ -160,6 +163,7 @@ async function refresh() { store.loadStats(24), store.loadActivity(24), store.loadFailing(), + store.loadActive(), ]) } @@ -218,9 +222,10 @@ function startPolling() { if (pollId) return pollId = setInterval(async () => { if (document.hidden) return - // Refresh stats + sparkline cheaply every tick; only reload the event - // list + failing rollup when there's active work (keeps idle ticks light). - await Promise.all([store.loadStats(24), store.loadActivity(24)]) + // Refresh stats + sparkline + active panel every tick (so new runs + // surface within 4s even from idle); only reload the full event list + + // failing rollup when there's active work (keeps idle ticks light). + await Promise.all([store.loadStats(24), store.loadActivity(24), store.loadActive()]) if (liveActive.value) await Promise.all([store.loadFirst(), store.loadFailing()]) }, POLL_MS) } diff --git a/frontend/src/stores/downloads.js b/frontend/src/stores/downloads.js index 564cb68..940d124 100644 --- a/frontend/src/stores/downloads.js +++ b/frontend/src/stores/downloads.js @@ -18,6 +18,10 @@ export const useDownloadsStore = defineStore('downloads', () => { const stats = ref({ pending: 0, running: 0, ok: 0, error: 0, skipped: 0 }) const activity = ref({ hours: 24, buckets: [] }) const failing = ref([]) + // Running + queued events, fetched independent of the feed's filter so + // the "active now" panel always reflects what's happening regardless of + // how the operator has filtered the historical list below. + const activeEvents = ref([]) function _params(extra = {}) { const out = { limit: 50, ...extra } @@ -75,10 +79,19 @@ export const useDownloadsStore = defineStore('downloads', () => { return failing.value } + async function loadActive() { + const [running, pending] = await Promise.all([ + api.get('/api/downloads', { params: { status: 'running', limit: 50 } }), + api.get('/api/downloads', { params: { status: 'pending', limit: 50 } }), + ]) + activeEvents.value = [...running, ...pending] + return activeEvents.value + } + return { events, cursor, hasMore, filter, selected, loading, error, stats, - activity, failing, + activity, failing, activeEvents, loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats, - loadActivity, loadFailing, + loadActivity, loadFailing, loadActive, } }) From 35fe420701efe16cc00c2095870754336fc44503 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 20:03:32 -0400 Subject: [PATCH 07/16] feat(maintenance): live queue-depth bar under the backfill buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each maintenance button (ML backfill, centroid recompute → ml queue; thumbnail backfill → thumbnail queue) now shows a status bar with the live pending count for its Celery queue, so the operator can see work is already queued/running before re-triggering and piling on. MaintenancePanel polls /api/system/activity/queues every 4s; QueueStatusBar reads the depth and turns warning-colored when the queue is busy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../settings/CentroidRecomputeCard.vue | 2 + .../components/settings/MLBackfillCard.vue | 2 + .../components/settings/MaintenancePanel.vue | 18 +++++ .../components/settings/QueueStatusBar.vue | 69 +++++++++++++++++++ .../settings/ThumbnailBackfillCard.vue | 2 + 5 files changed, 93 insertions(+) create mode 100644 frontend/src/components/settings/QueueStatusBar.vue diff --git a/frontend/src/components/settings/CentroidRecomputeCard.vue b/frontend/src/components/settings/CentroidRecomputeCard.vue index 1f49210..832db7d 100644 --- a/frontend/src/components/settings/CentroidRecomputeCard.vue +++ b/frontend/src/components/settings/CentroidRecomputeCard.vue @@ -11,6 +11,7 @@ mdi-vector-triangle Recompute centroids Enqueued. + @@ -19,6 +20,7 @@ import { toast } from '../../utils/toast.js' import { ref } from 'vue' import { useMLStore } from '../../stores/ml.js' +import QueueStatusBar from './QueueStatusBar.vue' const store = useMLStore() const busy = ref(false) const done = ref(false) diff --git a/frontend/src/components/settings/MLBackfillCard.vue b/frontend/src/components/settings/MLBackfillCard.vue index 1023811..9535648 100644 --- a/frontend/src/components/settings/MLBackfillCard.vue +++ b/frontend/src/components/settings/MLBackfillCard.vue @@ -10,6 +10,7 @@ mdi-refresh Run backfill now Enqueued. + @@ -18,6 +19,7 @@ import { toast } from '../../utils/toast.js' import { ref } from 'vue' import { useMLStore } from '../../stores/ml.js' +import QueueStatusBar from './QueueStatusBar.vue' const store = useMLStore() const busy = ref(false) const done = ref(false) diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index 42c775d..0258bc1 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -23,6 +23,8 @@ diff --git a/frontend/src/components/settings/ThumbnailBackfillCard.vue b/frontend/src/components/settings/ThumbnailBackfillCard.vue index 37aab2a..7d73e0d 100644 --- a/frontend/src/components/settings/ThumbnailBackfillCard.vue +++ b/frontend/src/components/settings/ThumbnailBackfillCard.vue @@ -11,6 +11,7 @@ mdi-image-refresh Run backfill now Enqueued. + @@ -19,6 +20,7 @@ import { toast } from '../../utils/toast.js' import { ref } from 'vue' import { useThumbnailsStore } from '../../stores/thumbnails.js' +import QueueStatusBar from './QueueStatusBar.vue' const store = useThumbnailsStore() const busy = ref(false) const done = ref(false) From c95b7602940d68b1661a1f970a2d884fd3ca4b45 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 21:12:18 -0400 Subject: [PATCH 08/16] feat(posts): in-context anchored feed with bidirectional infinite scroll Provenance "View post" deep-links to /posts?post_id=X, which now opens the feed centered on that post with infinite load in BOTH directions. Backend: PostFeedService.scroll gains a direction (older|newer); new around(post_id) returns a window of newer + the post + older with a cursor for each end. /api/posts accepts ?around= and ?direction=. + API tests. Frontend: posts store gains loadAround/loadOlder/loadNewer (older appends, newer prepends) with per-end cursors; PostsView's anchored mode scrolls to the post, observes top + bottom sentinels, and preserves scroll position on upward prepend so the page doesn't jump. Normal feed mode unchanged. Closes the remaining half of the post-navigation work. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/posts.py | 27 ++- backend/app/services/post_feed_service.py | 86 ++++++++-- frontend/src/stores/posts.js | 65 ++++++++ frontend/src/views/PostsView.vue | 190 +++++++++++++++++----- tests/test_api_posts.py | 68 +++++++- 5 files changed, 382 insertions(+), 54 deletions(-) diff --git a/backend/app/api/posts.py b/backend/app/api/posts.py index d96e523..9a3edb2 100644 --- a/backend/app/api/posts.py +++ b/backend/app/api/posts.py @@ -18,6 +18,8 @@ async def list_posts(): artist_id_raw = args.get("artist_id") platform = args.get("platform") or None limit_raw = args.get("limit", "24") + direction = args.get("direction", "older") + around_raw = args.get("around") try: limit = int(limit_raw) @@ -26,6 +28,16 @@ async def list_posts(): if limit < 1 or limit > 100: return _bad("invalid_limit", detail="limit must be between 1 and 100") + if direction not in ("older", "newer"): + return _bad("invalid_direction", detail="direction must be 'older' or 'newer'") + + around_id = None + if around_raw is not None: + try: + around_id = int(around_raw) + except ValueError: + return _bad("invalid_around", detail="around must be an integer post id") + artist_id = None if artist_id_raw is not None: try: @@ -40,11 +52,20 @@ async def list_posts(): ) async with get_session() as session: - try: - page = await PostFeedService(session).scroll( - cursor=cursor, artist_id=artist_id, + svc = PostFeedService(session) + if around_id is not None: + result = await svc.around( + post_id=around_id, artist_id=artist_id, platform=platform, limit=limit, ) + if result is None: + return _bad("not_found", status=404, detail=f"post id={around_id}") + return jsonify(result) + try: + page = await svc.scroll( + cursor=cursor, artist_id=artist_id, + platform=platform, limit=limit, direction=direction, + ) except ValueError as exc: # Service raises ValueError for malformed cursors only; # limit bounds are validated above. diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py index 974427e..319d645 100644 --- a/backend/app/services/post_feed_service.py +++ b/backend/app/services/post_feed_service.py @@ -56,9 +56,17 @@ class PostFeedService: artist_id: int | None = None, platform: str | None = None, limit: int = 24, + direction: str = "older", ) -> dict: + """Paginate the feed from `cursor`. direction='older' walks back in + time (default, infinite-scroll down); direction='newer' walks forward + (scroll up in an anchored view). Items are always returned in feed + (descending) order; `next_cursor` points to the far edge in the + requested direction (null when exhausted).""" if limit < 1 or limit > 100: raise ValueError("limit must be between 1 and 100") + if direction not in ("older", "newer"): + raise ValueError("direction must be 'older' or 'newer'") sort_key = _sort_key() stmt = ( @@ -72,22 +80,37 @@ class PostFeedService: stmt = stmt.where(Source.platform == platform) if cursor: cur_ts, cur_id = decode_cursor(cursor) - stmt = stmt.where( - or_( + if direction == "older": + stmt = stmt.where(or_( sort_key < cur_ts, and_(sort_key == cur_ts, Post.id < cur_id), - ) - ) + )) + else: + stmt = stmt.where(or_( + sort_key > cur_ts, + and_(sort_key == cur_ts, Post.id > cur_id), + )) - stmt = stmt.order_by(sort_key.desc(), Post.id.desc()).limit(limit + 1) + if direction == "older": + stmt = stmt.order_by(sort_key.desc(), Post.id.desc()) + else: + stmt = stmt.order_by(sort_key.asc(), Post.id.asc()) + stmt = stmt.limit(limit + 1) rows = (await self.session.execute(stmt)).all() + has_more = len(rows) > limit + rows = rows[:limit] + if direction == "newer": + # Fetched ascending (closest-newer first); flip to feed order. + rows = list(reversed(rows)) + next_cursor: str | None = None - if len(rows) > limit: - last_post, _, _ = rows[limit - 1] - last_key = last_post.post_date or last_post.downloaded_at - next_cursor = encode_cursor(last_key, last_post.id) - rows = rows[:limit] + if has_more and rows: + # Far edge in the travel direction: oldest row going older, + # newest row going newer (rows is descending for display). + edge_post = rows[-1][0] if direction == "older" else rows[0][0] + edge_key = edge_post.post_date or edge_post.downloaded_at + next_cursor = encode_cursor(edge_key, edge_post.id) post_ids = [p.id for p, _, _ in rows] thumbs_map = await self._thumbnails_for(post_ids) @@ -99,6 +122,49 @@ class PostFeedService: ] return {"items": items, "next_cursor": next_cursor} + async def around( + self, + *, + post_id: int, + artist_id: int | None = None, + platform: str | None = None, + limit: int = 12, + ) -> dict | None: + """A window centered on `post_id`: up to `limit` newer posts + the + post + up to `limit` older posts, in feed (descending) order, with a + cursor for each end. Returns None if the post doesn't exist.""" + anchor = (await self.session.execute( + select(Post, Artist, Source) + .join(Source, Post.source_id == Source.id) + .join(Artist, Source.artist_id == Artist.id) + .where(Post.id == post_id) + )).one_or_none() + if anchor is None: + return None + anchor_post, anchor_artist, anchor_source = anchor + anchor_key = anchor_post.post_date or anchor_post.downloaded_at + anchor_cursor = encode_cursor(anchor_key, anchor_post.id) + + older = await self.scroll( + cursor=anchor_cursor, artist_id=artist_id, platform=platform, + limit=limit, direction="older", + ) + newer = await self.scroll( + cursor=anchor_cursor, artist_id=artist_id, platform=platform, + limit=limit, direction="newer", + ) + thumbs_map = await self._thumbnails_for([anchor_post.id]) + atts_map = await self._attachments_for([anchor_post.id]) + anchor_item = self._to_dict( + anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map, + ) + return { + "items": newer["items"] + [anchor_item] + older["items"], + "cursor_older": older["next_cursor"], + "cursor_newer": newer["next_cursor"], + "anchor_id": anchor_post.id, + } + async def get_post(self, post_id: int) -> dict | None: row = (await self.session.execute( select(Post, Artist, Source) diff --git a/frontend/src/stores/posts.js b/frontend/src/stores/posts.js index 926bb63..f02e980 100644 --- a/frontend/src/stores/posts.js +++ b/frontend/src/stores/posts.js @@ -12,6 +12,14 @@ export const usePostsStore = defineStore('posts', () => { const done = ref(false) const filters = ref({ artist_id: null, platform: null }) + // In-context (anchored) view: bidirectional cursors so the feed can load + // newer posts on scroll-up and older posts on scroll-down around a post. + const cursorOlder = ref(null) + const cursorNewer = ref(null) + const doneOlder = ref(false) + const doneNewer = ref(false) + const anchorId = ref(null) + function _qs() { const q = {} if (cursor.value) q.cursor = cursor.value @@ -51,8 +59,65 @@ export const usePostsStore = defineStore('posts', () => { return await api.get(`/api/posts/${id}`) } + // Load a window centered on `postId`: newer posts above, the post, older + // posts below. Sets both directional cursors for subsequent scrolling. + async function loadAround(postId) { + loading.value = true + error.value = null + anchorId.value = null + try { + const body = await api.get('/api/posts', { params: { around: postId } }) + items.value = body.items + cursorOlder.value = body.cursor_older + cursorNewer.value = body.cursor_newer + doneOlder.value = body.cursor_older == null + doneNewer.value = body.cursor_newer == null + anchorId.value = body.anchor_id + } catch (e) { + error.value = e + } finally { + loading.value = false + } + } + + async function loadOlder() { + if (loading.value || doneOlder.value || cursorOlder.value == null) return + loading.value = true + try { + const body = await api.get('/api/posts', { + params: { cursor: cursorOlder.value, direction: 'older' }, + }) + items.value.push(...body.items) + cursorOlder.value = body.next_cursor + if (body.next_cursor == null) doneOlder.value = true + } catch (e) { + error.value = e + } finally { + loading.value = false + } + } + + async function loadNewer() { + if (loading.value || doneNewer.value || cursorNewer.value == null) return + loading.value = true + try { + const body = await api.get('/api/posts', { + params: { cursor: cursorNewer.value, direction: 'newer' }, + }) + items.value.unshift(...body.items) + cursorNewer.value = body.next_cursor + if (body.next_cursor == null) doneNewer.value = true + } catch (e) { + error.value = e + } finally { + loading.value = false + } + } + return { items, cursor, loading, done, error, filters, + cursorOlder, cursorNewer, doneOlder, doneNewer, anchorId, loadInitial, loadMore, getPostFull, + loadAround, loadOlder, loadNewer, } }) diff --git a/frontend/src/views/PostsView.vue b/frontend/src/views/PostsView.vue index 4df230b..a08a09d 100644 --- a/frontend/src/views/PostsView.vue +++ b/frontend/src/views/PostsView.vue @@ -1,39 +1,84 @@ diff --git a/tests/test_api_posts.py b/tests/test_api_posts.py index 1aa1ef0..ab0cad6 100644 --- a/tests/test_api_posts.py +++ b/tests/test_api_posts.py @@ -4,7 +4,7 @@ Validates list shape, cursor handling, filter validation, and detail endpoint. Service-level fixtures are exercised by test_post_feed_service — here we focus on the HTTP surface (validation, status codes, dict shape). """ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta import pytest @@ -107,6 +107,72 @@ async def test_detail_404_for_unknown(client): assert body["error"] == "not_found" +@pytest.fixture +async def post_timeline(db): + """Five posts on distinct dates: posts[0] oldest … posts[4] newest.""" + artist = Artist(name="tl-api", slug="tl-api") + db.add(artist) + await db.flush() + source = Source( + artist_id=artist.id, platform="patreon", + url="https://p/tl-api", enabled=True, + ) + db.add(source) + await db.flush() + base = datetime(2026, 1, 1, tzinfo=UTC) + posts = [] + for i in range(5): + p = Post( + source_id=source.id, external_post_id=f"TL{i}", + post_title=f"post {i}", post_date=base + timedelta(days=i), + ) + db.add(p) + posts.append(p) + await db.commit() + return artist, source, posts + + +@pytest.mark.asyncio +async def test_around_returns_window_with_anchor(client, post_timeline): + _, _, posts = post_timeline + anchor = posts[2] + resp = await client.get(f"/api/posts?around={anchor.id}&limit=1") + assert resp.status_code == 200 + body = await resp.get_json() + assert set(body.keys()) == {"items", "cursor_older", "cursor_newer", "anchor_id"} + assert body["anchor_id"] == anchor.id + # limit=1: one newer + anchor + one older, in feed (desc) order. + assert [it["id"] for it in body["items"]] == [posts[3].id, posts[2].id, posts[1].id] + assert body["cursor_older"] is not None # posts[0] still older + assert body["cursor_newer"] is not None # posts[4] still newer + + +@pytest.mark.asyncio +async def test_around_404_for_unknown(client): + resp = await client.get("/api/posts?around=999999") + assert resp.status_code == 404 + assert (await resp.get_json())["error"] == "not_found" + + +@pytest.mark.asyncio +async def test_direction_newer_walks_forward(client, post_timeline): + _, _, posts = post_timeline + around = await client.get(f"/api/posts?around={posts[1].id}&limit=1") + cursor_newer = (await around.get_json())["cursor_newer"] + assert cursor_newer is not None + resp = await client.get(f"/api/posts?cursor={cursor_newer}&direction=newer&limit=5") + assert resp.status_code == 200 + # Newer than the window's newest (posts[2]) → posts[3], posts[4] in desc order. + assert [it["id"] for it in (await resp.get_json())["items"]] == [posts[4].id, posts[3].id] + + +@pytest.mark.asyncio +async def test_rejects_bad_direction(client): + resp = await client.get("/api/posts?direction=sideways") + assert resp.status_code == 400 + assert (await resp.get_json())["error"] == "invalid_direction" + + @pytest.mark.asyncio async def test_detail_returns_uncapped_thumbnails(client, db): """Feed query caps thumbnails at 6 for previews; detail endpoint From e76aa36a29e55767c756ae637a930df70b24d62e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 29 May 2026 12:22:33 -0400 Subject: [PATCH 09/16] ci(I1): dedicated fast-fail ruff lint lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruff is pre-installed in the ci-python image, so a new `lint` job runs it with no dependency install and fails in seconds — surfacing the common lint bounce class without waiting on the backend job's ~30-60s wheel install. Dropped the now-redundant ruff step from backend-lint-and-test (same job name, required-checks unchanged). Co-Authored-By: Claude Opus 4.7 (1M context) --- .forgejo/workflows/ci.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 45b3571..cb247cf 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,7 +1,8 @@ name: CI # CI lanes per FabledRulebook/forgejo.md "CI philosophy": -# - backend-lint-and-test: ruff + `pytest -m "not integration"`, no service containers. +# - lint: ruff only, no dep install — fast-fail for the common lint bounce. +# - backend-lint-and-test: `pytest -m "not integration"`, no service containers. # - frontend-build: vitest unit + vite build. # - integration: pgvector + redis service containers; alembic + `pytest -m integration`. @@ -14,6 +15,20 @@ on: # (single-operator Forgejo repo) so push coverage is complete. jobs: + # Fast-fail lint lane. ruff is pre-installed in the ci-python image, so + # this runs with NO dependency install and surfaces the most common bounce + # class (lint: I001 / UP037 / ASYNC109 / W293 …) in seconds — instead of + # after the backend job's ~30-60s wheel install. ruff is static analysis, + # so no DB/secret env is needed. + lint: + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + - name: Ruff lint + run: ruff check backend/ tests/ alembic/ + backend-lint-and-test: runs-on: python-ci container: @@ -51,9 +66,8 @@ jobs: pip install -r requirements.txt pytest pytest-asyncio fi - - name: Ruff lint - run: ruff check backend/ tests/ alembic/ - + # Ruff moved to the dedicated fast `lint` job above (fails in seconds, + # no dep install). This job is now unit tests only. - name: Pytest (unit only — integration runs in the integration job) run: pytest tests/ -v -m "not integration" From 44410db492cd081d000014270e72d2e7ac39a11c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 29 May 2026 12:34:58 -0400 Subject: [PATCH 10/16] test(I2): vitest component smoke tests for high-risk UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds @vue/test-utils + happy-dom and mounts CredentialCard, DownloadEventRow, PostCard, ActiveDownloadsPanel, QueueStatusBar, asserting they render without throwing and surface key content — catching the dead-binding / render-error class (e.g. the last_verified_at regression, guarded explicitly). Vuetify components are left unresolved (Vue renders unknown elements + slots), so no Vuetify-plugin setup is needed; only RouterLink is stubbed. Per-file happy-dom env via docblock keeps the existing node-env specs untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/package.json | 4 ++- .../components/activeDownloadsPanel.spec.js | 30 +++++++++++++++++++ .../test/components/credentialCard.spec.js | 28 +++++++++++++++++ .../test/components/downloadEventRow.spec.js | 21 +++++++++++++ frontend/test/components/postCard.spec.js | 26 ++++++++++++++++ .../test/components/queueStatusBar.spec.js | 26 ++++++++++++++++ frontend/test/support/mountComponent.js | 24 +++++++++++++++ 7 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 frontend/test/components/activeDownloadsPanel.spec.js create mode 100644 frontend/test/components/credentialCard.spec.js create mode 100644 frontend/test/components/downloadEventRow.spec.js create mode 100644 frontend/test/components/postCard.spec.js create mode 100644 frontend/test/components/queueStatusBar.spec.js create mode 100644 frontend/test/support/mountComponent.js diff --git a/frontend/package.json b/frontend/package.json index d68122c..5e230c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,8 @@ "vue-tsc": "^2.0.0", "vite-plugin-vuetify": "^2.0.0", "sass": "^1.71.0", - "vitest": "^2.1.0" + "vitest": "^2.1.0", + "@vue/test-utils": "^2.4.0", + "happy-dom": "^15.0.0" } } diff --git a/frontend/test/components/activeDownloadsPanel.spec.js b/frontend/test/components/activeDownloadsPanel.spec.js new file mode 100644 index 0000000..e6e683e --- /dev/null +++ b/frontend/test/components/activeDownloadsPanel.spec.js @@ -0,0 +1,30 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import ActiveDownloadsPanel from '../../src/components/subscriptions/ActiveDownloadsPanel.vue' +import { useDownloadsStore } from '../../src/stores/downloads.js' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +describe('ActiveDownloadsPanel', () => { + it('lists a running download with its artist', () => { + const pinia = freshPinia() + useDownloadsStore().activeEvents = [{ + id: 1, status: 'running', + started_at: new Date(Date.now() - 65000).toISOString(), + platform: 'patreon', artist_name: 'Alice', + }] + const w = mountComponent(ActiveDownloadsPanel, { pinia }) + const t = w.text() + expect(t).toContain('Alice') + expect(t).toContain('downloading') + w.unmount() // clear the 1s elapsed-timer interval + }) + + it('renders nothing when there is no active work', () => { + const pinia = freshPinia() + useDownloadsStore().activeEvents = [] + const w = mountComponent(ActiveDownloadsPanel, { pinia }) + expect(w.text()).toBe('') + w.unmount() + }) +}) diff --git a/frontend/test/components/credentialCard.spec.js b/frontend/test/components/credentialCard.spec.js new file mode 100644 index 0000000..f98d39b --- /dev/null +++ b/frontend/test/components/credentialCard.spec.js @@ -0,0 +1,28 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import CredentialCard from '../../src/components/subscriptions/CredentialCard.vue' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +const platform = { key: 'patreon', name: 'Patreon', auth_type: 'cookies' } + +describe('CredentialCard', () => { + it('renders the last-verified row when last_verified is set', () => { + // Regression guard: the field was once read as last_verified_at, so the + // row silently never rendered. + const pinia = freshPinia() + const recent = new Date(Date.now() - 2 * 86400_000).toISOString() + const credential = { + platform: 'patreon', credential_type: 'cookies', + captured_at: recent, expires_at: null, last_verified: recent, + } + const w = mountComponent(CredentialCard, { props: { platform, credential }, pinia }) + expect(w.text()).toContain('Last verified') + }) + + it('renders the empty state with no credential', () => { + const pinia = freshPinia() + const w = mountComponent(CredentialCard, { props: { platform, credential: null }, pinia }) + expect(w.text()).toContain('No credential stored') + }) +}) diff --git a/frontend/test/components/downloadEventRow.spec.js b/frontend/test/components/downloadEventRow.spec.js new file mode 100644 index 0000000..17d2151 --- /dev/null +++ b/frontend/test/components/downloadEventRow.spec.js @@ -0,0 +1,21 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import DownloadEventRow from '../../src/components/downloads/DownloadEventRow.vue' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +describe('DownloadEventRow', () => { + it('renders the artist and the mapped status label', () => { + const pinia = freshPinia() + const now = new Date().toISOString() + const event = { + id: 1, status: 'ok', started_at: now, finished_at: now, + platform: 'patreon', artist_name: 'Alice', + files_count: 3, bytes_downloaded: 1000, error: null, summary: {}, + } + const w = mountComponent(DownloadEventRow, { props: { event }, pinia }) + const t = w.text() + expect(t).toContain('Alice') + expect(t).toContain('Completed') // downloadStatusLabel('ok') + }) +}) diff --git a/frontend/test/components/postCard.spec.js b/frontend/test/components/postCard.spec.js new file mode 100644 index 0000000..1e39004 --- /dev/null +++ b/frontend/test/components/postCard.spec.js @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import PostCard from '../../src/components/posts/PostCard.vue' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +describe('PostCard', () => { + it('renders the HTML-stripped title and the artist', () => { + const pinia = freshPinia() + const now = new Date().toISOString() + const post = { + id: 1, external_post_id: 'P1', + post_title: 'Hello World', + post_url: 'https://x/1', post_date: now, downloaded_at: now, + description_plain: 'a description', + artist: { id: 1, name: 'Sabu', slug: 'sabu' }, + source: { id: 1, platform: 'subscribestar' }, + thumbnails: [], thumbnails_more: 0, attachments: [], + } + const w = mountComponent(PostCard, { props: { post }, pinia }) + const t = w.text() + expect(t).toContain('Hello World') // plain title + expect(t).not.toContain('') // tags stripped + expect(t).toContain('Sabu') // artist (RouterLink slot) + }) +}) diff --git a/frontend/test/components/queueStatusBar.spec.js b/frontend/test/components/queueStatusBar.spec.js new file mode 100644 index 0000000..cb65599 --- /dev/null +++ b/frontend/test/components/queueStatusBar.spec.js @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' + +import QueueStatusBar from '../../src/components/settings/QueueStatusBar.vue' +import { useSystemActivityStore } from '../../src/stores/systemActivity.js' +import { freshPinia, mountComponent } from '../support/mountComponent.js' + +describe('QueueStatusBar', () => { + it('shows the pending count when the queue is busy', () => { + const pinia = freshPinia() + useSystemActivityStore().queues = { queues: { ml: 3 }, fetched_at: '' } + const w = mountComponent(QueueStatusBar, { + props: { queue: 'ml', queueLabel: 'ML' }, pinia, + }) + expect(w.text()).toContain('3 pending') + }) + + it('reads idle when the queue is empty', () => { + const pinia = freshPinia() + useSystemActivityStore().queues = { queues: { ml: 0 }, fetched_at: '' } + const w = mountComponent(QueueStatusBar, { + props: { queue: 'ml', queueLabel: 'ML' }, pinia, + }) + expect(w.text().toLowerCase()).toContain('idle') + }) +}) diff --git a/frontend/test/support/mountComponent.js b/frontend/test/support/mountComponent.js new file mode 100644 index 0000000..888a70a --- /dev/null +++ b/frontend/test/support/mountComponent.js @@ -0,0 +1,24 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' + +// Smoke-test helper. Vuetify components are left unresolved (Vue 3 renders +// unknown elements with their slot children, so text content is still +// present and nothing throws), which avoids standing up the whole Vuetify +// plugin in happy-dom. RouterLink is the one import that needs a router, so +// it's stubbed with a slot-rendering anchor. + +export function freshPinia () { + const pinia = createPinia() + setActivePinia(pinia) + return pinia +} + +export function mountComponent (Component, { props = {}, pinia } = {}) { + return mount(Component, { + props, + global: { + plugins: pinia ? [pinia] : [], + stubs: { RouterLink: { template: '
' } }, + }, + }) +} From 9ab5d709c8bdc82e3dbbeef6e5e144e881f921d8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 29 May 2026 13:16:33 -0400 Subject: [PATCH 11/16] fix(test): DownloadEventRow row shows platform, not artist name The row template renders status + platform (PlatformChip) + time + counts; the artist isn't displayed there. Assert on 'Completed'/'Patreon' instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/test/components/downloadEventRow.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/test/components/downloadEventRow.spec.js b/frontend/test/components/downloadEventRow.spec.js index 17d2151..e64c0e0 100644 --- a/frontend/test/components/downloadEventRow.spec.js +++ b/frontend/test/components/downloadEventRow.spec.js @@ -5,7 +5,7 @@ import DownloadEventRow from '../../src/components/downloads/DownloadEventRow.vu import { freshPinia, mountComponent } from '../support/mountComponent.js' describe('DownloadEventRow', () => { - it('renders the artist and the mapped status label', () => { + it('renders the mapped status label and platform', () => { const pinia = freshPinia() const now = new Date().toISOString() const event = { @@ -15,7 +15,7 @@ describe('DownloadEventRow', () => { } const w = mountComponent(DownloadEventRow, { props: { event }, pinia }) const t = w.text() - expect(t).toContain('Alice') expect(t).toContain('Completed') // downloadStatusLabel('ok') + expect(t).toContain('Patreon') // platformLabel via PlatformChip }) }) From 00e2608ba1eb10c7ab313e8724f34f7b299dcc77 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 29 May 2026 13:24:11 -0400 Subject: [PATCH 12/16] feat(I3): always-on pipeline status indicator in the top nav New /api/system/activity/summary aggregates scheduler health + per-queue pending depths + running count + 24h failure count in one cached call (safe to poll app-wide). PipelineStatusChip lives in the TopNav on every page: a compact running/queued/failing chip with a scheduler-health dot that expands to a popover (scheduler, busy queues, counts, link to downloads). Polls the summary every 8s, paused when backgrounded. Reuses the queue-cache read via _queues_cached(). + API test for the summary shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/system_activity.py | 45 +++++- .../src/components/PipelineStatusChip.vue | 146 ++++++++++++++++++ frontend/src/components/TopNav.vue | 2 + frontend/src/stores/systemActivity.js | 16 +- tests/test_api_system_activity.py | 19 +++ 5 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/PipelineStatusChip.vue diff --git a/backend/app/api/system_activity.py b/backend/app/api/system_activity.py index 480b8c9..0d0600c 100644 --- a/backend/app/api/system_activity.py +++ b/backend/app/api/system_activity.py @@ -20,6 +20,7 @@ from sqlalchemy import desc, func, select from ..config import get_config from ..extensions import get_session from ..models import TaskRun +from ..services.scheduler_service import scheduler_status system_activity_bp = Blueprint( "system_activity", __name__, url_prefix="/api/system/activity", @@ -81,17 +82,22 @@ def _read_workers_sync() -> dict: } +async def _queues_cached() -> dict: + """Per-queue Redis LLEN, cached 2s. Shared by /queues and /summary.""" + now = time.time() + if _QUEUE_CACHE["data"] is None or (now - _QUEUE_CACHE["ts"]) > _QUEUE_CACHE_TTL: + _QUEUE_CACHE["data"] = await asyncio.to_thread(_read_queues_sync) + _QUEUE_CACHE["ts"] = now + return _QUEUE_CACHE["data"] + + @system_activity_bp.route("/queues", methods=["GET"]) async def get_queues(): """Per-queue Redis LLEN. Cached 2s. Response: {queues: {name: depth_or_null}, fetched_at: iso8601} """ - now = time.time() - if _QUEUE_CACHE["data"] is None or (now - _QUEUE_CACHE["ts"]) > _QUEUE_CACHE_TTL: - _QUEUE_CACHE["data"] = await asyncio.to_thread(_read_queues_sync) - _QUEUE_CACHE["ts"] = now - return jsonify(_QUEUE_CACHE["data"]) + return jsonify(await _queues_cached()) @system_activity_bp.route("/workers", methods=["GET"]) @@ -107,6 +113,35 @@ async def get_workers(): return jsonify(_WORKER_CACHE["data"]) +@system_activity_bp.route("/summary", methods=["GET"]) +async def get_summary(): + """One-call rollup for the always-on TopNav pipeline indicator: + scheduler health, per-queue pending depths, currently-running count, and + recent (24h) failure count. Cheap — cached queue LLENs + two TaskRun + counts — so it's safe to poll app-wide.""" + queues_data = await _queues_cached() + depths = queues_data.get("queues", {}) + queued_total = sum(v for v in depths.values() if isinstance(v, int)) + since = datetime.now(UTC) - timedelta(hours=24) + async with get_session() as session: + scheduler = await scheduler_status(session) + running = (await session.execute( + select(func.count(TaskRun.id)).where(TaskRun.status == "running") + )).scalar_one() + failing = (await session.execute( + select(func.count(TaskRun.id)) + .where(TaskRun.status.in_(["error", "timeout"])) + .where(TaskRun.finished_at >= since) + )).scalar_one() + return jsonify({ + "scheduler": scheduler, + "queues": depths, + "queued_total": queued_total, + "running": int(running), + "failing": int(failing), + }) + + @system_activity_bp.route("/runs", methods=["GET"]) async def list_runs(): """Paginated task_run history. Query params: diff --git a/frontend/src/components/PipelineStatusChip.vue b/frontend/src/components/PipelineStatusChip.vue new file mode 100644 index 0000000..0b026c0 --- /dev/null +++ b/frontend/src/components/PipelineStatusChip.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/frontend/src/components/TopNav.vue b/frontend/src/components/TopNav.vue index b2d7939..07b5983 100644 --- a/frontend/src/components/TopNav.vue +++ b/frontend/src/components/TopNav.vue @@ -8,6 +8,7 @@ {{ health.icon }} +