From 78fb1dfe6dda6c427bbc3bb6b161ff28fafe1202 Mon Sep 17 00:00:00 2001 From: Daniel JB Clark Date: Fri, 31 Jul 2026 12:47:24 -0400 Subject: [PATCH 1/5] Reapply "fix: always delta-sync so category migrations reach Android" This reverts commit cc6e7b85d2f73f68a51fc16ac4486bc04cc01a09. --- superbrain-app/src/screens/HomeScreen.tsx | 15 +++++++-------- superbrain-app/src/services/syncService.ts | 5 +++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/superbrain-app/src/screens/HomeScreen.tsx b/superbrain-app/src/screens/HomeScreen.tsx index fa1d7f6..afab4a7 100644 --- a/superbrain-app/src/screens/HomeScreen.tsx +++ b/superbrain-app/src/screens/HomeScreen.tsx @@ -309,18 +309,15 @@ const HomeScreen = () => { const merged = [...analyzingPlaceholders, ...localPosts]; setPosts(merged); setLoading(false); - - // If not forcing refresh and no analyzing posts, we're done - if (!forceRefresh && analyzingShortcodes.length === 0) { - return; - } + // Do not return early: always continue to background sync so server-side + // recategorizations / taxonomy cuts reach the device. } else { // No local data at all — show loading spinner setLoading(true); } // ── Step 2: Background sync with backend ── - // This runs non-blocking: UI is already showing local data (if any) + // UI may already be showing local data; refresh if sync changes anything. const isOnline = await apiService.testConnection().catch(() => false); if (isOnline) { @@ -328,11 +325,12 @@ const HomeScreen = () => { await postsCache.flushPendingPostMutations(); await postsCache.flushPendingAnalyses(); - // Delta or full sync depending on DB state - const dataChanged = await syncService.syncIfNeeded(); + // Delta or full sync depending on DB state / pull-to-refresh + const dataChanged = await syncService.syncIfNeeded(forceRefresh); // If sync brought new data, re-read from local DB and update UI if (dataChanged || forceRefresh) { + await loadCategories(); const freshPosts = await localDb.getAllPosts(); if (freshPosts.length > 0) { // Clear analyzing state for posts that now exist in the synced data @@ -421,6 +419,7 @@ const HomeScreen = () => { const onRefresh = async () => { setRefreshing(true); + await loadCategories(); await loadPosts(true); // Force refresh from server setRefreshing(false); }; diff --git a/superbrain-app/src/services/syncService.ts b/superbrain-app/src/services/syncService.ts index edca8b6..dbe60bb 100644 --- a/superbrain-app/src/services/syncService.ts +++ b/superbrain-app/src/services/syncService.ts @@ -107,13 +107,14 @@ async function deltaSync(): Promise { /** * Decides whether to do a full or delta sync. * - Empty local DB → full sync + * - forceFull → full sync (pull-to-refresh after server-side migrations) * - Has data → delta sync * Returns true if any data changed. */ -async function syncIfNeeded(): Promise { +async function syncIfNeeded(forceFull: boolean = false): Promise { try { const empty = await localDb.isEmpty(); - if (empty) { + if (empty || forceFull) { const count = await fullSync(); return count > 0; } else { From 5bf7fdce3bc55a72a27220c56927078b2a9d5fed Mon Sep 17 00:00:00 2001 From: Daniel JB Clark Date: Fri, 31 Jul 2026 13:18:33 -0400 Subject: [PATCH 2/5] fix: full-resync Android posts when taxonomy version changes Delta sync can miss category migrations if last_synced_at already advanced; persist taxonomy_version and force a full post pull when it differs. Co-authored-by: Cursor --- superbrain-app/src/services/api.ts | 3 +++ superbrain-app/src/services/localDb.ts | 19 ++++++++++++++++ superbrain-app/src/services/syncService.ts | 26 +++++++++++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/superbrain-app/src/services/api.ts b/superbrain-app/src/services/api.ts index 240c1f5..8422105 100644 --- a/superbrain-app/src/services/api.ts +++ b/superbrain-app/src/services/api.ts @@ -419,6 +419,7 @@ class ApiService { allow_multiple_categories: boolean; fallback_category: string; use_default_categories: boolean; + taxonomy_version: string; } | null> { try { const headers = await this.getHeaders(); @@ -429,6 +430,7 @@ class ApiService { allow_multiple_categories: boolean; fallback_category: string; use_default_categories: boolean; + taxonomy_version?: string; }>( `${baseUrl}/taxonomy`, { headers, timeout: DEFAULT_TIMEOUT } @@ -439,6 +441,7 @@ class ApiService { allow_multiple_categories: !!response.data.allow_multiple_categories, fallback_category: response.data.fallback_category || 'Other', use_default_categories: !!response.data.use_default_categories, + taxonomy_version: response.data.taxonomy_version || '', }; } catch (error) { console.error('Error fetching taxonomy:', error); diff --git a/superbrain-app/src/services/localDb.ts b/superbrain-app/src/services/localDb.ts index a9c39b5..c20d4bf 100644 --- a/superbrain-app/src/services/localDb.ts +++ b/superbrain-app/src/services/localDb.ts @@ -230,6 +230,23 @@ async function setLastSyncTime(isoTimestamp: string): Promise { ); } +async function getSyncMeta(key: string): Promise { + const db = await getDb(); + const row = await db.getFirstAsync<{ value: string }>( + 'SELECT value FROM sync_meta WHERE key = ?', + [key] + ); + return row?.value ?? null; +} + +async function setSyncMeta(key: string, value: string): Promise { + const db = await getDb(); + await db.runAsync( + 'INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)', + [key, value] + ); +} + // ─── Utilities ───────────────────────────────────────────────────── /** Drop all data — used for hard reset. */ @@ -258,6 +275,8 @@ const localDb = { updatePost, getLastSyncTime, setLastSyncTime, + getSyncMeta, + setSyncMeta, clearAll, isEmpty, }; diff --git a/superbrain-app/src/services/syncService.ts b/superbrain-app/src/services/syncService.ts index dbe60bb..eb50fbb 100644 --- a/superbrain-app/src/services/syncService.ts +++ b/superbrain-app/src/services/syncService.ts @@ -108,14 +108,38 @@ async function deltaSync(): Promise { * Decides whether to do a full or delta sync. * - Empty local DB → full sync * - forceFull → full sync (pull-to-refresh after server-side migrations) + * - Server taxonomy_version differs from last successful sync → full sync + * (category migrations bump version; delta alone can miss them if the + * device's last_synced_at cursor already advanced) * - Has data → delta sync * Returns true if any data changed. */ async function syncIfNeeded(forceFull: boolean = false): Promise { try { const empty = await localDb.isEmpty(); - if (empty || forceFull) { + let taxonomyChanged = false; + let taxonomyVersion = ''; + try { + const taxonomy = await apiService.getTaxonomy(); + taxonomyVersion = taxonomy?.taxonomy_version || ''; + if (taxonomyVersion) { + const localVersion = await localDb.getSyncMeta('taxonomy_version'); + taxonomyChanged = localVersion !== taxonomyVersion; + if (taxonomyChanged) { + console.log( + `[Sync] Taxonomy version changed (${localVersion || 'none'} → ${taxonomyVersion}); forcing full sync` + ); + } + } + } catch { + /* offline / taxonomy endpoint unavailable — fall through */ + } + + if (empty || forceFull || taxonomyChanged) { const count = await fullSync(); + if (count > 0 && taxonomyVersion) { + await localDb.setSyncMeta('taxonomy_version', taxonomyVersion); + } return count > 0; } else { const changes = await deltaSync(); From cbfa5a06128140b09595b30bd5c1e4f0be941f2f Mon Sep 17 00:00:00 2001 From: Daniel JB Clark Date: Fri, 31 Jul 2026 13:21:56 -0400 Subject: [PATCH 3/5] fix: honor use_default_categories=false in Android category chips Stop seeding or merging legacy product/places/food chips when the server taxonomy disables built-in defaults; clarify the config example. Co-authored-by: Cursor --- backend/config/categories.toml.example | 4 ++ superbrain-app/src/constants/categories.ts | 25 ++++++---- superbrain-app/src/screens/HomeScreen.tsx | 46 +++++++++++-------- .../src/screens/PostDetailScreen.tsx | 19 ++++++-- 4 files changed, 59 insertions(+), 35 deletions(-) diff --git a/backend/config/categories.toml.example b/backend/config/categories.toml.example index ed679ef..109b819 100644 --- a/backend/config/categories.toml.example +++ b/backend/config/categories.toml.example @@ -6,6 +6,10 @@ # file under ~/.superbrain-server/config/categories.toml is safe from deploys. [taxonomy] +# false = only the [[categories]] list below is the taxonomy. Built-in +# defaults (product, places, food, software, book, …) are not used for +# classification and must not appear as client filter/edit chips. +# true = merge built-in defaults with any [[categories]] you add. use_default_categories = false allow_multiple_categories = false fallback_category = "Other" diff --git a/superbrain-app/src/constants/categories.ts b/superbrain-app/src/constants/categories.ts index 3f718e0..a650511 100644 --- a/superbrain-app/src/constants/categories.ts +++ b/superbrain-app/src/constants/categories.ts @@ -1,13 +1,12 @@ -export const DEFAULT_CATEGORIES = [ - { id: 'all', name: 'All', icon: 'star', count: 0 }, - // Operator taxonomy (config-driven on server) - { id: 'sysadmin', name: 'Sysadmin', icon: 'terminal', count: 0 }, - { id: 'science', name: 'Science', icon: 'flask', count: 0 }, - { id: 'technology', name: 'Technology', icon: 'hardware-chip', count: 0 }, - { id: 'history', name: 'History', icon: 'hourglass', count: 0 }, - { id: 'humanities', name: 'Humanities', icon: 'library', count: 0 }, - { id: 'politics', name: 'Politics', icon: 'newspaper', count: 0 }, - // Legacy built-in names (kept until migrated / for older posts) +/** Chip shown for the unfiltered feed. */ +export const ALL_CATEGORY = { id: 'all', name: 'All', icon: 'star', count: 0 }; + +/** + * Built-in legacy taxonomy (product/places/food/…). + * Only used when the server reports `use_default_categories: true`. + * When that flag is false, clients must not seed or show these chips. + */ +export const BUILTIN_DEFAULT_CATEGORIES = [ { id: 'product', name: 'Product', icon: 'cube', count: 0 }, { id: 'places', name: 'Places', icon: 'location', count: 0 }, { id: 'food', name: 'Food', icon: 'restaurant', count: 0 }, @@ -20,6 +19,12 @@ export const DEFAULT_CATEGORIES = [ { id: 'other', name: 'Other', icon: 'pricetag', count: 0 }, ]; +/** + * Offline / pre-taxonomy placeholder: All only. + * Real chips come from `/taxonomy` after load. + */ +export const DEFAULT_CATEGORIES = [ALL_CATEGORY]; + export const CATEGORY_ICONS: Record = { 'all': 'star', 'sysadmin': 'terminal-outline', diff --git a/superbrain-app/src/screens/HomeScreen.tsx b/superbrain-app/src/screens/HomeScreen.tsx index afab4a7..e89cd78 100644 --- a/superbrain-app/src/screens/HomeScreen.tsx +++ b/superbrain-app/src/screens/HomeScreen.tsx @@ -40,7 +40,7 @@ import { RootStackParamList } from '../../App'; import CustomToast from '../components/CustomToast'; import BottomNav from '../components/BottomNav'; import { getCollectionIconName, getCollectionIconColor } from '../constants/icons'; -import { DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { ALL_CATEGORY, BUILTIN_DEFAULT_CATEGORIES, DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; type NavigationProp = NativeStackNavigationProp; @@ -208,21 +208,33 @@ const HomeScreen = () => { apiService.getTaxonomy().catch(() => null), ]); - // Prefer configured taxonomy order when available; fall back to app defaults. - const seed = - taxonomy && taxonomy.categories.length > 0 - ? taxonomy.categories.map(c => ({ - id: c.id, - name: c.name, - icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag-outline', - count: 0, - })) - : DEFAULT_CATEGORIES.filter(c => c.id !== 'all').map(c => ({ ...c, count: 0 })); + // use_default_categories=false → only configured [[categories]]; never + // seed or surface built-in product/places/food/… chips (even if /categories + // still reports historical counts for leftover labels). + const useDefaults = taxonomy ? !!taxonomy.use_default_categories : false; + + let seed: Array<{ id: string; name: string; icon: string; count: number }>; + if (taxonomy && taxonomy.categories.length > 0) { + seed = taxonomy.categories.map(c => ({ + id: c.id, + name: c.name, + icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag-outline', + count: 0, + })); + } else if (useDefaults) { + seed = BUILTIN_DEFAULT_CATEGORIES.map(c => ({ ...c, count: 0 })); + } else { + seed = []; + } const mergedById = new Map(seed.map(c => [c.id.toLowerCase(), { ...c }])); + const configuredIds = new Set(mergedById.keys()); for (const c of cats || []) { const id = c.id.toLowerCase(); + if (!useDefaults && configuredIds.size > 0 && !configuredIds.has(id)) { + continue; // strict custom taxonomy: drop legacy / uncatalogued chips + } const existing = mergedById.get(id); mergedById.set(id, { id, @@ -232,15 +244,9 @@ const HomeScreen = () => { }); } - // Hide legacy zero-count defaults when a configured taxonomy is active. - let merged = Array.from(mergedById.values()); - if (taxonomy && taxonomy.categories.length > 0) { - const configured = new Set(taxonomy.categories.map(c => c.id.toLowerCase())); - merged = merged.filter(c => configured.has(c.id.toLowerCase()) || c.count > 0); - } - - const totalCount = merged.reduce((sum, c) => sum + c.count, 0); - setCategories([{ id: 'all', name: 'All', icon: 'star', count: totalCount }, ...merged]); + const merged = Array.from(mergedById.values()); + const totalCount = (cats || []).reduce((sum, c) => sum + (c.count || 0), 0); + setCategories([{ ...ALL_CATEGORY, count: totalCount }, ...merged]); } catch (e) { console.warn('Failed to load categories, using defaults:', e); } diff --git a/superbrain-app/src/screens/PostDetailScreen.tsx b/superbrain-app/src/screens/PostDetailScreen.tsx index b17a68a..7c8e69a 100644 --- a/superbrain-app/src/screens/PostDetailScreen.tsx +++ b/superbrain-app/src/screens/PostDetailScreen.tsx @@ -13,15 +13,18 @@ import { collectionsService } from '../services/collections'; import { Collection } from '../types'; import { schedulePostWatchLaterNotification, sendImmediateWatchLaterNotification, sendImmediateSavedNotification } from '../services/notificationService'; import { getCollectionIconName, getCollectionIconColor } from '../constants/icons'; -import { DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { BUILTIN_DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; type Props = NativeStackScreenProps; type CategoryOption = { id: string; name: string; icon: string }; -const FALLBACK_CATEGORIES: CategoryOption[] = DEFAULT_CATEGORIES - .filter(c => c.id !== 'all') - .map(c => ({ id: c.id, name: c.name, icon: c.icon })); +/** Only used if /taxonomy is unreachable and defaults are enabled. */ +const FALLBACK_CATEGORIES: CategoryOption[] = BUILTIN_DEFAULT_CATEGORIES.map(c => ({ + id: c.id, + name: c.name, + icon: c.icon, +})); const PostDetailScreen = ({ route, navigation }: Props) => { const { post } = route.params; @@ -31,7 +34,7 @@ const PostDetailScreen = ({ route, navigation }: Props) => { const [editedTitle, setEditedTitle] = useState(post.title); const [editedSummary, setEditedSummary] = useState(post.summary); const [saving, setSaving] = useState(false); - const [categoryOptions, setCategoryOptions] = useState(FALLBACK_CATEGORIES); + const [categoryOptions, setCategoryOptions] = useState([]); const [deleting, setDeleting] = useState(false); const [reanalyzing, setReanalyzing] = useState(false); const [toast, setToast] = useState({ visible: false, message: '', type: 'info' as 'success' | 'error' | 'warning' | 'info' }); @@ -66,6 +69,12 @@ const PostDetailScreen = ({ route, navigation }: Props) => { ); return; } + // No taxonomy payload: only fall back to built-ins when the server + // still uses the legacy default set (or taxonomy is unavailable). + if (taxonomy && taxonomy.use_default_categories === false) { + setCategoryOptions([]); + return; + } const cats = await apiService.getCategories(); if (cancelled) return; if (cats && cats.length > 0) { From 79b967a684a1569d72f33b6522116807df74eb96 Mon Sep 17 00:00:00 2001 From: Daniel JB Clark Date: Fri, 31 Jul 2026 13:46:31 -0400 Subject: [PATCH 4/5] fix: gate Android taxonomy behavior behind GET /taxonomy Keep upstream mainline chip/sync behavior when the taxonomy API is absent; activate strict chips, always-sync, and version full-resync only for taxonomy-aware servers. Co-authored-by: Cursor --- superbrain-app/src/constants/categories.ts | 12 ++--- superbrain-app/src/screens/HomeScreen.tsx | 53 ++++++++++++------- .../src/screens/PostDetailScreen.tsx | 30 +++-------- superbrain-app/src/services/api.ts | 7 ++- superbrain-app/src/services/syncService.ts | 18 ++++--- .../src/services/taxonomySupport.ts | 33 ++++++++++++ 6 files changed, 97 insertions(+), 56 deletions(-) create mode 100644 superbrain-app/src/services/taxonomySupport.ts diff --git a/superbrain-app/src/constants/categories.ts b/superbrain-app/src/constants/categories.ts index a650511..9f18dae 100644 --- a/superbrain-app/src/constants/categories.ts +++ b/superbrain-app/src/constants/categories.ts @@ -2,9 +2,9 @@ export const ALL_CATEGORY = { id: 'all', name: 'All', icon: 'star', count: 0 }; /** - * Built-in legacy taxonomy (product/places/food/…). - * Only used when the server reports `use_default_categories: true`. - * When that flag is false, clients must not seed or show these chips. + * Built-in mainline categories (product/places/food/…). + * Used when GET /taxonomy is unavailable, or when the server sets + * use_default_categories=true. Matching upstream default chip set. */ export const BUILTIN_DEFAULT_CATEGORIES = [ { id: 'product', name: 'Product', icon: 'cube', count: 0 }, @@ -20,10 +20,10 @@ export const BUILTIN_DEFAULT_CATEGORIES = [ ]; /** - * Offline / pre-taxonomy placeholder: All only. - * Real chips come from `/taxonomy` after load. + * Default chip set for servers without config-driven taxonomy (upstream path). + * Custom taxonomies replace this via GET /taxonomy when present. */ -export const DEFAULT_CATEGORIES = [ALL_CATEGORY]; +export const DEFAULT_CATEGORIES = [ALL_CATEGORY, ...BUILTIN_DEFAULT_CATEGORIES]; export const CATEGORY_ICONS: Record = { 'all': 'star', diff --git a/superbrain-app/src/screens/HomeScreen.tsx b/superbrain-app/src/screens/HomeScreen.tsx index e89cd78..bb870c3 100644 --- a/superbrain-app/src/screens/HomeScreen.tsx +++ b/superbrain-app/src/screens/HomeScreen.tsx @@ -40,7 +40,11 @@ import { RootStackParamList } from '../../App'; import CustomToast from '../components/CustomToast'; import BottomNav from '../components/BottomNav'; import { getCollectionIconName, getCollectionIconColor } from '../constants/icons'; -import { ALL_CATEGORY, BUILTIN_DEFAULT_CATEGORIES, DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { ALL_CATEGORY, DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { + isTaxonomyApiActive, + usesStrictCustomTaxonomy, +} from '../services/taxonomySupport'; type NavigationProp = NativeStackNavigationProp; @@ -208,23 +212,22 @@ const HomeScreen = () => { apiService.getTaxonomy().catch(() => null), ]); - // use_default_categories=false → only configured [[categories]]; never - // seed or surface built-in product/places/food/… chips (even if /categories - // still reports historical counts for leftover labels). - const useDefaults = taxonomy ? !!taxonomy.use_default_categories : false; + // No GET /taxonomy (upstream today) → keep full built-in pill set + counts. + // Taxonomy present + use_default_categories=false → configured chips only. + // Taxonomy present + defaults on → seed from server taxonomy list. + const taxonomyActive = isTaxonomyApiActive(taxonomy); + const strictCustom = usesStrictCustomTaxonomy(taxonomy); let seed: Array<{ id: string; name: string; icon: string; count: number }>; - if (taxonomy && taxonomy.categories.length > 0) { - seed = taxonomy.categories.map(c => ({ + if (taxonomyActive) { + seed = taxonomy!.categories.map(c => ({ id: c.id, name: c.name, icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag-outline', count: 0, })); - } else if (useDefaults) { - seed = BUILTIN_DEFAULT_CATEGORIES.map(c => ({ ...c, count: 0 })); } else { - seed = []; + seed = DEFAULT_CATEGORIES.filter(c => c.id !== 'all').map(c => ({ ...c, count: 0 })); } const mergedById = new Map(seed.map(c => [c.id.toLowerCase(), { ...c }])); @@ -232,8 +235,8 @@ const HomeScreen = () => { for (const c of cats || []) { const id = c.id.toLowerCase(); - if (!useDefaults && configuredIds.size > 0 && !configuredIds.has(id)) { - continue; // strict custom taxonomy: drop legacy / uncatalogued chips + if (strictCustom && !configuredIds.has(id)) { + continue; } const existing = mergedById.get(id); mergedById.set(id, { @@ -245,7 +248,10 @@ const HomeScreen = () => { } const merged = Array.from(mergedById.values()); - const totalCount = (cats || []).reduce((sum, c) => sum + (c.count || 0), 0); + // Upstream totals from visible chips; strict taxonomy still counts all posts in All. + const totalCount = strictCustom + ? (cats || []).reduce((sum, c) => sum + (c.count || 0), 0) + : merged.reduce((sum, c) => sum + c.count, 0); setCategories([{ ...ALL_CATEGORY, count: totalCount }, ...merged]); } catch (e) { console.warn('Failed to load categories, using defaults:', e); @@ -315,8 +321,15 @@ const HomeScreen = () => { const merged = [...analyzingPlaceholders, ...localPosts]; setPosts(merged); setLoading(false); - // Do not return early: always continue to background sync so server-side - // recategorizations / taxonomy cuts reach the device. + + // Upstream path: return early when local data is enough. + // Taxonomy-aware servers: always continue so migrations reach the device. + if (!forceRefresh && analyzingShortcodes.length === 0) { + const taxonomy = await apiService.getTaxonomy().catch(() => null); + if (!isTaxonomyApiActive(taxonomy)) { + return; + } + } } else { // No local data at all — show loading spinner setLoading(true); @@ -331,12 +344,17 @@ const HomeScreen = () => { await postsCache.flushPendingPostMutations(); await postsCache.flushPendingAnalyses(); - // Delta or full sync depending on DB state / pull-to-refresh + // forceFull / taxonomy_version resync only activate when GET /taxonomy exists + // (see syncService); without it this matches upstream delta-only sync. const dataChanged = await syncService.syncIfNeeded(forceRefresh); // If sync brought new data, re-read from local DB and update UI if (dataChanged || forceRefresh) { - await loadCategories(); + // Category chip reload is taxonomy-aware; skip extra work on mainline. + const taxonomy = await apiService.getTaxonomy().catch(() => null); + if (isTaxonomyApiActive(taxonomy)) { + await loadCategories(); + } const freshPosts = await localDb.getAllPosts(); if (freshPosts.length > 0) { // Clear analyzing state for posts that now exist in the synced data @@ -425,7 +443,6 @@ const HomeScreen = () => { const onRefresh = async () => { setRefreshing(true); - await loadCategories(); await loadPosts(true); // Force refresh from server setRefreshing(false); }; diff --git a/superbrain-app/src/screens/PostDetailScreen.tsx b/superbrain-app/src/screens/PostDetailScreen.tsx index 7c8e69a..d678f34 100644 --- a/superbrain-app/src/screens/PostDetailScreen.tsx +++ b/superbrain-app/src/screens/PostDetailScreen.tsx @@ -14,12 +14,13 @@ import { Collection } from '../types'; import { schedulePostWatchLaterNotification, sendImmediateWatchLaterNotification, sendImmediateSavedNotification } from '../services/notificationService'; import { getCollectionIconName, getCollectionIconColor } from '../constants/icons'; import { BUILTIN_DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; +import { isTaxonomyApiActive } from '../services/taxonomySupport'; type Props = NativeStackScreenProps; type CategoryOption = { id: string; name: string; icon: string }; -/** Only used if /taxonomy is unreachable and defaults are enabled. */ +/** Upstream / no-taxonomy edit list (built-in mainline categories). */ const FALLBACK_CATEGORIES: CategoryOption[] = BUILTIN_DEFAULT_CATEGORIES.map(c => ({ id: c.id, name: c.name, @@ -34,7 +35,7 @@ const PostDetailScreen = ({ route, navigation }: Props) => { const [editedTitle, setEditedTitle] = useState(post.title); const [editedSummary, setEditedSummary] = useState(post.summary); const [saving, setSaving] = useState(false); - const [categoryOptions, setCategoryOptions] = useState([]); + const [categoryOptions, setCategoryOptions] = useState(FALLBACK_CATEGORIES); const [deleting, setDeleting] = useState(false); const [reanalyzing, setReanalyzing] = useState(false); const [toast, setToast] = useState({ visible: false, message: '', type: 'info' as 'success' | 'error' | 'warning' | 'info' }); @@ -59,27 +60,10 @@ const PostDetailScreen = ({ route, navigation }: Props) => { try { const taxonomy = await apiService.getTaxonomy(); if (cancelled) return; - if (taxonomy && taxonomy.categories.length > 0) { + // Only replace the built-in picker when the server exposes taxonomy. + if (isTaxonomyApiActive(taxonomy)) { setCategoryOptions( - taxonomy.categories.map(c => ({ - id: c.id, - name: c.name, - icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag', - })) - ); - return; - } - // No taxonomy payload: only fall back to built-ins when the server - // still uses the legacy default set (or taxonomy is unavailable). - if (taxonomy && taxonomy.use_default_categories === false) { - setCategoryOptions([]); - return; - } - const cats = await apiService.getCategories(); - if (cancelled) return; - if (cats && cats.length > 0) { - setCategoryOptions( - cats.map(c => ({ + taxonomy!.categories.map(c => ({ id: c.id, name: c.name, icon: CATEGORY_ICONS[c.id] || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag', @@ -87,7 +71,7 @@ const PostDetailScreen = ({ route, navigation }: Props) => { ); } } catch { - // Keep FALLBACK_CATEGORIES + // Keep FALLBACK_CATEGORIES (upstream path) } })(); return () => { diff --git a/superbrain-app/src/services/api.ts b/superbrain-app/src/services/api.ts index 8422105..3b013f1 100644 --- a/superbrain-app/src/services/api.ts +++ b/superbrain-app/src/services/api.ts @@ -443,8 +443,11 @@ class ApiService { use_default_categories: !!response.data.use_default_categories, taxonomy_version: response.data.taxonomy_version || '', }; - } catch (error) { - console.error('Error fetching taxonomy:', error); + } catch (error: any) { + // 404 = upstream / pre-taxonomy servers — silent by design + if (error?.response?.status !== 404) { + console.error('Error fetching taxonomy:', error); + } return null; } } diff --git a/superbrain-app/src/services/syncService.ts b/superbrain-app/src/services/syncService.ts index eb50fbb..a6e8972 100644 --- a/superbrain-app/src/services/syncService.ts +++ b/superbrain-app/src/services/syncService.ts @@ -107,10 +107,10 @@ async function deltaSync(): Promise { /** * Decides whether to do a full or delta sync. * - Empty local DB → full sync - * - forceFull → full sync (pull-to-refresh after server-side migrations) - * - Server taxonomy_version differs from last successful sync → full sync - * (category migrations bump version; delta alone can miss them if the - * device's last_synced_at cursor already advanced) + * - forceFull → full sync only when GET /taxonomy is active (taxonomy-aware + * servers / pull-to-refresh after migrations). Without taxonomy, forceFull + * is ignored so behavior matches upstream mainline (delta only). + * - taxonomy_version change → full sync (no-op when endpoint absent) * - Has data → delta sync * Returns true if any data changed. */ @@ -119,9 +119,11 @@ async function syncIfNeeded(forceFull: boolean = false): Promise { const empty = await localDb.isEmpty(); let taxonomyChanged = false; let taxonomyVersion = ''; + let taxonomyActive = false; try { const taxonomy = await apiService.getTaxonomy(); - taxonomyVersion = taxonomy?.taxonomy_version || ''; + taxonomyActive = !!(taxonomy && taxonomy.categories.length > 0); + taxonomyVersion = taxonomyActive ? (taxonomy?.taxonomy_version || '') : ''; if (taxonomyVersion) { const localVersion = await localDb.getSyncMeta('taxonomy_version'); taxonomyChanged = localVersion !== taxonomyVersion; @@ -132,10 +134,12 @@ async function syncIfNeeded(forceFull: boolean = false): Promise { } } } catch { - /* offline / taxonomy endpoint unavailable — fall through */ + /* offline / taxonomy endpoint unavailable — upstream path */ } - if (empty || forceFull || taxonomyChanged) { + const effectiveForceFull = forceFull && taxonomyActive; + + if (empty || effectiveForceFull || taxonomyChanged) { const count = await fullSync(); if (count > 0 && taxonomyVersion) { await localDb.setSyncMeta('taxonomy_version', taxonomyVersion); diff --git a/superbrain-app/src/services/taxonomySupport.ts b/superbrain-app/src/services/taxonomySupport.ts new file mode 100644 index 0000000..6b41adf --- /dev/null +++ b/superbrain-app/src/services/taxonomySupport.ts @@ -0,0 +1,33 @@ +/** + * Helpers for optional server-driven category taxonomy (`GET /taxonomy`). + * + * When the endpoint is absent (upstream mainline today), clients must keep + * historical APK behavior. New chip/sync rules activate only when the server + * returns a successful taxonomy payload with at least one category. + */ + +export type TaxonomyPayload = { + categories: Array<{ id: string; name: string; precedence?: number; guidance?: string }>; + allow_multiple_categories: boolean; + fallback_category: string; + use_default_categories: boolean; + taxonomy_version: string; +}; + +/** True when the backend exposes a usable config-driven taxonomy. */ +export function isTaxonomyApiActive( + taxonomy: TaxonomyPayload | null | undefined +): boolean { + return !!(taxonomy && Array.isArray(taxonomy.categories) && taxonomy.categories.length > 0); +} + +/** + * Strict custom taxonomy: hide built-in product/places/food chips and any + * labels outside the configured list. Only when the server explicitly sets + * use_default_categories=false. + */ +export function usesStrictCustomTaxonomy( + taxonomy: TaxonomyPayload | null | undefined +): boolean { + return isTaxonomyApiActive(taxonomy) && taxonomy!.use_default_categories === false; +} From 44a655dfb3c866af162a4830611c23897fb8da4d Mon Sep 17 00:00:00 2001 From: Daniel JB Clark Date: Fri, 31 Jul 2026 14:10:53 -0400 Subject: [PATCH 5/5] fix: deduplicate GET /taxonomy calls and persist version unconditionally - Cache taxonomy result within each loadPosts cycle via taxonomyRef; pass pre-fetched payload to syncIfNeeded so it skips redundant fetch. Eliminates 2-3 extra GET /taxonomy round-trips per load on taxonomy servers and removes the 404 that upstream cold-start paths hit before early-return. - Defer taxonomy gate behind testConnection so offline upstream users return immediately with zero network calls (matching original behavior). - Save taxonomy_version after full sync regardless of post count (the version changed even if the server has 0 posts, avoiding repeated full-sync attempts). --- superbrain-app/src/screens/HomeScreen.tsx | 21 +++++++++++++++++++-- superbrain-app/src/services/syncService.ts | 17 +++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/superbrain-app/src/screens/HomeScreen.tsx b/superbrain-app/src/screens/HomeScreen.tsx index bb870c3..ce7ae28 100644 --- a/superbrain-app/src/screens/HomeScreen.tsx +++ b/superbrain-app/src/screens/HomeScreen.tsx @@ -42,6 +42,7 @@ import BottomNav from '../components/BottomNav'; import { getCollectionIconName, getCollectionIconColor } from '../constants/icons'; import { ALL_CATEGORY, DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories'; import { + TaxonomyPayload, isTaxonomyApiActive, usesStrictCustomTaxonomy, } from '../services/taxonomySupport'; @@ -75,6 +76,8 @@ const HomeScreen = () => { const [onboardingStep, setOnboardingStep] = useState(0); const [categories, setCategories] = useState(DEFAULT_CATEGORIES); const lastFocusRefreshRef = useRef(0); + /** Caches the GET /taxonomy result within a single loadPosts call. */ + const taxonomyRef = useRef(undefined); useEffect(() => { const bootstrap = async () => { @@ -289,6 +292,8 @@ const HomeScreen = () => { }; const loadPosts = async (forceRefresh: boolean = false) => { + // Reset taxonomy cache for this load cycle; re-fetched lazily below. + taxonomyRef.current = undefined; try { // Reconcile: if a post was in the failed list AND still stuck in analyzing, clean it up. const failedList = await postsCache.getFailedPosts(); @@ -324,11 +329,20 @@ const HomeScreen = () => { // Upstream path: return early when local data is enough. // Taxonomy-aware servers: always continue so migrations reach the device. + // Defer the taxonomy check until after we confirm connectivity (below) + // to avoid a 404 round-trip on upstream servers that lack GET /taxonomy. if (!forceRefresh && analyzingShortcodes.length === 0) { + const isOnlineForGate = await apiService.testConnection().catch(() => false); + if (!isOnlineForGate) { + return; + } const taxonomy = await apiService.getTaxonomy().catch(() => null); if (!isTaxonomyApiActive(taxonomy)) { return; } + // taxonomy is active — fall through to background sync below. + // Stash result so syncIfNeeded and post-sync reload can reuse it. + taxonomyRef.current = taxonomy; } } else { // No local data at all — show loading spinner @@ -346,12 +360,15 @@ const HomeScreen = () => { // forceFull / taxonomy_version resync only activate when GET /taxonomy exists // (see syncService); without it this matches upstream delta-only sync. - const dataChanged = await syncService.syncIfNeeded(forceRefresh); + // Reuse any taxonomy payload already fetched above to avoid a redundant call. + const taxonomy = taxonomyRef.current !== undefined + ? taxonomyRef.current + : await apiService.getTaxonomy().catch(() => null); + const dataChanged = await syncService.syncIfNeeded(forceRefresh, taxonomy); // If sync brought new data, re-read from local DB and update UI if (dataChanged || forceRefresh) { // Category chip reload is taxonomy-aware; skip extra work on mainline. - const taxonomy = await apiService.getTaxonomy().catch(() => null); if (isTaxonomyApiActive(taxonomy)) { await loadCategories(); } diff --git a/superbrain-app/src/services/syncService.ts b/superbrain-app/src/services/syncService.ts index a6e8972..8195d02 100644 --- a/superbrain-app/src/services/syncService.ts +++ b/superbrain-app/src/services/syncService.ts @@ -10,6 +10,7 @@ import localDb from './localDb'; import apiService from './api'; import { Post } from '../types'; +import { TaxonomyPayload, isTaxonomyApiActive } from './taxonomySupport'; const BATCH_SIZE = 200; // posts per batch during full sync @@ -112,17 +113,25 @@ async function deltaSync(): Promise { * is ignored so behavior matches upstream mainline (delta only). * - taxonomy_version change → full sync (no-op when endpoint absent) * - Has data → delta sync + * + * Accepts an optional pre-fetched taxonomy payload so callers that already + * fetched GET /taxonomy do not trigger a redundant round-trip. * Returns true if any data changed. */ -async function syncIfNeeded(forceFull: boolean = false): Promise { +async function syncIfNeeded( + forceFull: boolean = false, + prefetchedTaxonomy?: TaxonomyPayload | null, +): Promise { try { const empty = await localDb.isEmpty(); let taxonomyChanged = false; let taxonomyVersion = ''; let taxonomyActive = false; try { - const taxonomy = await apiService.getTaxonomy(); - taxonomyActive = !!(taxonomy && taxonomy.categories.length > 0); + const taxonomy = prefetchedTaxonomy !== undefined + ? prefetchedTaxonomy + : await apiService.getTaxonomy(); + taxonomyActive = isTaxonomyApiActive(taxonomy); taxonomyVersion = taxonomyActive ? (taxonomy?.taxonomy_version || '') : ''; if (taxonomyVersion) { const localVersion = await localDb.getSyncMeta('taxonomy_version'); @@ -141,7 +150,7 @@ async function syncIfNeeded(forceFull: boolean = false): Promise { if (empty || effectiveForceFull || taxonomyChanged) { const count = await fullSync(); - if (count > 0 && taxonomyVersion) { + if (taxonomyVersion) { await localDb.setSyncMeta('taxonomy_version', taxonomyVersion); } return count > 0;