feat: the empty front door offers to find what you already subscribe to (387 C6)
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 5s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 28s
CI / backend-lint-and-test (push) Successful in 39s
Build images / build-web (push) Successful in 1m16s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m45s

B4's on-ramp read "add a credential, add a source" - the manual path, and the one that makes a new installer retype creators they have already told Patreon about. With C4 in place the app can just look them up. This is the step where the milestone's original framing actually lands on screen.

Four rungs, chosen by ONE predicate rather than four independent v-ifs. The rungs are mutually exclusive by construction and the rung shown matches what is actually POSSIBLE:

  no credential            -> Add a credential. Discovery is not offered, because a button that cannot work is worse than its absence.
  credential, never synced -> Find what you already subscribe to. Queues the C3 sweep.
  synced, unmatched > 0    -> the count, linking into C4's bucket 1.
  synced, nothing unmatched-> the manual add, because there is genuinely nothing to discover.

The fifth state is rule 164's. A sweep that has been ATTEMPTED and never succeeded reads as "couldn't reach patreon", with the error type and the manual path still open - not a spinner, not a crash, not a retry button that will fail identically. An install with no outbound network lands here.

That is deliberately narrower than "there is an error": a roster that synced once and failed since is NOT unavailable. It has a roster, just an ageing one, and C4's freshness gate already handles that. Collapsing the two would hide a usable roster behind an error banner.

Still offer, never auto-add. An empty front door is exactly where "just add all thirty" is most tempting and most wrong - thirty backfills on first boot - so the rung carries the count and sends them to the picker.

No new machinery: three existing stores (credentials, membershipSync, membershipReconcile). Reconcile is fetched only once something has synced, since bucket 1's count is meaningless before that. Every load swallows its failure, because this screen renders on an install that can reach nothing.

