Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions superbrain-app/src/constants/categories.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
export const DEFAULT_CATEGORIES = [
{ id: 'all', name: 'All', icon: 'star', count: 0 },
/** Chip shown for the unfiltered feed. */
export const ALL_CATEGORY = { id: 'all', name: 'All', icon: 'star', count: 0 };

/**
* 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 },
{ id: 'places', name: 'Places', icon: 'location', count: 0 },

{ id: 'food', name: 'Food', icon: 'restaurant', count: 0 },
{ id: 'software', name: 'Software', icon: 'code-slash', count: 0 },
{ id: 'book', name: 'Book', icon: 'book', count: 0 },
Expand All @@ -13,8 +19,20 @@ export const DEFAULT_CATEGORIES = [
{ id: 'other', name: 'Other', icon: 'pricetag', count: 0 },
];

/**
* 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, ...BUILTIN_DEFAULT_CATEGORIES];

export const CATEGORY_ICONS: Record<string, string> = {
'all': 'star',
'sysadmin': 'terminal-outline',
'science': 'flask-outline',
'technology': 'hardware-chip-outline',
'history': 'hourglass-outline',
'humanities': 'library-outline',
'politics': 'newspaper-outline',
'product': 'cube-outline',
'places': 'location-outline',
'food': 'restaurant-outline',
Expand Down
149 changes: 116 additions & 33 deletions superbrain-app/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ 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, DEFAULT_CATEGORIES, CATEGORY_ICONS } from '../constants/categories';
import {
TaxonomyPayload,
isTaxonomyApiActive,
usesStrictCustomTaxonomy,
} from '../services/taxonomySupport';

type NavigationProp = NativeStackNavigationProp<RootStackParamList>;

Expand Down Expand Up @@ -71,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<TaxonomyPayload | null | undefined>(undefined);

useEffect(() => {
const bootstrap = async () => {
Expand Down Expand Up @@ -201,32 +208,60 @@ const HomeScreen = () => {
},
];

const loadCategories = async () => {
const loadCategories = async (
prefetchedTaxonomy?: TaxonomyPayload | null | Promise<TaxonomyPayload | null>,
) => {
try {
const cats = await apiService.getCategories();
if (cats && cats.length > 0) {
// Always keep full default pill set visible, then overlay live counts from backend.
const mergedById = new Map(
DEFAULT_CATEGORIES
.filter(c => c.id !== 'all')
.map(c => [c.id, { ...c, count: 0 }])
);
// Reuse a pre-fetched taxonomy when available to avoid redundant HTTP calls.
// On init (no prefetch), fetch taxonomy alongside categories.
const [cats, taxonomy] = await Promise.all([
apiService.getCategories(),
prefetchedTaxonomy !== undefined
? Promise.resolve(prefetchedTaxonomy)
: apiService.getTaxonomy().catch(() => null),
]);

for (const c of cats) {
const id = c.id.toLowerCase();
const existing = mergedById.get(id);
mergedById.set(id, {
id,
name: existing?.name || c.name,
icon: existing?.icon || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag-outline',
count: c.count,
});
}
// 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 (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 {
seed = DEFAULT_CATEGORIES.filter(c => c.id !== 'all').map(c => ({ ...c, count: 0 }));
}

const merged = Array.from(mergedById.values());
const totalCount = merged.reduce((sum, c) => sum + c.count, 0);
setCategories([{ id: 'all', name: 'All', icon: 'star', count: totalCount }, ...merged]);
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 (strictCustom && !configuredIds.has(id)) {
continue;
}
const existing = mergedById.get(id);
mergedById.set(id, {
id,
name: existing?.name || c.name,
icon: existing?.icon || CATEGORY_ICONS[c.name.trim().toLowerCase()] || 'pricetag-outline',
count: c.count,
});
}

const merged = Array.from(mergedById.values());
// 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);
}
Expand All @@ -244,11 +279,18 @@ const HomeScreen = () => {
}
setIsConfigured(true);
// Fire categories, collections sync, and posts loading in parallel
// for faster first paint
// for faster first paint. Fetch /taxonomy exactly once here (not
// awaited yet — same in-flight promise shared with both loadCategories
// and loadPosts, each awaiting it only at the point they actually need
// it) instead of each independently re-fetching, which both wasted a
// redundant HTTP call and could race under flaky network (one fetch
// succeeding while the other timed out, leaving rendered chips
// inconsistent with what sync just persisted).
const taxonomyPromise = apiService.getTaxonomy().catch(() => null);
const [, ,] = await Promise.all([
loadCategories(),
loadCategories(taxonomyPromise),
collectionsService.syncFromBackend().catch(() => { /* offline */ }),
loadPosts(false),
loadPosts(false, taxonomyPromise),
]);
// Reschedule Watch Later notifications with (possibly restored) collection data
if (!deferNotificationPrompt) {
Expand All @@ -262,7 +304,29 @@ const HomeScreen = () => {
}
};

