feat: no-access is visible per source, and findable (milestone 387 step A3)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m40s
Build images / build-ml (push) Successful in 2m47s
Build images / build-web (push) Successful in 1m35s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped

A3 of milestone 387, completing phase A. A1 made the count true, A2
made it a durable state; this makes it something the operator can see
without going looking.

Turned out smaller than filed, because A2 revealed why the existing
`tier_limited` palette entry in FailingSourcesCard had never rendered:
the chip was being cleared by the same successful run that produced it.
The colour was already chosen.

Where it surfaces:

- SourceHealthDot gains a `no-access` grade. Deliberately its own grade
  rather than folded into healthy (which hides it) or warning (which
  sends the operator hunting for a break that isn't there). A source
  with real failures still grades as failing whether or not it is also
  gated.
- SourceRow gets an info-coloured lock chip in the status cell, which
  was empty for these sources — they have zero failures. Placed ahead
  of the backfill states: "we can't see this creator" is the more
  useful thing to say than which walk phase it is in, and unlike those
  it does not resolve on its own.
- A "No access" status filter, deliberately separate from "Has errors".
  Without it a gated source is invisible in a long list, because it
  correctly stays out of the failing rollup.

Left OUT of NeedsAttentionCard on purpose. That card's only affordance
is Retry, and you cannot retry your way into a subscription tier —
issue 1285 already gives the real escape hatch, since disabling a
source clears its state. Nothing structural needed changing: the card
is fed by consecutive_failures > 0, which a tier-limited source never
has.

The count lives on the download event, not the source, so `list()`
joins it in with one DISTINCT ON query — selecting the run_stats
sub-object rather than whole metadata blobs, which carry up to 500KB of
truncated stdout each. Scoped to tier-gated rows only, so a healthy
library issues no extra query at all. Absent stays None rather than 0,
and both UI surfaces phrase the state without a number when it is
missing instead of printing a fabricated zero.

Also covers A1's live gated count, which shipped untested, and extends
the mount helper with slot stubs: SourceHealthDot puts the dot in a
NAMED slot, and unresolved Vuetify components render default slots
only — so those assertions would have found an empty wrapper and
passed vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
2026-09-09 21:26:48 -04:00
co-authored by Claude Opus 5
parent 7751715b83
commit 6bb18050a4
8 changed files with 282 additions and 4 deletions
@@ -20,6 +20,35 @@ describe('ActiveDownloadsPanel', () => {
w.unmount() // clear the 1s elapsed-timer interval
})
// #387 A1: the live payload gained a `gated` count. Shown only when non-zero
// so a healthy run stays uncluttered — a "🔒 0" on every download would be
// noise, and noise is what stops the number being noticed when it matters.
it('ticks the tier-gated count mid-walk when there is one', () => {
const pinia = freshPinia()
useDownloadsStore().activeEvents = [{
id: 1, status: 'running',
started_at: new Date(Date.now() - 65000).toISOString(),
platform: 'patreon', artist_name: 'Alice',
live: { downloaded: 2, skipped: 0, errors: 0, posts: 12, gated: 9 },
}]
const w = mountComponent(ActiveDownloadsPanel, { pinia })
expect(w.text()).toContain('9')
w.unmount()
})
it('omits the gated count when nothing was gated', () => {
const pinia = freshPinia()
useDownloadsStore().activeEvents = [{
id: 1, status: 'running',
started_at: new Date(Date.now() - 65000).toISOString(),
platform: 'patreon', artist_name: 'Alice',
live: { downloaded: 2, skipped: 0, errors: 0, posts: 12, gated: 0 },
}]
const w = mountComponent(ActiveDownloadsPanel, { pinia })
expect(w.find('.fc-active__count--gated').exists()).toBe(false)
w.unmount()
})
it('renders nothing when there is no active work', () => {
const pinia = freshPinia()
useDownloadsStore().activeEvents = []
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
import { describe, it, expect } from 'vitest'
import SourceHealthDot from '../../src/components/subscriptions/SourceHealthDot.vue'
import { VTooltipStub, mountComponent } from '../support/mountComponent.js'
// Milestone #387 A3. The dot is the only always-visible signal per source, so
// the grade it picks IS the claim FC makes about that subscription. These pin
// that no-access is graded as its own thing — not as healthy (which hides it)
// and not as a failure (which would send the operator hunting for a break that
// isn't there).
const checked = { last_checked_at: '2026-09-09T12:00:00+00:00' }
function dotClass (w) {
return w.find('.fc-health-dot').classes().join(' ')
}
describe('SourceHealthDot', () => {
it('grades a tier-gated source as no-access, not healthy', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: {
source: { ...checked, consecutive_failures: 0, error_type: 'tier_limited' },
},
})
expect(dotClass(w)).toContain('fc-health-dot--no-access')
expect(dotClass(w)).not.toContain('fc-health-dot--healthy')
})
it('shows the gated count when the list endpoint supplied one', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: {
source: {
...checked, consecutive_failures: 0,
error_type: 'tier_limited', tier_gated_count: 47,
},
},
})
expect(w.text()).toContain('47 posts')
})
it('states the condition without a number when no count was joined in', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: {
source: {
...checked, consecutive_failures: 0,
error_type: 'tier_limited', tier_gated_count: null,
},
},
})
// Absent is not zero: never render "0 posts you don't have access to".
expect(w.text()).not.toContain('0 post')
expect(w.text()).toContain("tier you don't hold")
})
it('a genuinely failing source still grades as a failure, gated or not', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: {
source: { ...checked, consecutive_failures: 9, error_type: 'tier_limited' },
warningThreshold: 5,
},
})
expect(dotClass(w)).toContain('fc-health-dot--critical')
expect(dotClass(w)).not.toContain('fc-health-dot--no-access')
})
it('an unchecked source is still unchecked', () => {
const w = mountComponent(SourceHealthDot, {
stubs: { VTooltip: VTooltipStub },
props: { source: { last_checked_at: null, consecutive_failures: 0 } },
})
expect(dotClass(w)).toContain('fc-health-dot--unchecked')
})
})