Ten tests on top of B4's, including the mutual-exclusion property asserted directly. Its phrases are each pinned to a single template line and both sides whitespace-normalised - a phrase spanning a line break would never match, and a mutual-exclusion check whose phrases never match passes vacuously (rule 167).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
2026-09-12 20:19:42 -04:00
co-authored by Claude Opus 5
parent 4533e036ac
commit eb6e0df858
2 changed files with 359 additions and 24 deletions
@@ -30,12 +30,19 @@
</template>
<!-- FRESH INSTALL: the on-ramp, in the order the steps actually depend on
each other a source cannot fetch anything without a credential. -->
each other a source cannot fetch anything without a credential.
#387 C6 adds the third rung: once a credential exists, FC can just
LOOK UP what the operator already subscribes to instead of making
them retype it. Exactly one rung shows, chosen by `rung` below. -->
<template v-else>
<p class="fc-empty__lead">Nothing here yet.</p>
<!-- 1. No credential. Discovery is not yet possible, so it is not
offered a button that cannot work is worse than its absence. -->
<template v-if="rung === 'credential'">
<p class="fc-empty__sub">
FabledCurator follows the creators you subscribe to and files what they
post. Two steps to start.
FabledCurator follows the creators you subscribe to and files what
they post. Two steps to start.
</p>
<div class="fc-empty__actions">
<v-btn
@@ -52,18 +59,111 @@
logged-in session.
</p>
</template>
<!-- 2. Credential, roster never synced. The payoff rung. -->
<template v-else-if="rung === 'discover'">
<p class="fc-empty__sub">
You've connected {{ credentialLabel }}. FabledCurator can look up the
creators you already subscribe to, so you don't have to add them by
hand.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent"
prepend-icon="mdi-account-search-outline"
:loading="discovering"
@click="discover"
>Find what you already subscribe to</v-btn>
<v-btn
size="small" variant="text" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
</div>
<p class="fc-empty__hint">
Nothing is added automatically — you'll get a list to pick from.
</p>
</template>
<!-- 3. Synced, and there is something to adopt. The count is the whole
message; picking happens in C4's card. -->
<template v-else-if="rung === 'adopt'">
<p class="fc-empty__sub">
{{ unmatchedCount }}
{{ unmatchedCount === 1 ? 'creator you subscribe to is' : 'creators you subscribe to are' }}
not being followed here yet.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent"
prepend-icon="mdi-account-plus-outline"
:to="{ path: '/subscriptions' }"
>Choose who to follow</v-btn>
</div>
<p class="fc-empty__hint">
Added one at a time, so a fresh install doesn't start a dozen
backfills at once.
</p>
</template>
<!-- 4. Discovery was tried and could not reach the platform. Rule #164:
say so plainly. Not a spinner, not a crash, not a retry button
that will fail the same way. The manual path still works. -->
<template v-else-if="rung === 'unavailable'">
<p class="fc-empty__sub">
FabledCurator couldn't reach {{ credentialLabel }} to look up your
subscriptions{{ discoveryError ? ` (${discoveryError})` : '' }}. You
can still add creators by hand.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
<v-btn
size="small" variant="text" prepend-icon="mdi-key-variant"
:to="{ path: '/subscriptions', query: { tab: 'settings' } }"
>Check the credential</v-btn>
</div>
</template>
<!-- 5. Synced and nothing to discover: the roster is fully tracked, or
the account subscribes to nothing. Either way the manual path is
genuinely the only thing left to offer. -->
<template v-else>
<p class="fc-empty__sub">
Everything you subscribe to on {{ credentialLabel }} is already
followed here. Add a creator by hand to start filling the feed.
</p>
<div class="fc-empty__actions">
<v-btn
size="small" variant="tonal" color="accent" prepend-icon="mdi-plus"
:to="{ path: '/subscriptions' }"
>Add a source</v-btn>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useCredentialsStore } from '../../stores/credentials.js'
import { useMembershipReconcileStore } from '../../stores/membershipReconcile.js'
import { useMembershipSyncStore } from '../../stores/membershipSync.js'
import { useSourcesStore } from '../../stores/sources.js'
const store = useSourcesStore()
const { scheduleStatus: status } = storeToRefs(store)
const credentials = useCredentialsStore()
const syncStore = useMembershipSyncStore()
const reconcile = useMembershipReconcileStore()
const { byPlatform } = storeToRefs(credentials)
const { platforms: syncPlatforms } = storeToRefs(syncStore)
const { platforms: reconcilePlatforms } = storeToRefs(reconcile)
// Absent status is treated as "fresh install", which is the safe way round:
// the on-ramp is useful to a first-run operator and merely redundant to an
// established one, whereas "see what's running" shown to someone with nothing
@@ -75,11 +175,91 @@ const sourceCountLabel = computed(() => {
return `${n} ${n === 1 ? 'source' : 'sources'}`
})
// --- #387 C6: which on-ramp rung this install is actually on ---------------
//
// ONE predicate, not four independent v-ifs. The rungs are mutually exclusive
// by construction — an install is at exactly one point in the loop — and four
// separate conditions would eventually let two of them render at once, which
// on the first screen a new installer ever sees is the worst place for it.
const hasCredential = computed(() => byPlatform.value.size > 0)
// Any platform that has ever completed a sweep. NULL last_success_at means
// NEVER, which the C3 endpoint is deliberately careful to preserve — rendering
// it as 0 is the conflation that whole surface exists to prevent.
const syncedPlatform = computed(
() => syncPlatforms.value.find((p) => p.last_success_at) || null,
)
// Errored WITHOUT ever having succeeded. A platform that synced once and
// failed since is not "unavailable" — it has a roster, just an ageing one, and
// C4's freshness gate is what handles that. This is specifically the
// never-worked case, which is what an install with no outbound network looks
// like (rule #164).
const discoveryError = computed(() => {
if (syncedPlatform.value) return null
const failed = syncPlatforms.value.find((p) => p.last_error_type)
return failed ? failed.last_error_type : null
})
const unmatchedCount = computed(() =>
reconcilePlatforms.value.reduce(
(n, p) => n + (p.subscribed_not_tracked?.length || 0), 0,
),
)
// Which platforms the operator has connected, for the copy. Named rather than
// counted: "you've connected patreon" is a sentence about their setup, where
// "1 credential" is a sentence about our data model.
const credentialLabel = computed(() => {
const names = [...byPlatform.value.keys()]
if (names.length === 0) return 'your platform'
if (names.length === 1) return names[0]
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
})
const rung = computed(() => {
// Absent data reads as "no credential", the same safe-direction default B4
// chose for hasSources: the first rung is useful to a genuine fresh install
// and merely redundant to anyone further along, whereas a discovery button
// shown to someone with no credential is a dead end.
if (!hasCredential.value) return 'credential'
if (discoveryError.value) return 'unavailable'
if (!syncedPlatform.value) return 'discover'
return unmatchedCount.value > 0 ? 'adopt' : 'manual'
})
const discovering = ref(false)
async function discover () {
// The sweep is queued, not inline (C3) — so this cannot report a result, and
// must not pretend to. syncNow() raises a toast saying it runs in the
// background; re-reading the state is what eventually moves the rung.
discovering.value = true
try {
await syncStore.syncNow()
await syncStore.load().catch(() => {})
} finally {
discovering.value = false
}
}
// Bucket 1's count is only meaningful once something has synced, so it is
// fetched then rather than on every empty render. `immediate` covers the case
// where the sync state arrived before this watcher was set up.
watch(syncedPlatform, (p) => {
if (p && reconcilePlatforms.value.length === 0) reconcile.load().catch(() => {})
}, { immediate: true })
onMounted(() => {
// The ribbon may already have loaded this on the front door; in Browse's
// Posts tab nothing has. Failure is swallowed — an empty feed must still
// explain itself when the status call is unavailable (rule #164).
// explain itself when the status call is unavailable (rule #164). The same
// goes for every call below: this screen renders on an install that can
// reach nothing, and a rejected promise here would leave it blank.
if (!status.value) store.loadScheduleStatus().catch(() => {})
if (byPlatform.value.size === 0) credentials.loadAll().catch(() => {})
if (syncPlatforms.value.length === 0) syncStore.load().catch(() => {})
})
</script>
+156 -1
View File
@@ -2,6 +2,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import FeedEmptyState from '../../src/components/posts/FeedEmptyState.vue'
import { useCredentialsStore } from '../../src/stores/credentials.js'
import { useMembershipReconcileStore } from '../../src/stores/membershipReconcile.js'
import { useMembershipSyncStore } from '../../src/stores/membershipSync.js'
import { useSourcesStore } from '../../src/stores/sources.js'
import { mountWithStore } from '../support/mountComponent.js'
@@ -10,10 +13,46 @@ import { mountWithStore } from '../support/mountComponent.js'
// "add a source" when they already have three and are mid-backfill is worse
// than saying nothing — it reads as the app not knowing its own state.
const mountWith = (status) => mountWithStore(FeedEmptyState, () => {
// The second argument is C6's world. Omitting it means "no credential", which
// is both the fresh-install default and the rung every B4 case above asserts —
// so those keep passing unchanged rather than needing the new vocabulary.
const mountWith = (status, { credentials = [], sync = [], reconcile = [] } = {}) =>
mountWithStore(FeedEmptyState, () => {
useSourcesStore().scheduleStatus = status
useCredentialsStore().byPlatform = new Map(
credentials.map((name) => [name, { platform: name }]),
)
useMembershipSyncStore().platforms = sync
useMembershipReconcileStore().platforms = reconcile
})
const EMPTY = { total_sources: 0 }
const SYNCED = [{ platform: 'patreon', last_success_at: '2026-09-12T00:00:00Z' }]
const NEVER = [{ platform: 'patreon', last_success_at: null }]
// Every rung's distinguishing phrase, so a test can assert that exactly ONE is
// on screen. Each is chosen to sit on a SINGLE template line: a phrase spanning
// a line break would never match once the renderer keeps the newline, and a
// mutual-exclusion check whose phrases never match passes vacuously — coverage
// in appearance only (rule #167). Both sides are whitespace-normalised anyway,
// so indentation changes cannot quietly break it either.
const RUNG_PHRASES = {
credential: 'Add a credential',
discover: 'Find what you already subscribe to',
adopt: 'not being followed here yet',
unavailable: "couldn't reach",
manual: 'Everything you subscribe to on',
}
const flat = (s) => s.replace(/\s+/g, ' ').trim()
function rungsShown (w) {
const text = flat(w.text())
return Object.entries(RUNG_PHRASES)
.filter(([, phrase]) => text.includes(flat(phrase)))
.map(([name]) => name)
}
describe('FeedEmptyState', () => {
beforeEach(() => {
globalThis.fetch = vi.fn(async () => { throw new Error('offline') })
@@ -58,4 +97,120 @@ describe('FeedEmptyState', () => {
// Decorative: the surrounding prose already carries the meaning.
expect(img.attributes('alt')).toBe('')
})
// --- #387 C6: the discovery rung -----------------------------------------
//
// The loop this closes: a new installer has already told Patreon which
// creators they follow, and making them retype that list is the friction the
// whole milestone is about. These pin that the rung shown matches what is
// actually POSSIBLE — offering discovery before a credential exists, or
// after it has proven unreachable, is a button that cannot work.
it('offers discovery once a credential exists and nothing has synced', () => {
const w = mountWith(EMPTY, { credentials: ['patreon'], sync: NEVER })
expect(w.text()).toContain('Find what you already subscribe to')
expect(w.text()).toContain('patreon')
// Nothing is added for them — the offer/never-auto-add line from C4.
expect(w.text()).toContain('Nothing is added automatically')
})
it('never offers discovery before a credential exists', () => {
// The button would 404 against an unconnected platform. Absence beats a
// dead control on the first screen anyone sees.
const w = mountWith(EMPTY, { sync: NEVER })
expect(w.text()).not.toContain('Find what you already subscribe to')
expect(w.text()).toContain('Add a credential')
})
it('a sweep that has never worked reads as unavailable, not as a retry', () => {
// Rule #164: an install with no outbound network must reach this screen and
// be told plainly. Not a spinner, not a crash, not a button that will fail
// the same way — and the manual path stays open.
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: [{ platform: 'patreon', last_success_at: null, last_error_type: 'ConnectionError' }],
})
expect(w.text()).toContain("couldn't reach")
expect(w.text()).toContain('ConnectionError')
expect(w.text()).toContain('Add a source')
expect(w.text()).not.toContain('Find what you already subscribe to')
})
it('a roster that synced once and failed since is NOT unavailable', () => {
// It has a roster, just an ageing one — C4's freshness gate handles that.
// Collapsing the two would hide a usable roster behind an error banner.
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: [{
platform: 'patreon',
last_success_at: '2026-09-12T00:00:00Z',
last_error_type: 'PatreonAuthError',
}],
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
})
expect(w.text()).not.toContain("couldn't reach")
expect(w.text()).toContain('not being followed here yet')
})
it('counts what there is to adopt, and sends them to the picker', () => {
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: SYNCED,
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }, { id: 2 }] }],
})
expect(w.text()).toContain('2 creators you subscribe to are')
expect(w.text()).toContain('Choose who to follow')
})
it('singularises a lone unmatched creator', () => {
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: SYNCED,
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
})
expect(w.text()).toContain('1 creator you subscribe to is')
expect(w.text()).not.toContain('1 creators')
})
it('a fully-tracked roster stops offering discovery', () => {
// Nothing left to find, so the manual path is the honest last rung rather
// than a discovery button that would return an empty list.
const w = mountWith(EMPTY, {
credentials: ['patreon'],
sync: SYNCED,
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [] }],
})
expect(w.text()).toContain('Everything you subscribe to on')
expect(w.text()).toContain('Add a source')
expect(w.text()).not.toContain('Find what you already subscribe to')
expect(w.text()).not.toContain('not being followed here yet')
})
it('shows exactly one rung in every state', () => {
// The component claims the rungs are mutually exclusive by construction.
// This is that claim, asserted — two on screen at once would be worst
// exactly here, on the first screen a new installer ever sees.
const states = [
[EMPTY, {}],
[EMPTY, { credentials: ['patreon'], sync: NEVER }],
[EMPTY, { credentials: ['patreon'], sync: [{ platform: 'patreon', last_success_at: null, last_error_type: 'ConnectionError' }] }],
[EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }] }],
[EMPTY, { credentials: ['patreon'], sync: SYNCED, reconcile: [{ platform: 'patreon', subscribed_not_tracked: [] }] }],
]
for (const [status, world] of states) {
expect(rungsShown(mountWith(status, world))).toHaveLength(1)
}
})
it('an install that is already fetching never sees an on-ramp rung', () => {
// hasSources wins over everything C6 added — someone mid-backfill is not
// onboarding, whatever their roster says.
const w = mountWith({ total_sources: 3 }, {
credentials: ['patreon'],
sync: SYNCED,
reconcile: [{ platform: 'patreon', subscribed_not_tracked: [{ id: 1 }] }],
})
expect(w.text()).toContain("See what's running")
expect(rungsShown(w)).toHaveLength(0)
})
})