map now working

This commit is contained in:
Davide Scaini
2026-03-28 19:34:22 +01:00
parent 5d58126d2f
commit 3441079913
18 changed files with 1489 additions and 10 deletions
+161
View File
@@ -0,0 +1,161 @@
# BincioActivity — Context for Claude
## What this project is
BincioActivity is a federated, open-source, self-hosted activity stats platform
(think personal Strava). Two-stage pipeline:
1. **`bincio extract`** (Python): GPX/FIT/TCX → BAS JSON data store
2. **`bincio render`** (Astro/Node): BAS data store → static website
The BAS (BincioActivity Schema) JSON files are the federation protocol.
Anyone can publish their data as BAS JSON and others can include it.
## Key design decisions
- **No database, no server** — everything is static files
- **Python with uv** for the extract stage
- **Astro + Svelte + Tailwind + MapLibre GL + Observable Plot** for the site
- **Haversine** (not geopy) for distance calculations (10x faster)
- **Worker initializer pattern** for ProcessPoolExecutor — large shared data
(strava_lookup dict, known_hashes frozenset) is sent once per worker via
`initializer=`, not once per task
- **BAS activity IDs** always use UTC with Z suffix for URL safety
- **TCX files** from Garmin use both `http://` and `https://` namespace URIs —
parser handles both
## User's data
- Source: `~/src/cycling_data_davide/`
- `activities/` — Strava export (GPX, FIT, TCX, all with .gz variants)
- `Karoo_2026/` — recent Karoo device FIT files
- `Karoo/` — older Karoo FIT files
- `activities.csv` — Strava metadata (names, descriptions, gear)
- Extracted output: `~/bincio_data/` (or `/tmp/bincio_test/` for testing)
- ~3,200 input files → ~2,082 unique activities after dedup
- Date range: 20142026
## Project structure
```
bincio/ Python package
extract/
models.py DataPoint, ParsedActivity, LapData
parsers/ GPX, FIT, TCX parsers + factory
sport.py sport name normalisation
metrics.py haversine-based stats computation (single pass)
timeseries.py downsample to 1Hz, build BAS timeseries object
simplify.py RDP track simplification → GeoJSON
dedup.py exact (hash) + near-duplicate detection
strava_csv.py Strava activities.csv importer
writer.py BAS JSON + GeoJSON writer
config.py extract_config.yaml loader
cli.py `bincio extract` CLI
render/
cli.py `bincio render` CLI (symlinks data, runs astro build/dev)
schema/
bas-v1.schema.json JSON Schema for BAS
SCHEMA.md Human-readable BAS spec
site/ Astro project
src/
layouts/Base.astro
pages/
index.astro Activity feed (loads index.json client-side)
activity/[id].astro Single activity (SSG, loads detail JSON client-side)
stats/index.astro Heatmap + year totals
components/
ActivityFeed.svelte Card grid, sport filter, pagination
ActivityDetail.svelte Map + stats + charts wrapper
ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs)
StatsView.svelte Yearly heatmap + totals
lib/
types.ts BAS TypeScript types
format.ts formatDistance, formatDuration, sportIcon, etc.
```
## How to run
```bash
# Extract
cd ~/src/bincio_activity
uv run bincio extract --input ~/src/cycling_data_davide/activities --output /tmp/bincio_test
# Site dev server
cd site
ln -sf /tmp/bincio_test public/data # symlink data
BINCIO_DATA_DIR=/tmp/bincio_test npm run dev
# Tests
uv run pytest
```
## MapLibre GL + Vite/Astro — known gotchas
Learnt the hard way during debugging (March 2026):
- **`maplibregl.workerUrl = ...` is the v3 API and silently no-ops in v4+.**
The v5 API is `maplibregl.setWorkerUrl(url)`, but you don't need it at all in a
normal Vite environment — MapLibre handles the blob worker automatically.
- **`optimizeDeps: { exclude: ['maplibre-gl'] }` breaks tile loading.**
It prevents Vite from converting MapLibre's UMD bundle to ESM. The UMD bundle
uses AMD `define()` internally; served raw, the tile worker blob fails silently →
black map, no tiles. The correct setting is `include: ['maplibre-gl']`.
- **`build.target: 'es2022'` (and `optimizeDeps.esbuildOptions.target`) is required.**
MapLibre's dependencies use ES2022 class field syntax. If esbuild downgrades it,
helpers like `__publicField` aren't available inside the serialised worker blob
scope → tile loading fails. This is a known upstream issue (maplibre-gl-js #6680).
- **Use static imports, not dynamic `await import('maplibre-gl')`, when possible.**
With `client:only="svelte"` in Astro, SSR never runs for the component so there is
no `window is not defined` risk. Static import lets Vite pre-bundle correctly.
- **Use `client:only="svelte"` (not `client:load`) for the activity detail page.**
`client:load` does SSR + hydration; complex interactive components with MapLibre
can hit hydration mismatch issues. `client:only` mounts fresh on the client only.
- **MapLibre v5 requires explicit `center` and `zoom` in the Map constructor.**
v4 silently defaulted to `center: [0,0], zoom: 0`. v5 leaves internal projection
state undefined → `Cannot read properties of undefined (reading 'lng')` crashes
on any operation that touches coordinates (markers, resize, render). Always pass
`center` and `zoom` even if you plan to `fitBounds` later.
- **MapLibre v5 requires `setLngLat()` on markers before `.addTo(map)`.**
v4 tolerated markers without coordinates. v5 calls `Marker._update()` inside
`addTo()`, which needs valid lngLat → same `'lng'` crash. Set a dummy `[0, 0]`
if the real position arrives later (e.g. hover markers).
The working `astro.config.mjs` Vite section:
```js
vite: {
optimizeDeps: {
include: ['maplibre-gl'],
esbuildOptions: { target: 'es2022' },
},
build: { target: 'es2022' },
},
```
## Known issues / next steps
- `bincio render` Python CLI is a stub — site is built via `npm run build` directly
- Activity IDs in existing test data still use `+0000` format (pre-fix); re-run extract to get `Z` format
- Some activities appear with both untitled and titled IDs (near-dedup timing race)
- Stats page heatmap month labels use absolute positioning and may misalign
- Federation (remote data sources) not yet implemented in site
- Friends pages (`/friends/{handle}/`) not yet implemented
- `bincio render` should automate: symlink data → `astro build`
- The `site/.env` file is gitignored — document the setup for new users
- Add `--workers` benchmark: on 8 cores, ~7 min for 3,200 activities first run
## What "good" looks like (not yet done)
- [ ] `bincio render` Python CLI wraps `astro build` properly
- [ ] Friends/federation pages in site
- [ ] Personal records page
- [ ] Activity search / full-text filter in feed
- [ ] Map thumbnail in activity cards (SVG path from GeoJSON)
- [ ] GitHub Actions template for auto-publish
- [ ] Karoo/Garmin Connect importers beyond Strava
+162
View File
@@ -0,0 +1,162 @@
# BincioActivity
A federated, open-source, self-hosted activity stats platform.
Own your data. Share what you want. Follow friends by URL.
## What it is
BincioActivity turns a folder of GPX/FIT/TCX files into a beautiful, modern
static website — no database, no server required. It can run from the local
filesystem, GitHub Pages, or any static host.
**Federation**: anyone can "follow" a friend's data by adding a URL to their
config. Friends' activities appear in your site, attributed to them.
## Quick start
```bash
# Install
pip install bincio # or: uv add bincio
# Extract your activities
cp extract_config.example.yaml extract_config.yaml
# edit extract_config.yaml with your paths
bincio extract
# Build the site (requires Node ≥ 20)
cd site && npm install
BINCIO_DATA_DIR=~/bincio_data npm run build
# open site/dist/index.html
```
## Two stages
### Stage 1 — Extract (`bincio extract`)
Reads GPX, FIT, TCX files (including `.gz` compressed) and writes a
BincioActivity Schema (BAS) data store: plain JSON + GeoJSON files.
```
bincio extract # uses extract_config.yaml
bincio extract --input ~/rides --output ~/bincio_data
bincio extract --file ride.gpx # single file → stdout
bincio extract --since 2025-01-01 # incremental
```
Supported sources:
- GPX (generic, Garmin extensions)
- FIT (Garmin, Hammerhead Karoo)
- TCX (including Garmin's https:// namespace variant)
- All of the above gzip-compressed (`.gz`)
- Strava bulk export (`activities.csv` carries titles and descriptions)
### Stage 2 — Render (`bincio render`)
Generates a static site from the BAS data store using Astro.
```
cd site
BINCIO_DATA_DIR=~/bincio_data npm run dev # development
BINCIO_DATA_DIR=~/bincio_data npm run build # production build → site/dist/
```
## Configuration
### extract_config.yaml
```yaml
owner:
handle: yourname
display_name: Your Name
input:
dirs:
- ~/Activities/gpx
- ~/Activities/fit
metadata_csv: ~/strava_export/activities.csv # optional Strava metadata
output:
dir: ~/bincio_data
default_privacy: public # public | blur_start | no_gps | private
track:
rdp_epsilon: 0.0001 # GPS simplification (~11m at equator)
incremental: true # skip already-processed files
```
### site/.env
```
BINCIO_DATA_DIR=/path/to/bincio_data
```
## The BincioActivity Schema (BAS)
The data store is a directory of plain JSON files:
```
bincio_data/
index.json ← activity feed + owner manifest
activities/
{id}.json ← full activity with timeseries
{id}.geojson ← simplified GPS track
```
See `SCHEMA.md` for the full specification. The schema is versioned and
published as a standalone document so anyone can write importers in any
language.
## Federation
Add a friend's `index.json` URL to your `site_config.yaml`:
```yaml
data_sources:
- type: local
path: ~/bincio_data
- type: remote
handle: alice
url: https://alice.github.io/bincio/index.json
```
At build time the renderer fetches their public data and renders it under
`/friends/alice/`.
## Privacy
Privacy is enforced at the data layer — activities never leave your control:
| Level | GPS track | Stats visible |
|---|---|---|
| `public` | Full track | Yes |
| `blur_start` | First/last 200 m removed | Yes |
| `no_gps` | Not published | Yes |
| `private` | Not published | Not in index |
## Tech stack
| Layer | Technology |
|---|---|
| Extract | Python 3.12, click, fitdecode, gpxpy, lxml, rdp |
| Site framework | Astro (static generation) |
| UI components | Svelte 5 |
| Styling | Tailwind CSS |
| Charts | Observable Plot |
| Maps | MapLibre GL + OpenFreeMap tiles |
| Package manager (Python) | uv |
| Package manager (Node) | npm |
## Development
```bash
# Python
uv sync
uv run pytest
uv run bincio --help
# Site
cd site && npm install
BINCIO_DATA_DIR=/tmp/bincio_test npm run dev
```
+27
View File
@@ -25,6 +25,33 @@ def simplify_track(
return [p for (p, _, _), keep in zip(gps_pts, mask) if keep]
def preview_coords(
points: list[DataPoint],
max_points: int = 20,
) -> list[list[float]] | None:
"""Return a small list of [lat, lon] pairs for card thumbnail rendering.
Uses a coarser RDP pass, then subsamples to at most max_points.
Returns None if there is no GPS data.
"""
gps = [(p.lat, p.lon) for p in points if p.lat is not None and p.lon is not None]
if len(gps) < 2:
return None
# Coarse RDP (larger epsilon = fewer points)
coords = [[lon, lat] for lat, lon in gps]
mask = rdp(coords, epsilon=0.001, return_mask=True)
reduced = [gps[i] for i, keep in enumerate(mask) if keep]
# Subsample if still too many
if len(reduced) > max_points:
step = len(reduced) / max_points
reduced = [reduced[int(i * step)] for i in range(max_points)]
reduced.append(gps[-1]) # always include the last point
return [[round(lat, 5), round(lon, 5)] for lat, lon in reduced]
def build_geojson(
points: list[DataPoint],
activity_id: str,
+3 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from bincio.extract.metrics import ComputedMetrics
from bincio.extract.models import LapData, ParsedActivity
from bincio.extract.simplify import build_geojson
from bincio.extract.simplify import build_geojson, preview_coords
from bincio.extract.timeseries import build_timeseries
@@ -119,6 +119,8 @@ def build_summary(
"privacy": privacy,
"detail_url": f"activities/{activity_id}.json",
"track_url": f"activities/{activity_id}.geojson" if has_gps else None,
# Small track preview for card thumbnails — no separate fetch needed
"preview_coords": preview_coords(activity.points) if has_gps else None,
}
+141 -9
View File
@@ -1,4 +1,10 @@
"""bincio render — CLI command (stub, Astro stage TBD)."""
"""bincio render — build or serve the Astro static site."""
import os
import subprocess
import sys
from pathlib import Path
from typing import Optional
import click
from rich.console import Console
@@ -6,13 +12,139 @@ from rich.console import Console
console = Console()
def _find_site_dir(explicit: Optional[str]) -> Path:
"""Locate the Astro project directory."""
if explicit:
p = Path(explicit).expanduser().resolve()
if not (p / "package.json").exists():
raise click.UsageError(f"No package.json found in --site-dir {p}")
return p
# Search upward from cwd: ./site, ../site (for when cwd is bincio_data/)
for candidate in [Path.cwd() / "site", Path.cwd().parent / "site"]:
if (candidate / "package.json").exists():
return candidate
raise click.UsageError(
"Could not find the Astro site directory. "
"Run from the project root or pass --site-dir."
)
def _find_data_dir(explicit: Optional[str], config_path: Optional[str]) -> Path:
"""Resolve the BAS data directory."""
if explicit:
return Path(explicit).expanduser().resolve()
if config_path and Path(config_path).exists():
import yaml
raw = yaml.safe_load(Path(config_path).read_text())
out = raw.get("output", {}).get("dir")
if out:
return Path(out).expanduser().resolve()
# Default: ./bincio_data next to cwd
default = Path.cwd() / "bincio_data"
if default.exists():
return default
raise click.UsageError(
"Could not find the BAS data directory. "
"Run `bincio extract` first, or pass --data-dir."
)
def _ensure_npm(site: Path) -> None:
"""Run `npm install` if node_modules is missing or stale."""
if not (site / "node_modules").exists():
console.print("Running [cyan]npm install[/cyan]…")
subprocess.run(["npm", "install"], cwd=site, check=True)
def _link_data(site: Path, data: Path) -> None:
"""Symlink the BAS data store into site/public/data."""
public_data = site / "public" / "data"
if public_data.is_symlink():
if public_data.resolve() == data:
return # already correct
public_data.unlink()
elif public_data.exists():
console.print(
f"[yellow]Warning:[/yellow] {public_data} exists and is not a symlink — "
"remove it manually if you want bincio to manage it."
)
return
public_data.symlink_to(data)
console.print(f"Linked data: [cyan]{data}[/cyan] → [cyan]{public_data}[/cyan]")
@click.command()
@click.option("--config", "config_path", default="site_config.yaml")
@click.option("--out", "out_dir", default="./site/dist")
@click.option("--serve", is_flag=True, help="Start dev server with hot reload.")
@click.option("--config", "config_path", default=None,
help="Path to extract_config.yaml (reads output.dir from it).")
@click.option("--data-dir", default=None,
help="BAS data store directory (output of bincio extract).")
@click.option("--site-dir", default=None,
help="Astro project directory (default: ./site).")
@click.option("--out", "out_dir", default=None,
help="Build output directory (default: site/dist).")
@click.option("--serve", is_flag=True,
help="Start dev server with hot reload instead of building.")
@click.option("--deploy", default=None, metavar="TARGET",
help="Deploy target: 'github'.")
def render(config_path: str, out_dir: str, serve: bool, deploy: str | None) -> None:
"""Generate static site from BAS data store (Astro stage — coming soon)."""
console.print("[yellow]bincio render is not yet implemented.[/yellow]")
console.print("The web renderer (Astro + MapLibre + Observable Plot) is next.")
help="Deploy after build. Currently supports: github.")
def render(
config_path: Optional[str],
data_dir: Optional[str],
site_dir: Optional[str],
out_dir: Optional[str],
serve: bool,
deploy: Optional[str],
) -> None:
"""Build (or serve) the BincioActivity static site from a BAS data store."""
site = _find_site_dir(site_dir)
data = _find_data_dir(data_dir, config_path)
console.print(f"Site: [cyan]{site}[/cyan]")
console.print(f"Data: [cyan]{data}[/cyan]")
_ensure_npm(site)
_link_data(site, data)
env = {**os.environ, "BINCIO_DATA_DIR": str(data)}
if serve:
console.print("Starting [cyan]astro dev[/cyan]…")
subprocess.run(["npm", "run", "dev"], cwd=site, env=env)
return
# Build
cmd = ["npm", "run", "build"]
if out_dir:
# Pass outDir via Astro CLI flag
cmd = ["npx", "astro", "build", "--outDir", str(Path(out_dir).resolve())]
console.print("Running [cyan]astro build[/cyan]…")
result = subprocess.run(cmd, cwd=site, env=env)
if result.returncode != 0:
console.print("[red]Build failed.[/red]")
sys.exit(result.returncode)
dist = Path(out_dir).resolve() if out_dir else site / "dist"
console.print(f"\n[green]Build complete.[/green] Output: [cyan]{dist}[/cyan]")
if deploy == "github":
_deploy_github(site, dist)
def _deploy_github(site: Path, dist: Path) -> None:
"""Push dist/ to the gh-pages branch."""
console.print("Deploying to [cyan]GitHub Pages[/cyan]…")
# Requires npx gh-pages or git subtree push
result = subprocess.run(
["npx", "gh-pages", "-d", str(dist)],
cwd=site,
)
if result.returncode != 0:
console.print(
"[yellow]Tip:[/yellow] install gh-pages with `npm install -g gh-pages`"
)
+32
View File
@@ -0,0 +1,32 @@
owner:
handle: brutsalvadi
display_name: Bru
input:
dirs:
- ~/src/cycling_data_davide/activities
- ~/src/cycling_data_davide/Karoo_2026
- ~/src/cycling_data_davide/Karoo
# Strava bulk export metadata — provides names, descriptions, gear
metadata_csv: ~/src/cycling_data_davide/activities.csv
output:
dir: ~/bincio_data
default_privacy: public
sensors:
heart_rate: true
cadence: true
temperature: true
power: true
track:
simplify: rdp
rdp_epsilon: 0.0001 # ~11m at equator
timeseries_hz: 1 # 1 sample/second max
classifier:
enabled: false # ML activity type classifier (requires scikit-learn extra)
incremental: true # skip files whose hash hasn't changed since last run
+7
View File
@@ -7,4 +7,11 @@ export default defineConfig({
output: "static",
// When hosting at a subdirectory (e.g. GitHub Pages project site), set:
// base: "/repo-name",
vite: {
optimizeDeps: {
include: ['maplibre-gl'],
esbuildOptions: { target: 'es2022' },
},
build: { target: 'es2022' },
},
});
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Timeseries } from '../lib/types';
export let timeseries: Timeseries;
// Linked hover: emit/receive index into timeseries arrays
export let hoveredIdx: number | null = null;
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence';
let activeTab: Tab = 'elevation';
let chartEl: HTMLDivElement;
let Plot: any;
let chart: SVGElement | null = null;
// Pre-build data array once
$: data = timeseries.t.map((t, i) => ({
t,
elevation: timeseries.elevation_m[i],
speed: timeseries.speed_kmh[i],
hr: timeseries.hr_bpm[i],
cadence: timeseries.cadence_rpm[i],
}));
$: hasHR = timeseries.hr_bpm.some(v => v != null);
$: hasCadence = timeseries.cadence_rpm.some(v => v != null);
$: hasElevation = timeseries.elevation_m.some(v => v != null);
$: hasSpeed = timeseries.speed_kmh.some(v => v != null);
const tabLabels: Record<Tab, string> = {
elevation: 'Elevation',
speed: 'Speed',
hr: 'Heart Rate',
cadence: 'Cadence',
};
onMount(async () => {
Plot = await import('@observablehq/plot');
renderChart();
});
$: if (Plot && chartEl) {
activeTab; // reactive dependency
renderChart();
}
function renderChart() {
if (!Plot || !chartEl) return;
chart?.remove();
const w = chartEl.clientWidth || 800;
const h = 220;
const marks: any[] = [];
let yLabel = '';
let yKey = '';
let color = '#00c8ff';
if (activeTab === 'elevation' && hasElevation) {
yKey = 'elevation'; yLabel = 'Elevation (m)'; color = '#00c8ff';
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
);
} else if (activeTab === 'speed' && hasSpeed) {
yKey = 'speed'; yLabel = 'Speed (km/h)'; color = '#ff6b35';
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
);
} else if (activeTab === 'hr' && hasHR) {
yKey = 'hr'; yLabel = 'Heart Rate (bpm)'; color = '#f87171';
marks.push(
Plot.areaY(data, { x: 't', y: yKey, fill: color, fillOpacity: 0.15, curve: 'monotoneX' }),
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
);
} else if (activeTab === 'cadence' && hasCadence) {
yKey = 'cadence'; yLabel = 'Cadence (rpm)'; color = '#a78bfa';
marks.push(
Plot.lineY(data, { x: 't', y: yKey, stroke: color, strokeWidth: 1.5, curve: 'monotoneX' }),
);
}
if (!marks.length) return;
// Hover crosshair
marks.push(
Plot.ruleX(data, Plot.pointerX({
x: 't',
stroke: 'rgba(255,255,255,0.3)',
strokeWidth: 1,
strokeDasharray: '4,4',
})),
Plot.dot(data, Plot.pointerX({
x: 't', y: yKey,
r: 4, fill: color, stroke: 'white', strokeWidth: 1.5,
})),
Plot.text(data, Plot.pointerX({
x: 't', y: yKey,
text: (d: any) => d[yKey] != null ? `${Math.round(d[yKey])}` : '',
dy: -12, fill: 'white', fontSize: 11, fontWeight: '600',
})),
);
chart = Plot.plot({
width: w,
height: h,
marginLeft: 48,
marginBottom: 32,
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
x: {
label: null,
tickFormat: (t: number) => {
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
return h > 0 ? `${h}h${m.toString().padStart(2,'0')}` : `${m}m`;
},
grid: false,
ticks: 6,
},
y: { label: yLabel, grid: true, tickCount: 4 },
marks,
});
// Attach pointer listener to emit hover index
chart.addEventListener('input', () => {
const pt = (chart as any)?.value;
if (pt) {
hoveredIdx = timeseries.t.findIndex(t => t === pt.t);
} else {
hoveredIdx = null;
}
});
chartEl.appendChild(chart);
}
</script>
<!-- Tab bar -->
<div class="flex gap-1 mb-3">
{#each Object.entries(tabLabels) as [tab, label]}
{@const enabled =
tab === 'elevation' ? hasElevation :
tab === 'speed' ? hasSpeed :
tab === 'hr' ? hasHR :
hasCadence}
<button
class="px-3 py-1.5 rounded-md text-sm transition-colors"
class:opacity-30={!enabled}
class:cursor-not-allowed={!enabled}
class:bg-zinc-800={activeTab === tab}
class:text-white={activeTab === tab}
class:text-zinc-500={activeTab !== tab}
class:hover:text-zinc-300={activeTab !== tab && enabled}
disabled={!enabled}
on:click={() => { if (enabled) activeTab = tab as Tab; }}
>
{label}
</button>
{/each}
</div>
<div bind:this={chartEl} class="w-full overflow-hidden" />
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ActivitySummary, ActivityDetail } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
import ActivityMap from './ActivityMap.svelte';
import ActivityCharts from './ActivityCharts.svelte';
export let activity: ActivitySummary;
export let base: string = '/';
let detail: ActivityDetail | null = null;
let error = '';
// Linked hover index shared between map and charts
let hoveredIdx: number | null = null;
onMount(async () => {
if (!activity.detail_url) return;
try {
const res = await fetch(`${base}data/${activity.detail_url}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
detail = await res.json();
} catch (e: any) {
error = e.message;
}
});
$: trackUrl = activity.track_url ? `${base}data/${activity.track_url}` : null;
$: color = sportColor(activity.sport);
const stat = (label: string, value: string) => ({ label, value });
$: stats = [
stat('Distance', formatDistance(activity.distance_m)),
stat('Moving time', formatDuration(activity.moving_time_s ?? activity.duration_s)),
stat('Elevation ↑', formatElevation(activity.elevation_gain_m)),
stat('Avg speed', formatSpeed(activity.avg_speed_kmh)),
stat('Max speed', formatSpeed(activity.max_speed_kmh)),
stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—'),
stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—'),
stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—'),
];
</script>
<!-- Header -->
<div class="flex items-start gap-4 mb-6">
<a href={`${base}`} class="text-zinc-500 hover:text-white transition-colors mt-1 shrink-0">
← Back
</a>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
style="background:{color}22;color:{color}"
>
{sportIcon(activity.sport)} {sportLabel(activity.sport, activity.sub_sport)}
</span>
<span class="text-xs text-zinc-500">
{formatDate(activity.started_at)} · {formatTime(activity.started_at)}
</span>
</div>
<h1 class="text-2xl font-bold text-white">{activity.title}</h1>
{#if detail?.description}
<p class="text-zinc-400 mt-1 text-sm">{detail.description}</p>
{/if}
</div>
</div>
<!-- Map + Stats split -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4">
<!-- Map -->
<div class="h-[400px] lg:h-[420px] rounded-xl overflow-hidden bg-zinc-800">
{#if trackUrl}
<ActivityMap
{trackUrl}
timeseries={detail?.timeseries ?? null}
bbox={detail?.bbox ?? null}
accentColor={color}
bind:hoveredIdx
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600 text-sm">
No GPS track
</div>
{/if}
</div>
<!-- Stats panel -->
<div class="grid grid-cols-2 lg:grid-cols-1 gap-px bg-zinc-800 rounded-xl overflow-hidden">
{#each stats as s}
<div class="bg-zinc-900 px-4 py-3">
<p class="text-2xl font-bold text-white">{s.value}</p>
<p class="text-xs text-zinc-500">{s.label}</p>
</div>
{/each}
{#if detail?.gear}
<div class="bg-zinc-900 px-4 py-3 col-span-2 lg:col-span-1">
<p class="text-sm font-medium text-zinc-300">{detail.gear}</p>
<p class="text-xs text-zinc-500">Gear</p>
</div>
{/if}
</div>
</div>
<!-- Charts -->
{#if error}
<p class="text-red-400 text-sm mt-4">{error}</p>
{:else if detail?.timeseries && detail.timeseries.t.length > 0}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<ActivityCharts timeseries={detail.timeseries} bind:hoveredIdx />
</div>
{:else if !detail}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse" />
{/if}
<!-- Laps -->
{#if detail?.laps?.length}
<div class="mt-4 bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table class="w-full text-sm">
<thead class="border-b border-zinc-800">
<tr class="text-left text-zinc-500 text-xs">
<th class="px-4 py-2">Lap</th>
<th class="px-4 py-2">Distance</th>
<th class="px-4 py-2">Time</th>
<th class="px-4 py-2">Avg speed</th>
<th class="px-4 py-2">Avg HR</th>
</tr>
</thead>
<tbody>
{#each detail.laps as lap}
<tr class="border-b border-zinc-800/50 hover:bg-zinc-800/50">
<td class="px-4 py-2 text-zinc-400">#{lap.index + 1}</td>
<td class="px-4 py-2 text-white">{formatDistance(lap.distance_m)}</td>
<td class="px-4 py-2 text-white">{formatDuration(lap.duration_s)}</td>
<td class="px-4 py-2 text-white">{formatSpeed(lap.avg_speed_kmh)}</td>
<td class="px-4 py-2 text-white">{lap.avg_hr_bpm ? `${lap.avg_hr_bpm} bpm` : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
/** Render preview_coords as an SVG polyline path string. */
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
if (!coords || coords.length < 2) return '';
const lats = coords.map(c => c[0]);
const lons = coords.map(c => c[1]);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
const latR = maxLat - minLat || 0.001;
const lonR = maxLon - minLon || 0.001;
const pad = 4;
const scaleX = (w - pad * 2) / lonR;
const scaleY = (h - pad * 2) / latR;
const scale = Math.min(scaleX, scaleY);
const offX = pad + (w - pad * 2 - lonR * scale) / 2;
const offY = pad + (h - pad * 2 - latR * scale) / 2;
return coords
.map(([lat, lon], i) => {
const x = (lon - minLon) * scale + offX;
const y = h - ((lat - minLat) * scale + offY); // flip: SVG y↓, lat↑
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
}
const PAGE_SIZE = 60;
let all: ActivitySummary[] = [];
let sport: Sport | 'all' = 'all';
let shown = PAGE_SIZE;
let loading = true;
let error = '';
$: filtered = sport === 'all' ? all : all.filter(a => a.sport === sport);
$: visible = filtered.slice(0, shown);
$: hasMore = shown < filtered.length;
$: if (sport) shown = PAGE_SIZE; // reset pagination on filter change
onMount(async () => {
try {
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const index: BASIndex = await res.json();
all = index.activities.filter(a => a.privacy !== 'private');
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
});
const sports: Array<{ value: Sport | 'all'; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'cycling', label: '🚴 Cycling' },
{ value: 'running', label: '🏃 Running' },
{ value: 'hiking', label: '🥾 Hiking' },
{ value: 'walking', label: '🚶 Walking' },
{ value: 'swimming', label: '🏊 Swimming' },
{ value: 'other', label: '⚡ Other' },
];
</script>
<!-- Filter bar -->
<div class="flex gap-2 mb-6 flex-wrap">
{#each sports as s}
<button
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
class:border-zinc-700={sport !== s.value}
class:text-zinc-400={sport !== s.value}
class:border-[--accent]={sport === s.value}
class:text-white={sport === s.value}
style={sport === s.value ? 'background:var(--accent-dim)' : ''}
on:click={() => sport = s.value}
>
{s.label}
</button>
{/each}
{#if all.length > 0}
<span class="ml-auto text-sm text-zinc-500 self-center">
{filtered.length} {filtered.length === 1 ? 'activity' : 'activities'}
</span>
{/if}
</div>
{#if loading}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each Array(12) as _}
<div class="h-36 rounded-xl bg-zinc-800 animate-pulse"></div>
{/each}
</div>
{:else if error}
<p class="text-red-400 text-center py-12">Could not load activities: {error}</p>
{:else if filtered.length === 0}
<p class="text-zinc-500 text-center py-12">No activities found.</p>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{#each visible as a (a.id)}
<a
href={`${import.meta.env.BASE_URL}activity/${a.id}/`}
class="block rounded-xl bg-zinc-900 border border-zinc-800 p-4 hover:border-zinc-600 hover:bg-zinc-800/80 transition-all group"
>
<!-- header -->
<div class="flex items-start justify-between gap-2 mb-3">
<div class="flex-1 min-w-0">
<p class="text-xs text-zinc-500 mb-0.5">{formatDate(a.started_at)}</p>
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
{a.title}
</h3>
</div>
<span
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
style="background:{sportColor(a.sport)}22; color:{sportColor(a.sport)}"
>
{sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)}
</span>
</div>
<!-- track thumbnail -->
{#if a.preview_coords}
<svg viewBox="0 0 120 70" class="w-full mt-2 mb-3 rounded overflow-hidden bg-zinc-800/60" style="height:70px">
<path
d={trackPath(a.preview_coords, 120, 70)}
fill="none"
stroke={sportColor(a.sport)}
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
opacity="0.9"
/>
</svg>
{/if}
<!-- stats row -->
<div class="grid grid-cols-3 gap-2 text-center">
<div>
<p class="text-lg font-bold text-white">{formatDistance(a.distance_m)}</p>
<p class="text-xs text-zinc-500">Distance</p>
</div>
<div>
<p class="text-lg font-bold text-white">{formatDuration(a.moving_time_s ?? a.duration_s)}</p>
<p class="text-xs text-zinc-500">Moving time</p>
</div>
<div>
<p class="text-lg font-bold text-white">{formatElevation(a.elevation_gain_m)}</p>
<p class="text-xs text-zinc-500">Elevation</p>
</div>
</div>
<!-- secondary stats -->
{#if a.avg_speed_kmh || a.avg_hr_bpm}
<div class="flex gap-4 mt-3 pt-3 border-t border-zinc-800 text-xs text-zinc-400">
{#if a.avg_speed_kmh}
<span>{a.avg_speed_kmh.toFixed(1)} km/h</span>
{/if}
{#if a.avg_hr_bpm}
<span>{a.avg_hr_bpm} bpm</span>
{/if}
{#if a.avg_cadence_rpm}
<span>{a.avg_cadence_rpm} rpm</span>
{/if}
</div>
{/if}
</a>
{/each}
</div>
{#if hasMore}
<div class="text-center mt-8">
<button
class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors text-sm"
on:click={() => shown += PAGE_SIZE}
>
Load more ({filtered.length - shown} remaining)
</button>
</div>
{/if}
{/if}
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { Timeseries } from '../lib/types';
export let trackUrl: string;
export let timeseries: Timeseries | null = null;
export let bbox: [number, number, number, number] | null = null;
export let accentColor: string = '#00c8ff';
export let hoveredIdx: number | null = null;
let mapEl: HTMLDivElement;
let map: any;
const MarkerClass = maplibregl.Marker;
let hoverMarker: any;
let markersAdded = false;
const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron';
onMount(() => {
map = new maplibregl.Map({
container: mapEl,
style: TILE_STYLE,
center: [0, 0],
zoom: 1,
attributionControl: false,
});
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
// Hover dot marker — must set lngLat before addTo in MapLibre v5
const el = document.createElement('div');
el.style.cssText = `
width:12px;height:12px;border-radius:50%;
background:white;border:2px solid ${accentColor};
box-shadow:0 0 6px ${accentColor};display:none;pointer-events:none;
`;
hoverMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
.setLngLat([0, 0])
.addTo(map);
map.on('load', () => {
map.addSource('track', {
type: 'geojson',
data: trackUrl,
lineMetrics: true,
});
map.addLayer({
id: 'track-shadow',
type: 'line',
source: 'track',
paint: { 'line-color': 'rgba(0,0,0,0.3)', 'line-width': 5, 'line-blur': 2 },
});
map.addLayer({
id: 'track-line',
type: 'line',
source: 'track',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: {
'line-width': 3,
'line-gradient': [
'interpolate', ['linear'], ['line-progress'],
0, accentColor,
0.5, '#ff6b35',
1, accentColor,
],
},
});
});
});
// Fit to bbox when detail JSON loads (bbox is null at map init)
$: if (map && bbox) {
const fit = () => map.fitBounds(
[[bbox![0], bbox![1]], [bbox![2], bbox![3]]],
{ padding: 40, animate: true },
);
map.loaded() ? fit() : map.once('load', fit);
}
// Add start/end markers when timeseries arrives
$: if (map && MarkerClass && timeseries && !markersAdded) {
markersAdded = true;
const add = () => {
const lats = (timeseries!.lat ?? []).filter(v => v != null) as number[];
const lons = (timeseries!.lon ?? []).filter(v => v != null) as number[];
if (!lats.length) return;
new MarkerClass({ element: makeDot('#4ade80'), anchor: 'center' })
.setLngLat([lons[0], lats[0]]).addTo(map);
new MarkerClass({ element: makeDot('#f87171'), anchor: 'center' })
.setLngLat([lons[lons.length - 1], lats[lats.length - 1]]).addTo(map);
};
map.loaded() ? add() : map.once('load', add);
}
// Hover dot linked to chart crosshair
$: if (hoverMarker && timeseries && hoveredIdx != null) {
const lat = timeseries.lat?.[hoveredIdx];
const lon = timeseries.lon?.[hoveredIdx];
if (lat != null && lon != null) {
hoverMarker.getElement().style.display = 'block';
hoverMarker.setLngLat([lon, lat]);
}
} else if (hoverMarker) {
hoverMarker.getElement().style.display = 'none';
}
function makeDot(color: string): HTMLDivElement {
const el = document.createElement('div');
el.style.cssText = `
width:10px;height:10px;border-radius:50%;
background:${color};border:2px solid white;
box-shadow:0 0 4px rgba(0,0,0,0.5);
`;
return el;
}
onDestroy(() => map?.remove());
</script>
<div bind:this={mapEl} class="w-full h-full" />
+179
View File
@@ -0,0 +1,179 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, sportIcon } from '../lib/format';
let activities: ActivitySummary[] = [];
let loading = true;
onMount(async () => {
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
const index: BASIndex = await res.json();
activities = index.activities.filter(a => a.privacy !== 'private' && a.distance_m);
loading = false;
});
// ── Heatmap ───────────────────────────────────────────────────────────────
// Build a map: dateString → total distance (m)
$: byDate = (() => {
const m = new Map<string, number>();
for (const a of activities) {
const d = a.started_at.slice(0, 10); // YYYY-MM-DD
m.set(d, (m.get(d) ?? 0) + (a.distance_m ?? 0));
}
return m;
})();
// Current year and prior 3 years to show
const now = new Date();
const years = [now.getFullYear(), now.getFullYear()-1, now.getFullYear()-2, now.getFullYear()-3];
function getWeeks(year: number): string[][] {
// Returns array of weeks, each week is array of 7 date strings (MonSun)
// Pad with empty strings at start/end
const jan1 = new Date(year, 0, 1);
const dec31 = new Date(year, 11, 31);
// Align to Monday
const start = new Date(jan1);
start.setDate(jan1.getDate() - ((jan1.getDay() + 6) % 7));
const end = new Date(dec31);
end.setDate(dec31.getDate() + (6 - (dec31.getDay() + 6) % 7));
const weeks: string[][] = [];
let cur = new Date(start);
while (cur <= end) {
const week: string[] = [];
for (let d = 0; d < 7; d++) {
const iso = cur.toISOString().slice(0, 10);
week.push(cur.getFullYear() === year ? iso : '');
cur.setDate(cur.getDate() + 1);
}
weeks.push(week);
}
return weeks;
}
function cellColor(date: string): string {
if (!date) return 'transparent';
const km = (byDate.get(date) ?? 0) / 1000;
if (km === 0) return '#27272a'; // zinc-800
if (km < 20) return '#0e4c5a';
if (km < 50) return '#0a6e82';
if (km < 80) return '#0891b2'; // cyan-600
if (km < 120) return '#06b6d4'; // cyan-500
return '#00c8ff';
}
// ── Totals ────────────────────────────────────────────────────────────────
$: totalsByYear = (() => {
const m = new Map<number, { dist: number; count: number }>();
for (const a of activities) {
const y = new Date(a.started_at).getFullYear();
const cur = m.get(y) ?? { dist: 0, count: 0 };
cur.dist += a.distance_m ?? 0;
cur.count += 1;
m.set(y, cur);
}
return m;
})();
$: allYears = [...totalsByYear.keys()].sort((a, b) => b - a);
const DOW = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function monthLabels(weeks: string[][]): Array<{month:string;col:number}> {
const seen = new Set<string>();
return weeks.flatMap((week, i) => {
const day = week.find(d => d);
if (!day) return [];
const m = MONTHS[parseInt(day.slice(5, 7)) - 1];
if (seen.has(m)) return [];
seen.add(m);
return [{ month: m, col: i }];
});
}
</script>
{#if loading}
<div class="h-64 rounded-xl bg-zinc-800 animate-pulse mb-6" />
{:else}
<!-- Year totals -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8">
{#each allYears.slice(0, 4) as year}
{@const t = totalsByYear.get(year)}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<p class="text-xs text-zinc-500 mb-1">{year}</p>
<p class="text-2xl font-bold text-white">{formatDistance(t?.dist ?? 0)}</p>
<p class="text-sm text-zinc-400">{t?.count ?? 0} activities</p>
</div>
{/each}
</div>
<!-- Heatmaps per year -->
{#each years as year}
{@const weeks = getWeeks(year)}
{@const labels = monthLabels(weeks)}
{@const yt = totalsByYear.get(year)}
{#if yt}
<div class="mb-8">
<div class="flex items-baseline gap-3 mb-2">
<h2 class="text-lg font-semibold text-white">{year}</h2>
<span class="text-sm text-zinc-400">
{formatDistance(yt.dist)} · {yt.count} activities
</span>
</div>
<div class="overflow-x-auto">
<div class="inline-block">
<!-- Month labels -->
<div class="flex mb-1 ml-6">
{#each labels as { month, col }}
<span
class="text-xs text-zinc-500 absolute"
style="left: calc({col} * 13px)"
>{month}</span>
{/each}
<!-- spacer to set width -->
<div style="width:{weeks.length * 13}px" />
</div>
<!-- Grid -->
<div class="flex gap-[3px]">
<!-- Day-of-week labels -->
<div class="flex flex-col gap-[3px] mr-1">
{#each DOW as d, i}
<span class="text-[9px] text-zinc-600 h-[10px] leading-[10px] w-3 text-right">
{i % 2 === 1 ? d : ''}
</span>
{/each}
</div>
<!-- Weeks -->
{#each weeks as week}
<div class="flex flex-col gap-[3px]">
{#each week as date}
<div
class="w-[10px] h-[10px] rounded-[2px]"
style="background:{cellColor(date)}"
title={date ? `${date}: ${formatDistance(byDate.get(date) ?? 0)}` : ''}
/>
{/each}
</div>
{/each}
</div>
</div>
</div>
<!-- Legend -->
<div class="flex items-center gap-1 mt-2">
<span class="text-xs text-zinc-500 mr-1">Less</span>
{#each ['#27272a','#0e4c5a','#0a6e82','#0891b2','#06b6d4','#00c8ff'] as c}
<div class="w-[10px] h-[10px] rounded-[2px]" style="background:{c}" />
{/each}
<span class="text-xs text-zinc-500 ml-1">More</span>
</div>
</div>
{/if}
{/each}
{/if}
+41
View File
@@ -0,0 +1,41 @@
---
interface Props {
title?: string;
description?: string;
}
const { title = 'BincioActivity', description = 'Your personal activity stats' } = Astro.props;
---
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<title>{title}</title>
<style is:global>
:root {
--accent: #00c8ff;
--accent-dim: rgba(0,200,255,0.15);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; }
/* MapLibre GL needs these */
.maplibregl-canvas { outline: none; }
</style>
</head>
<body class="bg-zinc-950 text-zinc-100 font-sans antialiased min-h-screen">
<nav class="border-b border-zinc-800 sticky top-0 z-50 bg-zinc-950/90 backdrop-blur">
<div class="max-w-7xl mx-auto px-4 h-12 flex items-center gap-6">
<a href="/" class="font-bold text-white tracking-tight hover:text-[--accent] transition-colors">
Bincio<span class="text-[--accent]">Activity</span>
</a>
<a href="/" class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
<a href="/stats/" class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 py-6">
<slot />
</main>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
import type { Sport } from './types';
export function formatDistance(m: number | null, unit: 'metric' | 'imperial' = 'metric'): string {
if (m == null) return '—';
if (unit === 'imperial') {
const miles = m / 1609.344;
return miles >= 10 ? `${miles.toFixed(1)} mi` : `${miles.toFixed(2)} mi`;
}
const km = m / 1000;
return km >= 10 ? `${km.toFixed(1)} km` : `${km.toFixed(2)} km`;
}
export function formatDuration(s: number | null): string {
if (s == null) return '—';
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`;
return `${m}m ${sec.toString().padStart(2, '0')}s`;
}
export function formatSpeed(kmh: number | null): string {
if (kmh == null) return '—';
return `${kmh.toFixed(1)} km/h`;
}
export function formatElevation(m: number | null): string {
if (m == null) return '—';
return `${Math.round(m)} m`;
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', {
day: 'numeric', month: 'short', year: 'numeric',
});
}
export function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit', minute: '2-digit',
});
}
export function formatDateShort(iso: string): string {
return new Date(iso).toLocaleDateString('en-GB', {
day: 'numeric', month: 'short',
});
}
const SPORT_ICONS: Record<Sport, string> = {
cycling: '🚴',
running: '🏃',
hiking: '🥾',
walking: '🚶',
swimming: '🏊',
other: '⚡',
};
const SPORT_COLORS: Record<Sport, string> = {
cycling: '#00c8ff',
running: '#ff6b35',
hiking: '#4ade80',
walking: '#a3e635',
swimming: '#38bdf8',
other: '#a78bfa',
};
export function sportIcon(sport: Sport): string {
return SPORT_ICONS[sport] ?? '⚡';
}
export function sportColor(sport: Sport): string {
return SPORT_COLORS[sport] ?? '#a78bfa';
}
export function sportLabel(sport: Sport, subSport?: string | null): string {
const base = sport.charAt(0).toUpperCase() + sport.slice(1);
if (subSport && subSport !== 'generic') {
return `${subSport.charAt(0).toUpperCase() + subSport.slice(1)} ${base}`;
}
return base;
}
+2
View File
@@ -24,6 +24,8 @@ export interface ActivitySummary {
privacy: Privacy;
detail_url: string | null;
track_url: string | null;
/** ~20 [lat, lon] pairs for card thumbnail — no separate fetch needed. */
preview_coords: [number, number][] | null;
}
export interface BASIndex {
+27
View File
@@ -0,0 +1,27 @@
---
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import Base from '../../layouts/Base.astro';
import ActivityDetail from '../../components/ActivityDetail.svelte';
import type { BASIndex, ActivitySummary } from '../../lib/types';
export async function getStaticPaths() {
const dataDir = process.env.BINCIO_DATA_DIR
?? resolve(process.cwd(), '..', 'bincio_data');
const raw = readFileSync(join(dataDir, 'index.json'), 'utf-8');
const index: BASIndex = JSON.parse(raw);
return index.activities
.filter(a => a.privacy !== 'private' && a.id)
.map(a => ({
params: { id: a.id },
props: { activity: a },
}));
}
const { activity } = Astro.props as { activity: ActivitySummary };
const base = import.meta.env.BASE_URL;
---
<Base title={`${activity.title} — BincioActivity`}>
<ActivityDetail {activity} {base} client:only="svelte" />
</Base>
+8
View File
@@ -0,0 +1,8 @@
---
import Base from '../layouts/Base.astro';
import ActivityFeed from '../components/ActivityFeed.svelte';
---
<Base title="BincioActivity — Feed">
<h1 class="text-2xl font-bold text-white mb-6">Activities</h1>
<ActivityFeed client:load />
</Base>
+8
View File
@@ -0,0 +1,8 @@
---
import Base from '../../layouts/Base.astro';
import StatsView from '../../components/StatsView.svelte';
---
<Base title="Stats — BincioActivity">
<h1 class="text-2xl font-bold text-white mb-6">Stats</h1>
<StatsView client:load />
</Base>