redesign community page as sortable table
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
|
|
||||||
type Period = 'week' | 'month' | 'year' | 'all';
|
type Period = 'week' | 'month' | 'year' | 'all';
|
||||||
|
type SortKey = 'display_name' | 'count' | 'distance_m' | 'elevation_m' | 'duration_s' | 'sports' | 'streak';
|
||||||
|
|
||||||
interface UserRaw {
|
interface UserRaw {
|
||||||
handle: string;
|
handle: string;
|
||||||
@@ -33,6 +34,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let period: Period = 'month';
|
let period: Period = 'month';
|
||||||
|
let sortKey: SortKey = 'distance_m';
|
||||||
|
let sortAsc = false;
|
||||||
let users: UserRaw[] = [];
|
let users: UserRaw[] = [];
|
||||||
let stats: UserStat[] = [];
|
let stats: UserStat[] = [];
|
||||||
let totals: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
|
let totals: Totals = { count: 0, distance_m: 0, elevation_m: 0, duration_s: 0, users: 0 };
|
||||||
@@ -46,7 +49,6 @@
|
|||||||
if (p === 'all') return new Date(0);
|
if (p === 'all') return new Date(0);
|
||||||
if (p === 'year') return new Date(now.getFullYear(), 0, 1);
|
if (p === 'year') return new Date(now.getFullYear(), 0, 1);
|
||||||
if (p === 'month') return new Date(now.getFullYear(), now.getMonth(), 1);
|
if (p === 'month') return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
// week: Monday
|
|
||||||
const d = new Date(now);
|
const d = new Date(now);
|
||||||
const day = d.getDay();
|
const day = d.getDay();
|
||||||
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
|
d.setDate(d.getDate() - (day === 0 ? 6 : day - 1));
|
||||||
@@ -54,7 +56,6 @@
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Max consecutive active days across all public activities (all-time). */
|
|
||||||
function maxStreak(activities: ActivitySummary[]): number {
|
function maxStreak(activities: ActivitySummary[]): number {
|
||||||
if (!activities.length) return 0;
|
if (!activities.length) return 0;
|
||||||
const days = [...new Set(activities.map(a => a.started_at.slice(0, 10)))].sort();
|
const days = [...new Set(activities.map(a => a.started_at.slice(0, 10)))].sort();
|
||||||
@@ -75,7 +76,6 @@
|
|||||||
for (const u of rawUsers) {
|
for (const u of rawUsers) {
|
||||||
const pub = u.activities.filter(a => a.privacy !== 'private');
|
const pub = u.activities.filter(a => a.privacy !== 'private');
|
||||||
const filtered = pub.filter(a => new Date(a.started_at) >= start);
|
const filtered = pub.filter(a => new Date(a.started_at) >= start);
|
||||||
if (filtered.length === 0) continue;
|
|
||||||
|
|
||||||
const stat: UserStat = {
|
const stat: UserStat = {
|
||||||
handle: u.handle,
|
handle: u.handle,
|
||||||
@@ -117,24 +117,15 @@
|
|||||||
try {
|
try {
|
||||||
const rootUrl = `${base}data/index.json`;
|
const rootUrl = `${base}data/index.json`;
|
||||||
const root: BASIndex = await fetch(rootUrl).then(r => r.json());
|
const root: BASIndex = await fetch(rootUrl).then(r => r.json());
|
||||||
|
|
||||||
const userShards = (root.shards ?? []).filter(s => s.handle);
|
const userShards = (root.shards ?? []).filter(s => s.handle);
|
||||||
if (userShards.length === 0) {
|
if (userShards.length === 0) { error = 'No community members found.'; return; }
|
||||||
error = 'No community members found.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
userShards.map(async shard => {
|
userShards.map(async shard => {
|
||||||
const shardBase = `${base}data/`;
|
const url = shard.url.startsWith('http') ? shard.url : `${base}data/${shard.url}`;
|
||||||
const url = shard.url.startsWith('http') ? shard.url : `${shardBase}${shard.url}`;
|
|
||||||
const shardIndex: BASIndex = await fetch(url).then(r => r.json());
|
const shardIndex: BASIndex = await fetch(url).then(r => r.json());
|
||||||
const activities = await fetchShard(url);
|
const activities = await fetchShard(url);
|
||||||
return {
|
return { handle: shard.handle!, display_name: shardIndex.owner?.display_name ?? shard.handle!, activities } as UserRaw;
|
||||||
handle: shard.handle!,
|
|
||||||
display_name: shardIndex.owner?.display_name ?? shard.handle!,
|
|
||||||
activities,
|
|
||||||
} as UserRaw;
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -147,37 +138,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: if (users.length) {
|
$: if (users.length) ({ stats, totals } = computeStats(users, period));
|
||||||
({ stats, totals } = computeStats(users, period));
|
|
||||||
|
$: sorted = [...stats].sort((a, b) => {
|
||||||
|
let av: number | string, bv: number | string;
|
||||||
|
if (sortKey === 'display_name') { av = a.display_name.toLowerCase(); bv = b.display_name.toLowerCase(); }
|
||||||
|
else if (sortKey === 'sports') { av = a.sports.length; bv = b.sports.length; }
|
||||||
|
else { av = a[sortKey] as number; bv = b[sortKey] as number; }
|
||||||
|
if (av < bv) return sortAsc ? -1 : 1;
|
||||||
|
if (av > bv) return sortAsc ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setSort(key: SortKey) {
|
||||||
|
if (sortKey === key) sortAsc = !sortAsc;
|
||||||
|
else { sortKey = key; sortAsc = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function chevron(key: SortKey) {
|
||||||
|
if (sortKey !== key) return '';
|
||||||
|
return sortAsc ? ' ↑' : ' ↓';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(loadData);
|
onMount(loadData);
|
||||||
|
|
||||||
// ── Ranking helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function top3<K extends keyof UserStat>(key: K, min = 0): UserStat[] {
|
|
||||||
return [...stats]
|
|
||||||
.filter(u => (u[key] as number) > min)
|
|
||||||
.sort((a, b) => (b[key] as number) - (a[key] as number))
|
|
||||||
.slice(0, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
function top3Sports(): UserStat[] {
|
|
||||||
return [...stats]
|
|
||||||
.filter(u => u.sports.length > 1)
|
|
||||||
.sort((a, b) => b.sports.length - a.sports.length)
|
|
||||||
.slice(0, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MEDAL = ['🥇', '🥈', '🥉'];
|
|
||||||
|
|
||||||
$: rowsCount = top3('count', 0);
|
|
||||||
$: rowsDistance = top3('distance_m', 0);
|
|
||||||
$: rowsElevation = top3('elevation_m', 0);
|
|
||||||
$: rowsDuration = top3('duration_s', 0);
|
|
||||||
$: rowsSports = top3Sports();
|
|
||||||
$: rowsStreak = [...stats].filter(u => u.streak > 1).sort((a, b) => b.streak - a.streak).slice(0, 3);
|
|
||||||
|
|
||||||
const PERIODS: { key: Period; label: string }[] = [
|
const PERIODS: { key: Period; label: string }[] = [
|
||||||
{ key: 'week', label: 'This week' },
|
{ key: 'week', label: 'This week' },
|
||||||
{ key: 'month', label: 'This month' },
|
{ key: 'month', label: 'This month' },
|
||||||
@@ -186,8 +170,7 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-6">
|
||||||
<!-- Header -->
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-white mb-1">Community</h1>
|
<h1 class="text-2xl font-bold text-white mb-1">Community</h1>
|
||||||
<p class="text-zinc-400 text-sm">What everyone's been up to — together.</p>
|
<p class="text-zinc-400 text-sm">What everyone's been up to — together.</p>
|
||||||
@@ -215,7 +198,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Community totals banner -->
|
<!-- Community totals -->
|
||||||
{#if totals.users > 0}
|
{#if totals.users > 0}
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{#each [
|
{#each [
|
||||||
@@ -232,117 +215,78 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Leaderboard sections -->
|
<!-- Table -->
|
||||||
{#if totals.users === 0}
|
{#if totals.users === 0}
|
||||||
<p class="text-zinc-500 text-sm">No public activities in this period yet.</p>
|
<p class="text-zinc-500 text-sm">No public activities in this period yet.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="overflow-x-auto rounded-xl border border-zinc-800">
|
||||||
|
<table class="w-full text-sm">
|
||||||
<!-- Out there -->
|
<thead>
|
||||||
{#if rowsCount.length}
|
<tr class="border-b border-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
<th class="text-left px-4 py-3 font-medium w-6">#</th>
|
||||||
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Out there</h2>
|
<th class="text-left px-4 py-3 font-medium">
|
||||||
<p class="text-xs text-zinc-500 -mt-1">Who kept showing up</p>
|
<button on:click={() => setSort('display_name')} class="hover:text-white transition-colors">
|
||||||
{#each rowsCount as u, i}
|
Athlete{chevron('display_name')}
|
||||||
<div class="flex items-center justify-between">
|
</button>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
</th>
|
||||||
<span class="text-base leading-none">{MEDAL[i]}</span>
|
<th class="text-right px-4 py-3 font-medium">
|
||||||
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
<button on:click={() => setSort('count')} class="hover:text-white transition-colors">
|
||||||
</div>
|
Activities{chevron('count')}
|
||||||
<span class="text-sm font-medium text-white ml-2 shrink-0">{u.count} {u.count === 1 ? 'activity' : 'activities'}</span>
|
</button>
|
||||||
</div>
|
</th>
|
||||||
{/each}
|
<th class="text-right px-4 py-3 font-medium">
|
||||||
</div>
|
<button on:click={() => setSort('distance_m')} class="hover:text-white transition-colors">
|
||||||
{/if}
|
Distance{chevron('distance_m')}
|
||||||
|
</button>
|
||||||
<!-- Going far -->
|
</th>
|
||||||
{#if rowsDistance.length}
|
<th class="text-right px-4 py-3 font-medium">
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
<button on:click={() => setSort('elevation_m')} class="hover:text-white transition-colors">
|
||||||
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Going far</h2>
|
Elevation{chevron('elevation_m')}
|
||||||
<p class="text-xs text-zinc-500 -mt-1">Who covered the most ground</p>
|
</button>
|
||||||
{#each rowsDistance as u, i}
|
</th>
|
||||||
<div class="flex items-center justify-between">
|
<th class="text-right px-4 py-3 font-medium">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<button on:click={() => setSort('duration_s')} class="hover:text-white transition-colors">
|
||||||
<span class="text-base leading-none">{MEDAL[i]}</span>
|
Time{chevron('duration_s')}
|
||||||
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
</button>
|
||||||
</div>
|
</th>
|
||||||
<span class="text-sm font-medium text-white ml-2 shrink-0">{formatDistance(u.distance_m)}</span>
|
<th class="text-right px-4 py-3 font-medium hidden sm:table-cell">
|
||||||
</div>
|
<button on:click={() => setSort('sports')} class="hover:text-white transition-colors">
|
||||||
{/each}
|
Sports{chevron('sports')}
|
||||||
</div>
|
</button>
|
||||||
{/if}
|
</th>
|
||||||
|
<th class="text-right px-4 py-3 font-medium hidden md:table-cell">
|
||||||
<!-- Reaching new heights -->
|
<button on:click={() => setSort('streak')} class="hover:text-white transition-colors">
|
||||||
{#if rowsElevation.length}
|
Streak{chevron('streak')}
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
</button>
|
||||||
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Reaching new heights</h2>
|
</th>
|
||||||
<p class="text-xs text-zinc-500 -mt-1">Who climbed the most</p>
|
</tr>
|
||||||
{#each rowsElevation as u, i}
|
</thead>
|
||||||
<div class="flex items-center justify-between">
|
<tbody>
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
{#each sorted as u, i}
|
||||||
<span class="text-base leading-none">{MEDAL[i]}</span>
|
<tr class="border-b border-zinc-800/50 last:border-0 hover:bg-zinc-800/30 transition-colors">
|
||||||
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
<td class="px-4 py-3 text-zinc-600 tabular-nums">{i + 1}</td>
|
||||||
</div>
|
<td class="px-4 py-3">
|
||||||
<span class="text-sm font-medium text-white ml-2 shrink-0">{Math.round(u.elevation_m).toLocaleString()} m</span>
|
<a href="{base}u/{u.handle}/" class="text-white font-medium hover:text-[--accent] transition-colors">
|
||||||
</div>
|
{u.display_name}
|
||||||
{/each}
|
</a>
|
||||||
</div>
|
<span class="text-zinc-600 text-xs ml-1">@{u.handle}</span>
|
||||||
{/if}
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums text-zinc-300">{u.count}</td>
|
||||||
<!-- Hours on the move -->
|
<td class="px-4 py-3 text-right tabular-nums text-zinc-300">{u.distance_m > 0 ? formatDistance(u.distance_m) : '—'}</td>
|
||||||
{#if rowsDuration.length}
|
<td class="px-4 py-3 text-right tabular-nums text-zinc-300">{u.elevation_m > 0 ? `${Math.round(u.elevation_m).toLocaleString()} m` : '—'}</td>
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
<td class="px-4 py-3 text-right tabular-nums text-zinc-300">{u.duration_s > 0 ? formatDuration(u.duration_s) : '—'}</td>
|
||||||
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Hours on the move</h2>
|
<td class="px-4 py-3 text-right hidden sm:table-cell">
|
||||||
<p class="text-xs text-zinc-500 -mt-1">Who invested the most time</p>
|
|
||||||
{#each rowsDuration as u, i}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<span class="text-base leading-none">{MEDAL[i]}</span>
|
|
||||||
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-medium text-white ml-2 shrink-0">{formatDuration(u.duration_s)}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Explorer -->
|
|
||||||
{#if rowsSports.length}
|
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
|
||||||
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Explorer</h2>
|
|
||||||
<p class="text-xs text-zinc-500 -mt-1">Who tried the most sports</p>
|
|
||||||
{#each rowsSports as u, i}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<span class="text-base leading-none">{MEDAL[i]}</span>
|
|
||||||
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-medium text-white ml-2 shrink-0 flex gap-1">
|
|
||||||
{#each u.sports as s}{sportIcon(s)}{/each}
|
{#each u.sports as s}{sportIcon(s)}{/each}
|
||||||
</span>
|
</td>
|
||||||
</div>
|
<td class="px-4 py-3 text-right tabular-nums text-zinc-300 hidden md:table-cell">
|
||||||
|
{u.streak > 0 ? `${u.streak}d` : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</tbody>
|
||||||
{/if}
|
</table>
|
||||||
|
|
||||||
<!-- Longest streak (all-time, shown in all periods as context) -->
|
|
||||||
{#if rowsStreak.length}
|
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 space-y-3">
|
|
||||||
<h2 class="text-sm font-semibold text-zinc-300 uppercase tracking-wide">Never stopped</h2>
|
|
||||||
<p class="text-xs text-zinc-500 -mt-1">Longest streak of consecutive days (all time)</p>
|
|
||||||
{#each rowsStreak as u, i}
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
|
||||||
<span class="text-base leading-none">{MEDAL[i]}</span>
|
|
||||||
<a href="{base}u/{u.handle}/" class="text-sm text-zinc-200 hover:text-white truncate">{u.display_name}</a>
|
|
||||||
</div>
|
|
||||||
<span class="text-sm font-medium text-white ml-2 shrink-0">{u.streak} days</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user