From ecea6b403cddad38e097269a4992080d76d08c5e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 03:08:03 +0200 Subject: [PATCH 1/6] feat(filters): show channel counts on topic and language chips The per-tag channel_count was already returned by GET /api/tags but only surfaced in the chip tooltip. Render it as a small count badge on the chip face so the relative weight of each topic/language is visible at a glance. --- frontend/src/components/Sidebar.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0a95fdb..29e04ff 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -81,13 +81,20 @@ function TagChip({ ); } From 581faeaa34f5e19ad767f1acc759c41540ed6b31 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 03:08:10 +0200 Subject: [PATCH 2/6] feat(feed): reshuffle button for the "surprise me" sort The backend shuffle sort already accepts a seed param. Add a circular reshuffle button next to the sort control (shown only when shuffle is active) that re-rolls the seed and re-queries the feed; selecting shuffle also seeds a fresh order instead of the deterministic seed-0 one. The seed lives in FeedFilters but is intentionally kept out of the URL state. --- frontend/src/components/Sidebar.tsx | 47 +++++++++++++++++------ frontend/src/i18n/locales/de/sidebar.json | 1 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/lib/api.ts | 4 ++ 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 29e04ff..6e37c9a 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { EyeOff, GripVertical, Pencil, + RefreshCw, RotateCcw, X, } from "lucide-react"; @@ -48,6 +49,9 @@ const SORT_IDS = [ const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"]; +// Fresh shuffle token for the "surprise me" sort. +const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); + const DATE_PRESETS: { days: number; key: string }[] = [ { days: 1, key: "24h" }, { days: 7, key: "1week" }, @@ -215,17 +219,38 @@ export default function Sidebar({ ); case "sort": return ( - +
+ + {filters.sort === "shuffle" && ( + + )} +
); case "date": return ( diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json index 18ce833..221aa2f 100644 --- a/frontend/src/i18n/locales/de/sidebar.json +++ b/frontend/src/i18n/locales/de/sidebar.json @@ -23,6 +23,7 @@ "from": "Von", "to": "Bis", "clearDates": "Daten löschen", + "reshuffle": "Neu mischen", "widget": { "show": "Anzeigen", "sort": "Sortierung", diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 2e42985..152f7b9 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -23,6 +23,7 @@ "from": "From", "to": "To", "clearDates": "clear dates", + "reshuffle": "Reshuffle", "widget": { "show": "Show", "sort": "Sort", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 5390f0b..5c5cc0e 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -23,6 +23,7 @@ "from": "Ettől", "to": "Eddig", "clearDates": "dátumok törlése", + "reshuffle": "Újrakeverés", "widget": { "show": "Megjelenítés", "sort": "Rendezés", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2bf9319..95a9c43 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -73,6 +73,9 @@ export interface FeedFilters { tagMode: "or" | "and"; q: string; sort: string; + // Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the + // feed re-queries with a fresh order. Ignored by every other sort. + seed?: number; includeNormal: boolean; includeShorts: boolean; includeLive: boolean; @@ -153,6 +156,7 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string { p.set("tag_mode", f.tagMode); if (f.q) p.set("q", f.q); p.set("sort", f.sort); + if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); p.set("show_normal", String(f.includeNormal)); p.set("include_shorts", String(f.includeShorts)); p.set("include_live", String(f.includeLive)); From 95a18dfcc16e8a7bb18d67e31d2df6cc24f5c7a3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 04:06:14 +0200 Subject: [PATCH 3/6] feat(feed): add scope=all to browse the whole shared catalog The feed query was always scoped to the user's own non-hidden subscriptions via an INNER JOIN on subscriptions. Add a scope param: scope=my (default) keeps that behaviour; scope=all LEFT-joins the subscription instead, so every video in the shared catalog shows while per-channel priority still resolves for channels the user is subscribed to. Per-user watch state stays private via the VideoState outer join in both modes. The priority sort is made null-safe (coalesce to 0) since unsubscribed channels have no subscription row in all-mode. --- backend/app/routes/feed.py | 66 +++++++++++++++++++++++++------------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 6134e23..fc0b8b3 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -66,33 +66,49 @@ def _filtered_query( include_shorts: bool, include_live: bool, show: str, + scope: str = "my", ) -> tuple[Select, object]: """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. - Returns the column-bearing select plus the watch-status expression for sorting.""" + Returns the column-bearing select plus the watch-status expression for sorting. + + `scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions. + `scope="all"` shows every video in the shared catalog (any user's ingested channels); + the subscription is then only LEFT-joined so per-channel priority still resolves for + channels the user happens to be subscribed to. Per-user watch state stays private in + either mode via the VideoState outer join.""" state = aliased(VideoState) status_expr = func.coalesce(state.status, "new") position_expr = func.coalesce(state.position_seconds, 0) - query = ( - select( - Video.id, - Video.title, - Video.channel_id, - Channel.title.label("channel_title"), - Channel.thumbnail_url.label("channel_thumbnail"), - Channel.handle.label("channel_handle"), - Video.published_at, - Video.thumbnail_url, - Video.duration_seconds, - Video.view_count, - Video.is_short, - Video.live_status, - status_expr.label("status"), - position_expr.label("position_seconds"), + query = select( + Video.id, + Video.title, + Video.channel_id, + Channel.title.label("channel_title"), + Channel.thumbnail_url.label("channel_thumbnail"), + Channel.handle.label("channel_handle"), + Video.published_at, + Video.thumbnail_url, + Video.duration_seconds, + Video.view_count, + Video.is_short, + Video.live_status, + status_expr.label("status"), + position_expr.label("position_seconds"), + ).join(Channel, Channel.id == Video.channel_id) + + if scope == "all": + # Whole shared catalog; subscription is optional (only for priority sort). + query = query.outerjoin( + Subscription, + and_( + Subscription.channel_id == Video.channel_id, + Subscription.user_id == user.id, + ), ) - .join(Channel, Channel.id == Video.channel_id) + else: # Only channels this user is subscribed to (and hasn't hidden). - .join( + query = query.join( Subscription, and_( Subscription.channel_id == Video.channel_id, @@ -100,7 +116,9 @@ def _filtered_query( Subscription.hidden.is_(False), ), ) - .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id)) + + query = query.outerjoin( + state, and_(state.video_id == Video.id, state.user_id == user.id) ) if channel_id: @@ -199,6 +217,7 @@ def _feed_params( include_shorts: bool = False, include_live: bool = False, show: str = "unwatched", + scope: str = "my", ) -> dict: return { "tags": tags, @@ -214,6 +233,7 @@ def _feed_params( "include_shorts": include_shorts, "include_live": include_live, "show": show, + "scope": scope, } @@ -226,7 +246,8 @@ SORTS = { "title": func.lower(Video.title).asc().nulls_last(), "subscribers": Channel.subscriber_count.desc().nulls_last(), # Your per-channel priority (set in the channel manager), newest first within a tier. - "priority": Subscription.priority.desc(), + # coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row. + "priority": func.coalesce(Subscription.priority, 0).desc(), } @@ -243,7 +264,8 @@ def get_feed( query, _status = _filtered_query(db, user, **params) if sort == "priority": query = query.order_by( - Subscription.priority.desc(), Video.published_at.desc().nulls_last() + func.coalesce(Subscription.priority, 0).desc(), + Video.published_at.desc().nulls_last(), ) else: order = SORTS.get(sort) From eb58f436b68e780b9cb24e2491d39b8302ee0a6f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 04:06:22 +0200 Subject: [PATCH 4/6] feat(feed): shared-library scope toggle in the header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Mine / Library" segmented toggle (shown on the feed page) that switches FeedFilters.scope between the user's own subscriptions and the whole shared catalog. A read-scope-less user (signed in but no YouTube grant) can now browse and manage the shared library on their own account — the empty "my feed" state offers a "browse the shared library" shortcut alongside the connect-YouTube CTA. scope is preserved across "Clear all" (it's a mode, not a filter) and kept out of the URL state. Trilingual strings (HU/EN/DE) for the toggle and the CTA. --- frontend/src/App.tsx | 1 + frontend/src/components/Feed.tsx | 10 +++++++- frontend/src/components/Header.tsx | 31 +++++++++++++++++++++++- frontend/src/components/Sidebar.tsx | 7 +++--- frontend/src/i18n/locales/de/feed.json | 1 + frontend/src/i18n/locales/de/header.json | 7 ++++++ frontend/src/i18n/locales/en/feed.json | 1 + frontend/src/i18n/locales/en/header.json | 7 ++++++ frontend/src/i18n/locales/hu/feed.json | 1 + frontend/src/i18n/locales/hu/header.json | 7 ++++++ frontend/src/lib/api.ts | 3 +++ 11 files changed, 71 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e890b5..ff3dfcf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ const DEFAULT_FILTERS: FeedFilters = { tagMode: "or", q: "", sort: "newest", + scope: "my", includeNormal: true, includeShorts: false, includeLive: false, diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 09a05cf..7380bb0 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -148,7 +148,9 @@ export default function Feed({ if (query.isLoading) return
{t("feed.loading")}
; if (query.isError) return
{t("feed.loadError")}
; if (items.length === 0) { - if (!canRead) + // No YouTube connection and looking at their own (empty) subscriptions: offer both + // connecting YouTube and just browsing the shared library (no read scope needed). + if (!canRead && filters.scope === "my") return (
@@ -160,6 +162,12 @@ export default function Feed({ > {t("feed.setUp")} +
); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 7474091..15dc8d0 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,6 @@ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { BarChart3, Home, Info, LogOut, Search, Settings, Shield, Tv } from "lucide-react"; +import { BarChart3, Home, Info, Library, LogOut, Search, Settings, Shield, Tv, User } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import { type LangCode } from "../i18n"; @@ -49,6 +49,35 @@ export default function Header({ + {page === "feed" && ( +
+ {(["my", "all"] as const).map((s) => ( + + ))} +
+ )} + {page === "feed" ? (
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6e37c9a..3ab6c97 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -60,8 +60,9 @@ const DATE_PRESETS: { days: number; key: string }[] = [ { days: 365, key: "1year" }, ]; -// Filter values owned by the sidebar (everything except the header search `q`). -const DEFAULT_SIDEBAR_FILTERS: Omit = { +// Filter values owned by the sidebar (everything except the header search `q` and the +// `scope` mode, both preserved across a "Clear all"). +const DEFAULT_SIDEBAR_FILTERS: Omit = { tags: [], tagMode: "or", sort: "newest", @@ -170,7 +171,7 @@ export default function Sidebar({ function clearAll() { setCustomDates(false); - setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q }); + setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope }); } const available: Record = { diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json index 07e9185..3a46ab8 100644 --- a/frontend/src/i18n/locales/de/feed.json +++ b/frontend/src/i18n/locales/de/feed.json @@ -4,6 +4,7 @@ "emptyTitle": "Dein Feed ist leer", "emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.", "setUp": "Meinen Feed einrichten", + "browseShared": "Oder einfach die gemeinsame Bibliothek durchsuchen", "noMatches": "Keine Videos entsprechen diesen Filtern.", "videoCount_one": "{{formattedCount}} Video", "videoCount_other": "{{formattedCount}} Videos", diff --git a/frontend/src/i18n/locales/de/header.json b/frontend/src/i18n/locales/de/header.json index 4465368..b634f4f 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -3,6 +3,13 @@ "searchPlaceholder": "Deine Abos durchsuchen…", "channelManager": "Kanalverwaltung", "usageStats": "Nutzung & Statistik", + "scope": { + "label": "Feed-Quelle", + "my": "Meine", + "myTip": "Nur Videos aus deinen eigenen Abos", + "all": "Bibliothek", + "allTip": "Alle Videos im gemeinsamen Katalog" + }, "account": { "admin": "Admin", "feed": "Feed", diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 571247c..8e30dda 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -4,6 +4,7 @@ "emptyTitle": "Your feed is empty", "emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.", "setUp": "Set up my feed", + "browseShared": "Or just browse the shared library", "noMatches": "No videos match these filters.", "videoCount_one": "{{formattedCount}} video", "videoCount_other": "{{formattedCount}} videos", diff --git a/frontend/src/i18n/locales/en/header.json b/frontend/src/i18n/locales/en/header.json index 7cbe22d..d01e782 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -3,6 +3,13 @@ "searchPlaceholder": "Search your subscriptions…", "channelManager": "Channel manager", "usageStats": "Usage & stats", + "scope": { + "label": "Feed source", + "my": "Mine", + "myTip": "Only videos from your own subscriptions", + "all": "Library", + "allTip": "Every video in the shared catalog" + }, "account": { "admin": "Admin", "feed": "Feed", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 0c30c5e..2995492 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -4,6 +4,7 @@ "emptyTitle": "A hírfolyamod üres", "emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.", "setUp": "Hírfolyam beállítása", + "browseShared": "Vagy csak böngészd a közös könyvtárat", "noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.", "videoCount_one": "{{formattedCount}} videó", "videoCount_other": "{{formattedCount}} videó", diff --git a/frontend/src/i18n/locales/hu/header.json b/frontend/src/i18n/locales/hu/header.json index 44dcb04..535fad8 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -3,6 +3,13 @@ "searchPlaceholder": "Keresés a feliratkozásaid között…", "channelManager": "Csatornakezelő", "usageStats": "Használat és statisztika", + "scope": { + "label": "Hírfolyam forrása", + "my": "Sajátom", + "myTip": "Csak a saját feliratkozásaid videói", + "all": "Könyvtár", + "allTip": "A közös könyvtár összes videója" + }, "account": { "admin": "Adminisztrátor", "feed": "Hírfolyam", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 95a9c43..e813069 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -76,6 +76,8 @@ export interface FeedFilters { // Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the // feed re-queries with a fresh order. Ignored by every other sort. seed?: number; + // "my" = only your subscriptions (default); "all" = the whole shared catalog. + scope: "my" | "all"; includeNormal: boolean; includeShorts: boolean; includeLive: boolean; @@ -157,6 +159,7 @@ function feedQuery(f: FeedFilters, offset: number, limit: number): string { if (f.q) p.set("q", f.q); p.set("sort", f.sort); if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); + p.set("scope", f.scope); p.set("show_normal", String(f.includeNormal)); p.set("include_shorts", String(f.includeShorts)); p.set("include_live", String(f.includeLive)); From 12fe21d693e33e88f973c8def81bd65ccfb835e9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 04:23:49 +0200 Subject: [PATCH 5/6] improvement(channels): distinct label for full-history queued by another user The channel manager already styled the two deep-backfill-pending states differently (solid clickable chip when you requested full history vs a faint outline when another subscriber did), but both used the same "full history queued" label, so a channel queued by someone else looked identical to one you queued yourself. Give the by-other case its own label ("full history coming") so the distinction is legible at a glance, not just on hover. Trilingual. --- frontend/src/components/Channels.tsx | 2 +- frontend/src/i18n/locales/de/channels.json | 1 + frontend/src/i18n/locales/en/channels.json | 1 + frontend/src/i18n/locales/hu/channels.json | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 32f4785..2e32004 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -444,7 +444,7 @@ function ChannelRow({ - {t("channels.row.fullHistoryQueued")} + {t("channels.row.fullHistoryComing")} ) : ( diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json index e317808..8638c94 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -45,6 +45,7 @@ "full": "vollständig", "fullHint": "Vollständiger Verlauf geladen.", "fullHistoryQueued": "vollständiger Verlauf in Warteschlange", + "fullHistoryComing": "vollständiger Verlauf unterwegs", "queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.", "queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.", "getFullHistory": "vollständigen Verlauf laden", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 3fbbb72..9ecd280 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -45,6 +45,7 @@ "full": "full", "fullHint": "Full history fetched.", "fullHistoryQueued": "full history queued", + "fullHistoryComing": "full history coming", "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", "queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.", "getFullHistory": "get full history", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 6767ed4..30904eb 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -45,6 +45,7 @@ "full": "teljes", "fullHint": "Teljes előzmény letöltve.", "fullHistoryQueued": "teljes előzmény sorban", + "fullHistoryComing": "teljes előzmény úton", "queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.", "queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.", "getFullHistory": "teljes előzmény lekérése", From 99b963dbe63e5feb5f1bdcb1b04389e6138a80b0 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 04:30:27 +0200 Subject: [PATCH 6/6] improvement(sync): reflect deep backfill as active in the header status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header status only considered recent-sync pending, so it read "all synced" while the scheduler was still backfilling full history — directly contradicting the adjacent "N without full history" notice. Add an active "fetching history" state (spinner) shown when recent sync is done but deep backfill is still pending, so "all synced" appears only when nothing is pending at all. The admin pause button now also shows during deep backfill (it's pausable work). Trilingual. --- frontend/src/components/SyncStatus.tsx | 14 +++++++++++--- frontend/src/i18n/locales/de/header.json | 1 + frontend/src/i18n/locales/en/header.json | 1 + frontend/src/i18n/locales/hu/header.json | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 447238e..2e5c368 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -57,6 +57,13 @@ export default function SyncStatus({ {t("header.sync.syncing", { count: syncing })} + ) : notFull > 0 ? ( + // Recent uploads are in, but the scheduler is still backfilling full history — + // so "all synced" would be misleading. Reflect the deep work as active. + + + {t("header.sync.backfillingHistory")} + ) : ( {t("header.sync.allSynced")} )} @@ -74,9 +81,10 @@ export default function SyncStatus({ )} - {/* Pause only makes sense when there's work to pause; Resume must always show - while paused so it can be lifted. Hidden entirely when idle and not paused. */} - {isAdmin && (data.paused || syncing > 0) && ( + {/* Pause only makes sense when there's work to pause (recent OR deep backfill); + Resume must always show while paused so it can be lifted. Hidden entirely when + idle and not paused. */} + {isAdmin && (data.paused || syncing > 0 || notFull > 0) && (