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
+28 -16
View File
@@ -412,10 +412,11 @@ _COMBINED_FEED_STRIP = _FEED_STRIP | {"mmp"}
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
shards recursively. Returns the number of activities written.
feed.json is a BAS shard index (same format as per-user index.json).
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(
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)
# Remove stale feed pages
# Remove stale feed files (sequential pages and old year shards)
for f in data_dir.glob("feed*.json"):
f.unlink(missing_ok=True)
if not all_activities:
return 0
pages = [all_activities[i:i + FEED_PAGE_SIZE] for i in range(0, len(all_activities), FEED_PAGE_SIZE)]
for page_num, page in enumerate(pages):
slim = [{k: v for k, v in a.items() if k not in _COMBINED_FEED_STRIP} for a in page]
fname = "feed.json" if page_num == 0 else f"feed-{page_num + 1}.json"
doc = {
"bas_version": "1.0",
"page": page_num + 1,
"total_pages": len(pages),
"total_activities": len(all_activities),
"activities": slim,
}
(data_dir / fname).write_text(_dumps(doc))
# Group by YYYY-MM (month), preserving newest-first order within each bucket
by_month: dict[str, list[dict]] = {}
for a in all_activities:
ym = (a.get("started_at") or "")[:7] # "YYYY-MM"
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",
"total_activities": len(all_activities),
"shards": [{"url": f"feed-{ym}.json"} for ym in months_desc],
"activities": [],
}
(data_dir / "feed.json").write_text(_dumps(index_doc))
return len(all_activities)
+31 -74
View File
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
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. */
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
@@ -52,9 +52,6 @@
let error = '';
let mounted = false;
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. */
let totalActivities = 0;
/** Logged-in handle — resolved async via bincio:me event. */
@@ -97,26 +94,19 @@
: filtered;
$: visible = withSearch.slice(0, shown);
$: canShowMore = shown < withSearch.length;
$: hasMore = canShowMore || pendingShards.length > 0 || feedNextPage > 0;
$: hasMore = canShowMore || pendingShards.length > 0;
async function loadMore() {
if (canShowMore) {
shown += PAGE_SIZE;
return;
}
if (!pendingShards.length) return;
loadingMore = true;
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];
pendingShards = pendingShards.slice(1);
fresh = await loadShardActivities(url);
} else {
return;
}
const url = pendingShards[0];
pendingShards = pendingShards.slice(1);
const fresh = await loadShardActivities(url);
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) =>
@@ -132,12 +122,11 @@
$: 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.
let loadingAllShards = false;
let loadingAllFeedPages = false;
// Eager-load shards when a filter needs data not yet in memory.
let loadingAllShards = false;
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;
}
@@ -157,57 +146,28 @@
const yr = _neededYearRange(datePre, customFrom, customTo);
const needEager = !!query.trim() || yr !== null;
if (needEager && pendingShards.length > 0 && !loadingAllShards) {
loadingAllShards = true;
// When year-specific filter (no search), load only shards that cover
// the needed range; unneeded shards stay in pendingShards for "Load more".
// When search is active, load everything so full-text search works.
// Compute toLoad first — if empty (needed years already loaded, others remain),
// skip the async entirely to avoid an infinite reactive loop.
const toLoad = (yr && !query.trim())
? pendingShards.filter(url => { const y = _yearFromShard(url); return y !== null && y >= yr[0] && y <= yr[1]; })
: [...pendingShards];
(async () => {
for (const url of toLoad) {
pendingShards = pendingShards.filter(u => u !== url);
try {
const fresh = await loadShardActivities(url);
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 ?? ''),
);
} catch { /* ignore — partial results still useful */ }
}
loadingAllShards = false;
})();
}
}
$: {
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 (toLoad.length > 0) {
loadingAllShards = true;
(async () => {
for (const url of toLoad) {
pendingShards = pendingShards.filter(u => u !== url);
try {
const fresh = await loadShardActivities(url);
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 ?? ''),
);
} catch { /* ignore — partial results still useful */ }
}
loadingAllShards = false;
})();
}
}
}
@@ -245,8 +205,7 @@
if (combined) {
all = combined.activities;
totalActivities = combined.totalActivities;
feedTotalPages = combined.remainingPages + 1;
feedNextPage = combined.remainingPages > 0 ? 2 : 0;
pendingShards = combined.pendingShards;
loading = false;
return;
}
@@ -367,7 +326,7 @@
<p class="text-red-400 text-center py-12">Could not load activities: {error}</p>
{:else if withSearch.length === 0}
<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>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -462,10 +421,8 @@
Loading…
{:else if canShowMore}
Load more ({filtered.length - shown} remaining)
{:else if feedNextPage > 0}
Load more activities
{:else}
Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'year' : 'years'})
Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'period' : 'periods'})
{/if}
</button>
</div>
+10 -32
View File
@@ -58,7 +58,7 @@ function emptyIndex(): BASIndex {
// ── Helpers ───────────────────────────────────────────────────────────────────
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 {
@@ -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
* activities pre-sorted across all users, plus remaining page count.
* Load the combined feed (multi-user global feed).
*
* Falls back to the full shard-resolution path if feed.json doesn't exist
* (single-user installs, older data).
* feed.json is now a BAS shard index: the most-recent year shard is fetched
* 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(
baseUrl: string,
): Promise<{ activities: ActivitySummary[]; remainingPages: number; totalActivities: number } | null> {
): Promise<{ activities: ActivitySummary[]; pendingShards: string[]; totalActivities: number } | null> {
try {
const feed = await fetchJSON<FeedPage>(`${baseUrl}data/feed.json`);
const { index, pendingShards } = await loadIndexPaged(baseUrl, `${baseUrl}data/feed.json`);
return {
activities: feed.activities ?? [],
remainingPages: (feed.total_pages ?? 1) - 1,
totalActivities: feed.total_activities ?? 0,
activities: index.activities,
pendingShards,
totalActivities: (index as any).total_activities ?? index.activities.length,
};
} catch {
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
* activities are available offline.