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] 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( def build_geojson(
points: list[DataPoint], points: list[DataPoint],
activity_id: str, activity_id: str,
+3 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from bincio.extract.metrics import ComputedMetrics from bincio.extract.metrics import ComputedMetrics
from bincio.extract.models import LapData, ParsedActivity 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 from bincio.extract.timeseries import build_timeseries
@@ -119,6 +119,8 @@ def build_summary(
"privacy": privacy, "privacy": privacy,
"detail_url": f"activities/{activity_id}.json", "detail_url": f"activities/{activity_id}.json",
"track_url": f"activities/{activity_id}.geojson" if has_gps else None, "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 import click
from rich.console import Console from rich.console import Console
@@ -6,13 +12,139 @@ from rich.console import Console
console = 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.command()
@click.option("--config", "config_path", default="site_config.yaml") @click.option("--config", "config_path", default=None,
@click.option("--out", "out_dir", default="./site/dist") help="Path to extract_config.yaml (reads output.dir from it).")
@click.option("--serve", is_flag=True, help="Start dev server with hot reload.") @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", @click.option("--deploy", default=None, metavar="TARGET",
help="Deploy target: 'github'.") help="Deploy after build. Currently supports: github.")
def render(config_path: str, out_dir: str, serve: bool, deploy: str | None) -> None: def render(
"""Generate static site from BAS data store (Astro stage — coming soon).""" config_path: Optional[str],
console.print("[yellow]bincio render is not yet implemented.[/yellow]") data_dir: Optional[str],
console.print("The web renderer (Astro + MapLibre + Observable Plot) is next.") 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", output: "static",
// When hosting at a subdirectory (e.g. GitHub Pages project site), set: // When hosting at a subdirectory (e.g. GitHub Pages project site), set:
// base: "/repo-name", // 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; privacy: Privacy;
detail_url: string | null; detail_url: string | null;
track_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 { 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>