Global feed: switch from sequential pages to month-based BAS shards

feed.json is now a BAS shard index pointing to feed-YYYY-MM.json files
(~150 activities / ~25 KB gzip each) instead of 400+ sequential feed-N.json
pages. The frontend can now jump directly to a specific month when filtering
by year or date range, without loading all newer data first.

- merge.py: write_combined_feed groups by YYYY-MM and emits a shard index
- dataloader.ts: isYearShardUrl matches feed-YYYY-MM.json; loadCombinedFeed
  returns pendingShards; FeedPage interface and loadCombinedFeedPage removed
- ActivityFeed.svelte: _yearFromShard handles both index-YYYY and feed-YYYY-MM;
  feedNextPage/feedTotalPages/loadingAllFeedPages removed; infinite-loop bug
  fixed (toLoad.length guard before setting loadingAllShards); onMount uses
  pendingShards from loadCombinedFeed
This commit is contained in:
Davide Scaini
2026-05-15 10:25:01 +02:00
parent d3bce49445
commit fe437626e6
3 changed files with 69 additions and 122 deletions
+25 -13
View File
@@ -412,10 +412,11 @@ _COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"}
def write_combined_feed(data_dir: Path) -> int: def write_combined_feed(data_dir: Path) -> int:
"""Build data_dir/feed.json — the N most recent activities across all users. """Build data_dir/feed.json and per-month data_dir/feed-YYYY-MM.json shards.
The global feed page loads this single file instead of resolving 20+ user feed.json is a BAS shard index (same format as per-user index.json).
shards recursively. Returns the number of activities written. Each feed-YYYY-MM.json contains all activities for that month across all users,
sorted newest-first. Returns the number of activities written.
""" """
user_dirs = sorted( user_dirs = sorted(
p for p in data_dir.iterdir() p for p in data_dir.iterdir()
@@ -458,24 +459,35 @@ def write_combined_feed(data_dir: Path) -> int:
all_activities.sort(key=lambda a: a.get("started_at", ""), reverse=True) all_activities.sort(key=lambda a: a.get("started_at", ""), reverse=True)
# Remove stale feed pages # Remove stale feed files (sequential pages and old year shards)
for f in data_dir.glob("feed*.json"): for f in data_dir.glob("feed*.json"):
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
if not all_activities: if not all_activities:
return 0 return 0
pages = [all_activities[i:i + FEED_PAGE_SIZE] for i in range(0, len(all_activities), FEED_PAGE_SIZE)] # Group by YYYY-MM (month), preserving newest-first order within each bucket
for page_num, page in enumerate(pages): by_month: dict[str, list[dict]] = {}
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in page] for a in all_activities:
fname = "feed.json" if page_num == 0 else f"feed-{page_num + 1}.json" ym = (a.get("started_at") or "")[:7] # "YYYY-MM"
doc = { if len(ym) == 7 and ym[4] == "-":
by_month.setdefault(ym, []).append(a)
months_desc = sorted(by_month.keys(), reverse=True)
# Write per-month shard files (~150-200 acts each → ~25 KB gzip)
for ym, acts in by_month.items():
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in acts]
doc: dict = {"bas_version": "1.0", "activities": slim}
(data_dir / f"feed-{ym}.json").write_text(_dumps(doc))
# Write feed.json as a BAS shard index (same pattern as per-user index.json)
index_doc: dict = {
"bas_version": "1.0", "bas_version": "1.0",
"page": page_num + 1,
"total_pages": len(pages),
"total_activities": len(all_activities), "total_activities": len(all_activities),
"activities": slim, "shards": [{"url": f"feed-{ym}.json"} for ym in months_desc],
"activities": [],
} }
(data_dir / fname).write_text(_dumps(doc)) (data_dir / "feed.json").write_text(_dumps(index_doc))
return len(all_activities) return len(all_activities)
+13 -56
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types'; import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format'; import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
import { loadIndexPaged, loadShardActivities, loadCombinedFeed, loadCombinedFeedPage } from '../lib/dataloader'; import { loadIndexPaged, loadShardActivities, loadCombinedFeed } from '../lib/dataloader';
/** Render preview_coords as an SVG polyline path string. */ /** Render preview_coords as an SVG polyline path string. */
function trackPath(coords: [number, number][] | null, w: number, h: number): string { function trackPath(coords: [number, number][] | null, w: number, h: number): string {
@@ -52,9 +52,6 @@
let error = ''; let error = '';
let mounted = false; let mounted = false;
let pendingShards: string[] = []; let pendingShards: string[] = [];
/** Remaining combined-feed pages (multi-user global feed). */
let feedNextPage = 0;
let feedTotalPages = 0;
/** Grand total from feed.json — shows instance-wide count even before all pages are loaded. */ /** Grand total from feed.json — shows instance-wide count even before all pages are loaded. */
let totalActivities = 0; let totalActivities = 0;
/** Logged-in handle — resolved async via bincio:me event. */ /** Logged-in handle — resolved async via bincio:me event. */
@@ -97,26 +94,19 @@
: filtered; : filtered;
$: visible = withSearch.slice(0, shown); $: visible = withSearch.slice(0, shown);
$: canShowMore = shown < withSearch.length; $: canShowMore = shown < withSearch.length;
$: hasMore = canShowMore || pendingShards.length > 0 || feedNextPage > 0; $: hasMore = canShowMore || pendingShards.length > 0;
async function loadMore() { async function loadMore() {
if (canShowMore) { if (canShowMore) {
shown += PAGE_SIZE; shown += PAGE_SIZE;
return; return;
} }
if (!pendingShards.length) return;
loadingMore = true; loadingMore = true;
try { try {
let fresh: ActivitySummary[] = [];
if (feedNextPage > 0) {
fresh = await loadCombinedFeedPage(base, feedNextPage);
feedNextPage = feedNextPage < feedTotalPages ? feedNextPage + 1 : 0;
} else if (pendingShards.length) {
const url = pendingShards[0]; const url = pendingShards[0];
pendingShards = pendingShards.slice(1); pendingShards = pendingShards.slice(1);
fresh = await loadShardActivities(url); const fresh = await loadShardActivities(url);
} else {
return;
}
const existing = new Map(all.map(a => [a.id, a])); const existing = new Map(all.map(a => [a.id, a]));
for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a); for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a);
all = [...existing.values()].sort((a, b) => all = [...existing.values()].sort((a, b) =>
@@ -132,12 +122,11 @@
$: if (sport || datePre || query || customFrom || customTo) shown = PAGE_SIZE; // reset pagination on filter change $: if (sport || datePre || query || customFrom || customTo) shown = PAGE_SIZE; // reset pagination on filter change
// Eager-load shards / feed-pages when a filter needs data not yet in memory. // Eager-load shards when a filter needs data not yet in memory.
let loadingAllShards = false; let loadingAllShards = false;
let loadingAllFeedPages = false;
function _yearFromShard(url: string): number | null { function _yearFromShard(url: string): number | null {
const m = url.match(/index-(\d{4})\.json$/); const m = url.match(/(?:index|feed)-(\d{4})(?:-\d{2})?\.json$/);
return m ? parseInt(m[1], 10) : null; return m ? parseInt(m[1], 10) : null;
} }
@@ -157,13 +146,13 @@
const yr = _neededYearRange(datePre, customFrom, customTo); const yr = _neededYearRange(datePre, customFrom, customTo);
const needEager = !!query.trim() || yr !== null; const needEager = !!query.trim() || yr !== null;
if (needEager && pendingShards.length > 0 && !loadingAllShards) { if (needEager && pendingShards.length > 0 && !loadingAllShards) {
loadingAllShards = true; // Compute toLoad first — if empty (needed years already loaded, others remain),
// When year-specific filter (no search), load only shards that cover // skip the async entirely to avoid an infinite reactive loop.
// the needed range; unneeded shards stay in pendingShards for "Load more".
// When search is active, load everything so full-text search works.
const toLoad = (yr && !query.trim()) const toLoad = (yr && !query.trim())
? pendingShards.filter(url => { const y = _yearFromShard(url); return y !== null && y >= yr[0] && y <= yr[1]; }) ? pendingShards.filter(url => { const y = _yearFromShard(url); return y !== null && y >= yr[0] && y <= yr[1]; })
: [...pendingShards]; : [...pendingShards];
if (toLoad.length > 0) {
loadingAllShards = true;
(async () => { (async () => {
for (const url of toLoad) { for (const url of toLoad) {
pendingShards = pendingShards.filter(u => u !== url); pendingShards = pendingShards.filter(u => u !== url);
@@ -180,35 +169,6 @@
})(); })();
} }
} }
$: {
const yr = _neededYearRange(datePre, customFrom, customTo);
if ((!!query.trim() || yr !== null) && feedNextPage > 0 && !loadingAllFeedPages) {
loadingAllFeedPages = true;
// Capture at loop start — dateFrom is reactive and may change mid-fetch.
const effectiveFrom = dateFrom;
(async () => {
while (feedNextPage > 0) {
const page = feedNextPage;
feedNextPage = page < feedTotalPages ? page + 1 : 0;
try {
const fresh = await loadCombinedFeedPage(base, page);
const existing = new Map(all.map(a => [a.id, a]));
for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a);
all = [...existing.values()].sort((a, b) =>
(b.started_at ?? '').localeCompare(a.started_at ?? ''),
);
// Feed is sorted newest-first. Once the oldest activity in this page
// predates our from-filter, everything needed is already loaded.
if (effectiveFrom && fresh.length > 0) {
const oldest = fresh.reduce((m, a) => (a.started_at ?? '') < (m.started_at ?? '') ? a : m);
if ((oldest.started_at ?? '') < effectiveFrom) { feedNextPage = 0; break; }
}
} catch { /* ignore — partial results still useful */ }
}
loadingAllFeedPages = false;
})();
}
} }
$: if (mounted) { $: if (mounted) {
@@ -245,8 +205,7 @@
if (combined) { if (combined) {
all = combined.activities; all = combined.activities;
totalActivities = combined.totalActivities; totalActivities = combined.totalActivities;
feedTotalPages = combined.remainingPages + 1; pendingShards = combined.pendingShards;
feedNextPage = combined.remainingPages > 0 ? 2 : 0;
loading = false; loading = false;
return; return;
} }
@@ -367,7 +326,7 @@
<p class="text-red-400 text-center py-12">Could not load activities: {error}</p> <p class="text-red-400 text-center py-12">Could not load activities: {error}</p>
{:else if withSearch.length === 0} {:else if withSearch.length === 0}
<p class="text-zinc-500 text-center py-12"> <p class="text-zinc-500 text-center py-12">
{#if loadingAllFeedPages || loadingAllShards}Loading…{:else if query.trim()}No activities match "{query.trim()}".{:else}No activities found.{/if} {#if loadingAllShards}Loading…{:else if query.trim()}No activities match "{query.trim()}".{:else}No activities found.{/if}
</p> </p>
{:else} {:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -462,10 +421,8 @@
Loading… Loading…
{:else if canShowMore} {:else if canShowMore}
Load more ({filtered.length - shown} remaining) Load more ({filtered.length - shown} remaining)
{:else if feedNextPage > 0}
Load more activities
{:else} {:else}
Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'year' : 'years'}) Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'period' : 'periods'})
{/if} {/if}
</button> </button>
</div> </div>
+10 -32
View File
@@ -58,7 +58,7 @@ function emptyIndex(): BASIndex {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function isYearShardUrl(url: string): boolean { function isYearShardUrl(url: string): boolean {
return /(?:^|\/)index-\d{4}\.json$/.test(url); return /(?:^|\/)(?:index-\d{4}|feed-\d{4}-\d{2})\.json$/.test(url);
} }
function rewriteActivityUrls(a: ActivitySummary, shardBase: string): ActivitySummary { function rewriteActivityUrls(a: ActivitySummary, shardBase: string): ActivitySummary {
@@ -253,50 +253,28 @@ export async function loadShardActivities(shardUrl: string): Promise<ActivitySum
} }
} }
interface FeedPage {
page: number;
total_pages: number;
total_activities: number;
activities: ActivitySummary[];
}
/** /**
* Load the combined feed (multi-user global feed). Returns the first page of * Load the combined feed (multi-user global feed).
* activities pre-sorted across all users, plus remaining page count.
* *
* Falls back to the full shard-resolution path if feed.json doesn't exist * feed.json is now a BAS shard index: the most-recent year shard is fetched
* (single-user installs, older data). * immediately; older shards are returned as pendingShards for lazy loading.
* Returns null if feed.json doesn't exist (single-user installs).
*/ */
export async function loadCombinedFeed( export async function loadCombinedFeed(
baseUrl: string, baseUrl: string,
): Promise<{ activities: ActivitySummary[]; remainingPages: number; totalActivities: number } | null> { ): Promise<{ activities: ActivitySummary[]; pendingShards: string[]; totalActivities: number } | null> {
try { try {
const feed = await fetchJSON<FeedPage>(`${baseUrl}data/feed.json`); const { index, pendingShards } = await loadIndexPaged(baseUrl, `${baseUrl}data/feed.json`);
return { return {
activities: feed.activities ?? [], activities: index.activities,
remainingPages: (feed.total_pages ?? 1) - 1, pendingShards,
totalActivities: feed.total_activities ?? 0, totalActivities: (index as any).total_activities ?? index.activities.length,
}; };
} catch { } catch {
return null; return null;
} }
} }
/**
* Load a subsequent page of the combined feed (feed-2.json, feed-3.json, etc.).
*/
export async function loadCombinedFeedPage(
baseUrl: string,
page: number,
): Promise<ActivitySummary[]> {
try {
const feed = await fetchJSON<FeedPage>(`${baseUrl}data/feed-${page}.json`);
return feed.activities ?? [];
} catch {
return [];
}
}
/** /**
* Load a single activity detail, checking IndexedDB first so locally-converted * Load a single activity detail, checking IndexedDB first so locally-converted
* activities are available offline. * activities are available offline.