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:
+28
-16
@@ -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] == "-":
|
||||||
"bas_version": "1.0",
|
by_month.setdefault(ym, []).append(a)
|
||||||
"page": page_num + 1,
|
|
||||||
"total_pages": len(pages),
|
months_desc = sorted(by_month.keys(), reverse=True)
|
||||||
"total_activities": len(all_activities),
|
|
||||||
"activities": slim,
|
# Write per-month shard files (~150-200 acts each → ~25 KB gzip)
|
||||||
}
|
for ym, acts in by_month.items():
|
||||||
(data_dir / fname).write_text(_dumps(doc))
|
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)
|
return len(all_activities)
|
||||||
|
|||||||
@@ -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[] = [];
|
const url = pendingShards[0];
|
||||||
if (feedNextPage > 0) {
|
pendingShards = pendingShards.slice(1);
|
||||||
fresh = await loadCombinedFeedPage(base, feedNextPage);
|
const fresh = await loadShardActivities(url);
|
||||||
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 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,57 +146,28 @@
|
|||||||
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];
|
||||||
(async () => {
|
if (toLoad.length > 0) {
|
||||||
for (const url of toLoad) {
|
loadingAllShards = true;
|
||||||
pendingShards = pendingShards.filter(u => u !== url);
|
(async () => {
|
||||||
try {
|
for (const url of toLoad) {
|
||||||
const fresh = await loadShardActivities(url);
|
pendingShards = pendingShards.filter(u => u !== url);
|
||||||
const existing = new Map(all.map(a => [a.id, a]));
|
try {
|
||||||
for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a);
|
const fresh = await loadShardActivities(url);
|
||||||
all = [...existing.values()].sort((a, b) =>
|
const existing = new Map(all.map(a => [a.id, a]));
|
||||||
(b.started_at ?? '').localeCompare(a.started_at ?? ''),
|
for (const a of fresh) if (!existing.has(a.id)) existing.set(a.id, a);
|
||||||
);
|
all = [...existing.values()].sort((a, b) =>
|
||||||
} catch { /* ignore — partial results still useful */ }
|
(b.started_at ?? '').localeCompare(a.started_at ?? ''),
|
||||||
}
|
);
|
||||||
loadingAllShards = false;
|
} 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;
|
|
||||||
})();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user