diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py
index 76c0b27..6d6de11 100644
--- a/backend/app/services/scheduler_service.py
+++ b/backend/app/services/scheduler_service.py
@@ -235,6 +235,15 @@ async def scheduler_status(session: AsyncSession) -> dict:
select(func.count()).select_from(Source)
.where(Source.enabled.is_(True), no_access_sources_clause())
)).scalar_one()
+ # #387 B4: lets the front door tell "nothing configured yet" (a fresh
+ # install — show the on-ramp) apart from "configured, still fetching" (a
+ # first run in progress — show what's running). Telling someone to add a
+ # source when they already have three and are mid-backfill is worse than
+ # saying nothing. Deliberately NOT auto_sources, which counts only what is
+ # on a schedule: a source with auto_check off still means "configured".
+ total_sources = (await session.execute(
+ select(func.count()).select_from(Source).where(Source.enabled.is_(True))
+ )).scalar_one()
return {
"last_tick_at": last_tick_at,
@@ -243,5 +252,6 @@ async def scheduler_status(session: AsyncSession) -> dict:
"auto_sources": len(rows),
"failing_sources": failing_sources,
"no_access_sources": no_access_sources,
+ "total_sources": total_sources,
"platform_cooldowns": {p: dt.isoformat() for p, dt in cooldowns.items()},
}
diff --git a/frontend/src/components/posts/FeedEmptyState.vue b/frontend/src/components/posts/FeedEmptyState.vue
new file mode 100644
index 0000000..24a32db
--- /dev/null
+++ b/frontend/src/components/posts/FeedEmptyState.vue
@@ -0,0 +1,116 @@
+
+
+
+

