From eb6e0df858d81c2aac51ce10f5269f661f649f04 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 12 Sep 2026 20:19:42 -0400 Subject: [PATCH] feat: the empty front door offers to find what you already subscribe to (387 C6) 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) Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9 --- .../src/components/posts/FeedEmptyState.vue | 222 ++++++++++++++++-- .../test/components/feedEmptyState.spec.js | 161 ++++++++++++- 2 files changed, 359 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/posts/FeedEmptyState.vue b/frontend/src/components/posts/FeedEmptyState.vue index 24a32db..873b058 100644 --- a/frontend/src/components/posts/FeedEmptyState.vue +++ b/frontend/src/components/posts/FeedEmptyState.vue @@ -30,40 +30,140 @@ + 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. --> diff --git a/frontend/test/components/feedEmptyState.spec.js b/frontend/test/components/feedEmptyState.spec.js index 2b6d11d..904e12c 100644 --- a/frontend/test/components/feedEmptyState.spec.js +++ b/frontend/test/components/feedEmptyState.spec.js @@ -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,9 +13,45 @@ 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, () => { - useSourcesStore().scheduleStatus = status -}) +// 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(() => { @@ -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) + }) })