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 @@ + + + + + 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", }