+
+
+
+ Nothing has arrived yet.
+
+ {{ sourceCountLabel }} configured. The first check can take a while —
+ deep history is fetched in chunks.
+
+
+ See what's running
+
+
+
+
+
+ Nothing here yet.
+
+ FabledCurator follows the creators you subscribe to and files what they
+ post. Two steps to start.
+
+
+ Add a credential
+ Add a source
+
+
+ A credential comes first — a source can't fetch anything without your
+ logged-in session.
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/PostsView.vue b/frontend/src/views/PostsView.vue
index ee5b760..751b804 100644
--- a/frontend/src/views/PostsView.vue
+++ b/frontend/src/views/PostsView.vue
@@ -66,11 +66,11 @@
+
No posts match your search or filters.
-
No posts yet. Subscribe to a source on the
- Subscriptions
- tab to start capturing posts.
-
+
@@ -92,6 +92,7 @@ import { usePostsStore } from '../stores/posts.js'
import PostsFilterBar from '../components/posts/PostsFilterBar.vue'
import PostCard from '../components/posts/PostCard.vue'
import FeedStatusRibbon from '../components/posts/FeedStatusRibbon.vue'
+import FeedEmptyState from '../components/posts/FeedEmptyState.vue'
// The ingestion-status ribbon is a FRONT-DOOR concern, not a feed concern —
// inside Browse's Posts tab you are looking FOR something, and the Subscriptions
diff --git a/frontend/test/components/feedEmptyState.spec.js b/frontend/test/components/feedEmptyState.spec.js
new file mode 100644
index 0000000..2b6d11d
--- /dev/null
+++ b/frontend/test/components/feedEmptyState.spec.js
@@ -0,0 +1,61 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+
+import FeedEmptyState from '../../src/components/posts/FeedEmptyState.vue'
+import { useSourcesStore } from '../../src/stores/sources.js'
+import { mountWithStore } from '../support/mountComponent.js'
+
+// #387 B4. This is the first screen a fresh install shows anyone, so the thing
+// worth pinning is that it tells the two empties apart. Telling an operator to
+// "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
+})
+
+describe('FeedEmptyState', () => {
+ beforeEach(() => {
+ globalThis.fetch = vi.fn(async () => { throw new Error('offline') })
+ })
+ afterEach(() => vi.restoreAllMocks())
+
+ it('a fresh install gets the on-ramp, credential first', () => {
+ const w = mountWith({ total_sources: 0 })
+ expect(w.text()).toContain('Add a credential')
+ expect(w.text()).toContain('Add a source')
+ expect(w.text()).not.toContain("See what's running")
+ })
+
+ it('an install that is already fetching is told so, not told to set up', () => {
+ const w = mountWith({ total_sources: 3 })
+ expect(w.text()).toContain('3 sources')
+ expect(w.text()).toContain("See what's running")
+ expect(w.text()).not.toContain('Add a credential')
+ })
+
+ it('singularises a lone source', () => {
+ const w = mountWith({ total_sources: 1 })
+ expect(w.text()).toContain('1 source')
+ expect(w.text()).not.toContain('1 sources')
+ })
+
+ it('falls back to the on-ramp when the status call never answered', () => {
+ // Safe direction: the on-ramp is merely redundant to an established
+ // operator, whereas "see what's running" shown to someone with nothing
+ // configured is a dead end.
+ const w = mountWith(null)
+ expect(w.text()).toContain('Add a credential')
+ })
+
+ it('shows the brand mark, sized to be readable', () => {
+ // logo.svg stops reading below ~48px — that is why the 22px nav slot has a
+ // different mark. If this ever shrinks to a glyph, it is the wrong asset.
+ const img = mountWith({ total_sources: 0 }).find('img.fc-empty__mark')
+ expect(img.exists()).toBe(true)
+ expect(img.attributes('src')).toBe('/logo.svg')
+ expect(Number(img.attributes('width'))).toBeGreaterThanOrEqual(48)
+ // Decorative: the surrounding prose already carries the meaning.
+ expect(img.attributes('alt')).toBe('')
+ })
+})
diff --git a/frontend/test/components/feedStatusRibbon.spec.js b/frontend/test/components/feedStatusRibbon.spec.js
index 9106f96..9d61a8a 100644
--- a/frontend/test/components/feedStatusRibbon.spec.js
+++ b/frontend/test/components/feedStatusRibbon.spec.js
@@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import FeedStatusRibbon from '../../src/components/posts/FeedStatusRibbon.vue'
import { useSourcesStore } from '../../src/stores/sources.js'
-import { freshPinia, mountComponent } from '../support/mountComponent.js'
+import { mountWithStore } from '../support/mountComponent.js'
// #387 B3. This ribbon is the ONLY place phase A's work reaches someone who
// wasn't already looking for it, so what it does and does not say is the whole
@@ -12,11 +12,9 @@ import { freshPinia, mountComponent } from '../support/mountComponent.js'
// zero — a "0 failing" on the front door is noise that trains you to ignore
// the line, which is exactly what would hide the real number later.
-function mountWith (status) {
- const pinia = freshPinia()
+const mountWith = (status) => mountWithStore(FeedStatusRibbon, () => {
useSourcesStore().scheduleStatus = status
- return mountComponent(FeedStatusRibbon, { pinia })
-}
+})
describe('FeedStatusRibbon', () => {
beforeEach(() => {
diff --git a/frontend/test/support/mountComponent.js b/frontend/test/support/mountComponent.js
index 44aad24..bdf8336 100644
--- a/frontend/test/support/mountComponent.js
+++ b/frontend/test/support/mountComponent.js
@@ -35,3 +35,13 @@ export const VTooltipStub = {
name: 'VTooltip',
template: '
',
}
+
+// Mount with a fresh pinia and the store already seeded, for components that
+// read store state during render. Without it, the seeding has to be inlined
+// between createPinia and mount in every spec — the same copy-paste that issue
+// #3109 tracks for the backend row factories.
+export function mountWithStore (Component, seed, opts = {}) {
+ const pinia = freshPinia()
+ seed()
+ return mountComponent(Component, { ...opts, pinia })
+}
diff --git a/tests/test_api_sources.py b/tests/test_api_sources.py
index 2d1e773..741c30c 100644
--- a/tests/test_api_sources.py
+++ b/tests/test_api_sources.py
@@ -55,7 +55,7 @@ async def test_schedule_status_shape(client):
body = await resp.get_json()
assert set(body) == {
"last_tick_at", "next_due_at", "due_now", "auto_sources",
- "failing_sources", "no_access_sources",
+ "failing_sources", "no_access_sources", "total_sources",
"platform_cooldowns",
}
assert isinstance(body["due_now"], int)
diff --git a/tests/test_api_system_activity.py b/tests/test_api_system_activity.py
index 1238e7e..da5239d 100644
--- a/tests/test_api_system_activity.py
+++ b/tests/test_api_system_activity.py
@@ -62,7 +62,7 @@ async def test_summary_returns_rollup_shape(client, monkeypatch):
assert isinstance(body["failing"], int)
assert set(body["scheduler"]) == {
"last_tick_at", "next_due_at", "due_now", "auto_sources",
- "failing_sources", "no_access_sources",
+ "failing_sources", "no_access_sources", "total_sources",
"platform_cooldowns",
}