const loadPosts = async (forceRefresh: boolean = false) => {
const loadPosts = async (
forceRefresh: boolean = false,
prefetchedTaxonomy?: TaxonomyPayload | null | Promise<TaxonomyPayload | null>,
) => {
// Reset taxonomy cache for this load cycle; resolved lazily (below) at
// whichever point in this function first actually needs it, not eagerly
// here — keeps the fast local-data paint path (right below) unblocked by
// any network call, including a caller-supplied prefetch promise.
taxonomyRef.current = undefined;
// Resolves the taxonomy exactly once per loadPosts call, reusing
// taxonomyRef.current if a previous call site within this same cycle
// already resolved it, otherwise awaiting prefetchedTaxonomy (a caller's
// in-flight fetch, e.g. bootstrap sharing one /taxonomy call with
// loadCategories instead of each independently re-fetching), otherwise
// fetching fresh.
const resolveTaxonomy = async (): Promise<TaxonomyPayload | null> => {
if (taxonomyRef.current !== undefined) return taxonomyRef.current;
const resolved = prefetchedTaxonomy !== undefined
? await prefetchedTaxonomy
: await apiService.getTaxonomy().catch(() => null);
taxonomyRef.current = resolved;
return resolved;
};
try {
// Reconcile: if a post was in the failed list AND still stuck in analyzing, clean it up.
const failedList = await postsCache.getFailedPosts();
Expand Down Expand Up @@ -296,29 +360,46 @@ const HomeScreen = () => {
setPosts(merged);
setLoading(false);

// If not forcing refresh and no analyzing posts, we're done
// 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) {
return;
const isOnlineForGate = await apiService.testConnection().catch(() => false);
if (!isOnlineForGate) {
return;
}
const taxonomy = await resolveTaxonomy();
if (!isTaxonomyApiActive(taxonomy)) {
return;
}
// taxonomy is active — fall through to background sync below.
}
} 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) {
// Flush any offline mutations first
await postsCache.flushPendingPostMutations();
await postsCache.flushPendingAnalyses();

// Delta or full sync depending on DB state
const dataChanged = await syncService.syncIfNeeded();
// forceFull / taxonomy_version resync only activate when GET /taxonomy exists
// (see syncService); without it this matches upstream delta-only sync.
const taxonomy = await resolveTaxonomy();
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.
if (isTaxonomyApiActive(taxonomy)) {
await loadCategories(taxonomy);
}
const freshPosts = await localDb.getAllPosts();
if (freshPosts.length > 0) {
// Clear analyzing state for posts that now exist in the synced data
Expand Down Expand Up @@ -421,7 +502,9 @@ const HomeScreen = () => {
(post.summary && post.summary.toLowerCase().includes(searchQuery.toLowerCase())) ||
(post.tags && post.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())));

const matchesCategory = selectedCategory === 'all' || post.category === selectedCategory;
const matchesCategory =
selectedCategory === 'all' ||
(post.category || '').trim().toLowerCase() === selectedCategory.trim().toLowerCase();

return matchesSearch && matchesCategory;
});
Expand Down
49 changes: 44 additions & 5 deletions superbrain-app/src/screens/PostDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ 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';
import { isTaxonomyApiActive } from '../services/taxonomySupport';

type Props = NativeStackScreenProps<RootStackParamList, 'PostDetail'>;

// Filter out 'all' from categories to use for the edit dropdown
const CATEGORIES = DEFAULT_CATEGORIES.filter(c => c.id !== 'all');
type CategoryOption = { id: string; name: string; icon: string };

/** Upstream / no-taxonomy edit list (built-in mainline categories). */
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;
Expand All @@ -28,6 +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<CategoryOption[]>(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' });
Expand All @@ -46,6 +54,35 @@ const PostDetailScreen = ({ route, navigation }: Props) => {
}
}, [showEditModal]);

useEffect(() => {
let cancelled = false;
(async () => {
try {
// Gate behind connectivity to avoid a wasted 404 round-trip on
// upstream servers that lack GET /taxonomy.
const isOnline = await apiService.testConnection().catch(() => false);
if (!isOnline || cancelled) return;
const taxonomy = await apiService.getTaxonomy();
if (cancelled) return;
// 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',
}))
);
}
} catch {
// Keep FALLBACK_CATEGORIES (upstream path)
}
})();
return () => {
cancelled = true;
};
}, []);

const getPostImageUrl = (post: Post) => {
if (post.thumbnail_url) return post.thumbnail_url;
if (post.thumbnail) {
Expand Down Expand Up @@ -390,8 +427,10 @@ const PostDetailScreen = ({ route, navigation }: Props) => {
contentContainerStyle={styles.categoriesContent}
keyboardShouldPersistTaps="always"
>
{CATEGORIES.map((cat) => {
const isActive = editedCategory === cat.id;
{categoryOptions.map((cat) => {
const isActive =
(editedCategory || '').trim().toLowerCase() === cat.id.toLowerCase() ||
(editedCategory || '').trim().toLowerCase() === cat.name.trim().toLowerCase();
const catColor = getCategoryColor(cat.id);
return (
<TouchableOpacity
Expand Down
Loading