feat: Phase 4 — MapLibre route map + SVG elevation chart on activity screen

- Add /api/activity/{id}/geojson and /api/activity/{id}/timeseries endpoints
  (bearer-token-gated, falls back from _merged to raw activities dir)
- Rewrite activity detail screen with MapLibreGL v11 API (Map, Camera,
  GeoJSONSource, Layer) and react-native-svg area chart with gradient fill
- On-demand fetch for remote activities that have no local geojson/timeseries
- Add react-native-svg dependency; requires dev build (npx expo run:android)
This commit is contained in:
Davide Scaini
2026-04-24 15:40:10 +02:00
parent 02726034c7
commit 97c7fae9be
5 changed files with 507 additions and 164 deletions
+32
View File
@@ -455,6 +455,38 @@ async def stats() -> JSONResponse:
}) })
@app.get("/api/activity/{activity_id}/geojson")
async def get_activity_geojson(
activity_id: str,
user: User = Depends(_require_auth),
) -> JSONResponse:
"""Return GeoJSON track for an activity (mobile detail screen)."""
_check_id(activity_id)
dd = _get_data_dir()
user_dir = dd / user.handle
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
p = base / f"{activity_id}.geojson"
if p.exists():
return JSONResponse(json.loads(p.read_text()))
raise HTTPException(404, "GeoJSON not found")
@app.get("/api/activity/{activity_id}/timeseries")
async def get_activity_timeseries(
activity_id: str,
user: User = Depends(_require_auth),
) -> JSONResponse:
"""Return timeseries for an activity (mobile detail screen)."""
_check_id(activity_id)
dd = _get_data_dir()
user_dir = dd / user.handle
for base in (user_dir / "_merged" / "activities", user_dir / "activities"):
p = base / f"{activity_id}.timeseries.json"
if p.exists():
return JSONResponse(json.loads(p.read_text()))
raise HTTPException(404, "Timeseries not found")
@app.get("/api/wheel/version") @app.get("/api/wheel/version")
async def wheel_version() -> JSONResponse: async def wheel_version() -> JSONResponse:
"""Public endpoint: current bincio wheel version for mobile app update checks.""" """Public endpoint: current bincio wheel version for mobile app update checks."""
+85 -65
View File
@@ -76,11 +76,13 @@ instantly via Metro (the JS bundler) — no rebuild needed.
| Code changes | instant (Metro) | instant (Metro) | | Code changes | instant (Metro) | instant (Metro) |
| Native changes | need new Expo Go release | rebuild APK | | Native changes | need new Expo Go release | rebuild APK |
**Phase 0** only uses built-in Expo modules — Expo Go works. **Phase 1** (Pyodide) **Phase 0 and 0.5** only use built-in Expo modules — Expo Go works. **Phase 1**
requires a Development Build because `react-native-webview` is a native module. (Pyodide) and **Phase 4** (MapLibre maps) require a Development Build because
`react-native-webview` and `@maplibre/maplibre-react-native` are native modules.
The recommended setup from the start is a Development Build so you never hit a wall The preferred path for Phase 1+: connect the phone via USB and run
mid-phase. `npx expo run:android` once. After that, JS changes still update instantly via Metro
— no rebuild needed unless you change native code.
--- ---
@@ -147,25 +149,17 @@ reflect instantly without rebuilding.
For the emulator, create an AVD in Android Studio with API 33+ and start it before For the emulator, create an AVD in Android Studio with API 33+ and start it before
running the command. running the command.
#### Option B: EAS Build (cloud, no Android Studio required) #### Option B: local EAS build (no Android Studio, no external cloud)
EAS (Expo Application Services) builds the APK in the cloud. You get a download `eas build --local` runs the entire build pipeline on your own machine (or VPS):
link; install it on your device once.
```bash ```bash
npm install -g eas-cli npm install -g eas-cli
eas login # Expo account needed eas build -p android --profile development --local
eas build -p android --profile development
``` ```
After install, start Metro locally: This produces an `.apk` you can transfer to the device via any means (USB, VPS
download link, AirDrop). No Expo account or cloud service required.
```bash
cd mobile
npx expo start --dev-client
```
Shake the device to open the dev menu and enter the Metro URL if needed.
--- ---
@@ -520,59 +514,60 @@ The token is stored in the `settings` table and sent as
## Implementation plan ## Implementation plan
### Phase 0 — Foundation ### Phase 0 — Foundation
*Goal: app launches, settings can be configured, a BAS JSON file can be picked *Goal: app launches, settings can be configured, a BAS JSON file can be picked
and displayed as an activity card. No extraction yet.* and displayed as an activity card. No extraction yet.*
**`mobile/` scaffold:**
- `expo init mobile --template expo-template-blank-typescript`
- Expo Router with three tabs: **Feed**, **Import**, **Settings** - Expo Router with three tabs: **Feed**, **Import**, **Settings**
- `expo-sqlite` initialised; `activities` and `settings` tables created on first launch - `expo-sqlite` initialised; `activities` and `settings` tables created on first launch
- Settings screen: instance URL and handle fields, saved to `settings` table - Settings screen: instance URL and handle, saved to `settings` table
- Import screen: `expo-document-picker`; BAS `.json` files parsed and inserted into feed
- Feed screen: activity cards sorted by `started_at`, sport icon, distance, elevation
- `GET /api/wheel/version` server endpoint (public, no auth)
**Import screen (stub):** **Done when:** App launches, user picks a `.json` BAS file, sees it in the Feed. ✅
- `expo-document-picker` for `.fit`, `.gpx`, `.tcx`, `.json` files
- If a `.json` file is picked: parse as BAS detail, insert into `activities` (no timeseries), show in feed
- This lets the feed work before Pyodide is wired up
**Feed screen:**
- List of activities from `activities` table, sorted by `started_at`
- Each card: sport icon, title, date, distance, elevation
**Server (one small addition):**
- `GET /api/wheel/version``{ "version": "0.1.0", "url": "/bincio-0.1.0-py3-none-any.whl" }`
- No auth required; the wheel itself is already public
**Done when:** App launches on a phone, user enters instance URL and handle in
Settings, picks a `.json` BAS file, sees it in the Feed.
--- ---
### Phase 1 — Import via Pyodide ### Phase 0.5 — Remote feed sync ✅
*Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s, see the full ride *Goal: pull all activities from a remote bincio instance into the local feed.*
with map and chart.*
- `POST /api/auth/token` — password login returning a Bearer token (stored in
SQLite; password forgotten immediately after)
- `GET /api/feed` — auth-gated; reads `_merged/index.json` shards and returns
all activity summaries as JSON
- Settings screen: Connect section (password field + Connect button + status)
- Feed screen: **↓ Sync** button and pull-to-refresh; "cloud" badge on remote
activities; `syncFeed()` upserts remote summaries without overwriting local imports
**Done when:** Tap Connect, tap Sync, all instance activities appear in the Feed. ✅
---
### Phase 1 — Local FIT/GPX/TCX extraction via Pyodide
*Goal: pick a FIT/GPX/TCX file, extract it on-device in ~5 s.*
**Requires a Development Build** (`npx expo run:android` via USB, or
`eas build --local`). Expo Go does not support `react-native-webview`.
**Extraction engine (`mobile/extraction/`):** **Extraction engine (`mobile/extraction/`):**
- `PyodideWebView.tsx` — hidden `WebView` rendering an inline HTML page that - `PyodideWebView.tsx` — hidden `WebView` rendering an inline HTML page that
bootstraps Pyodide bootstraps Pyodide
- `wheelCache.ts` — on startup, `GET /api/wheel/version`; if version changed, - `wheelCache.ts` — on startup, `GET /api/wheel/version`; if version changed,
download and store wheel in `expo-file-system` app directory download and store wheel in `expo-file-system` app directory; falls back to
bundled `assets/bincio.whl` for offline / pre-deploy use
- `extractActivity.ts` — encodes file bytes as base64, sends via `postMessage`, - `extractActivity.ts` — encodes file bytes as base64, sends via `postMessage`,
awaits `{ detail, timeseries, geojson }` response awaits `{ detail, timeseries, geojson }` response
- Loading state: "Warming up extractor…" shown only on very first use - Loading state: "Warming up extractor…" shown only on very first use
**Import screen (full):** **Import screen (full):**
- Picks FIT/GPX/TCX, passes to `extractActivity`, stores in SQLite - Picks FIT/GPX/TCX, passes to `extractActivity`, stores result in SQLite
(`detail_json`, `timeseries_json`, `geojson` columns)
- Copies original file to `{documentDirectory}/originals/{source_hash}.{ext}` - Copies original file to `{documentDirectory}/originals/{source_hash}.{ext}`
- Duplicate detection via `source_hash` before extraction - Duplicate detection via `source_hash` before extraction
**Activity detail screen:** **Done when:** Pick a FIT file from the Karoo rides folder, see full stats in
- Stats grid: distance, moving time, elevation gain/loss, avg speed, avg HR, avg power the Feed within ~5 s, including map and elevation profile.
- Map: MapLibre React Native with the GeoJSON track overlaid
- Elevation chart: simple SVG line chart from timeseries data
**Done when:** Drop a FIT file from a Karoo onto the phone, see the full ride
stats, map, and elevation profile within ~5 s.
--- ---
@@ -596,35 +591,60 @@ Bincio within 5 minutes of connecting to WiFi, with no manual action.
--- ---
### Phase 3 — Sync ### Phase 3 — Push sync
*Goal: activities recorded on the phone appear on bincio.org after one tap.* *Goal: locally imported activities appear on the remote instance after one tap.*
**Server additions:** Auth (Bearer token + Connect UI) is already done in Phase 0.5. Remaining work:
- `POST /api/auth/token` — password login returning a Bearer token (long-lived,
stored securely; complements existing cookie auth, does not replace it) **Server:**
- `POST /api/upload` accepting a raw FIT/GPX/TCX file with Bearer token auth —
same as the existing web upload endpoint but token-gated
**App:** **App:**
- Login screen: instance URL + handle + password → stores token - Push button (Settings or Feed header): iterates unsynced local activities
- Sync screen: last sync time, unsynced count, **Push** and **Pull** buttons (`synced_at IS NULL AND origin = 'local'`), uploads original file, marks synced
- Push: iterates unsynced local activities, `POST /api/upload` with original file - Progress indicator per activity; useful for first push with many files
- Pull: fetches `index.json`, downloads missing activities, inserts as `origin = 'remote'`
- Progress indicator per activity (useful for first sync with many files)
**Done when:** Tap **Push**, activities appear on bincio.org with correct stats. **Done when:** Tap **Push**, locally imported activities appear on bincio.org.
--- ---
### Phase 4 — Polish *(ongoing)* ### Phase 4 — Activity detail: map + elevation chart
*Goal: every activity shows a route map and elevation profile.*
**Requires a Development Build**`@maplibre/maplibre-react-native` is a native
module. Same dev build used for Phase 1 covers Phase 4.
**Data strategy (on-demand fetch for remote activities):**
- Local activities (Phase 1 imports): `geojson` and `timeseries_json` stored in
SQLite — map and chart render immediately, no network needed
- Remote activities (Phase 0.5 synced): detail screen fetches
`GET /api/activity/{id}/geojson` and `GET /api/activity/{id}/timeseries`
on first open; both are Bearer-token-gated FastAPI endpoints
**Server additions:**
- `GET /api/activity/{id}/geojson` — reads `_merged/activities/{id}.geojson`
- `GET /api/activity/{id}/timeseries` — reads `activities/{id}.timeseries.json`
**App:**
- `@maplibre/maplibre-react-native`: route drawn as GeoJSON LineLayer over a
dark CartoDB base map; camera auto-fits track bounding box
- `react-native-svg`: elevation area chart from `elevation_m` + `t` arrays;
downsampled to ≤300 points; shows min/max elevation labels
**Done when:** Open any synced or locally imported activity — map and elevation
profile are visible within 1 s (local) or after one network round-trip (remote).
---
### Phase 5 — Polish *(ongoing)*
- **Offline map tiles** — bundle or download an MBTiles file for a region; - **Offline map tiles** — bundle or download an MBTiles file for a region;
MapLibre supports offline tile sources MapLibre supports offline tile sources
- **Batch import** — pick a folder (Strava export, Garmin bulk export); import all - **Batch import** — pick a folder (Strava export, Garmin bulk export); import all
FIT/GPX files found, with progress bar and per-file status FIT/GPX files found, with progress bar and per-file status
- **Share sheet** — on Android, intent filter for incoming `.fit`/`.gpx`/`.tcx` - **Share sheet** — Android intent filter for incoming `.fit`/`.gpx`/`.tcx` files
files from other apps; on iOS, Share Extension already set up in Phase 2 - **Re-extract** — button to re-run Pyodide extraction from the stored original file
- **Home screen widget** — last activity summary or weekly km total
- **Re-extract** — button in activity detail to re-run Pyodide extraction from
the stored original file (picks up algorithm improvements)
--- ---
+207 -74
View File
@@ -1,11 +1,64 @@
import { Camera, GeoJSONSource, Layer, Map } from '@maplibre/maplibre-react-native';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { useEffect, useState } from 'react';
import { useActivity } from '@/db/queries'; import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg';
import { useActivity, useSetting } from '@/db/queries';
const MAP_STYLE = 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
// ── Types ────────────────────────────────────────────────────────────────────
type Timeseries = {
t: number[];
elevation_m: (number | null)[];
lat?: (number | null)[] | null;
lon?: (number | null)[] | null;
};
// ── Screen ───────────────────────────────────────────────────────────────────
export default function ActivityScreen() { export default function ActivityScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter(); const router = useRouter();
const row = useActivity(id); const row = useActivity(id);
const instanceUrl = useSetting('instance_url')?.replace(/\/$/, '') ?? '';
const token = useSetting('api_token') ?? '';
const [geojson, setGeojson] = useState<object | null>(null);
const [timeseries, setTimeseries] = useState<Timeseries | null>(null);
const [loadingMap, setLoadingMap] = useState(false);
const [loadingChart, setLoadingChart] = useState(false);
useEffect(() => {
if (!row) return;
if (row.geojson) {
setGeojson(JSON.parse(row.geojson));
} else if (row.origin === 'remote' && instanceUrl && token) {
setLoadingMap(true);
fetch(`${instanceUrl}/api/activity/${row.id}/geojson`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setGeojson(data); })
.catch(() => {})
.finally(() => setLoadingMap(false));
}
if (row.timeseries_json) {
setTimeseries(JSON.parse(row.timeseries_json));
} else if (row.origin === 'remote' && instanceUrl && token) {
setLoadingChart(true);
fetch(`${instanceUrl}/api/activity/${row.id}/timeseries`, {
headers: { Authorization: `Bearer ${token}` },
})
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setTimeseries(data); })
.catch(() => {})
.finally(() => setLoadingChart(false));
}
}, [row?.id]);
if (!row) { if (!row) {
return ( return (
@@ -16,28 +69,13 @@ export default function ActivityScreen() {
} }
const detail = JSON.parse(row.detail_json); const detail = JSON.parse(row.detail_json);
const km = detail.distance_m != null const km = detail.distance_m != null ? (detail.distance_m / 1000).toFixed(2) : null;
? (detail.distance_m / 1000).toFixed(2) const elev = detail.elevation_gain_m != null ? Math.round(detail.elevation_gain_m) : null;
: null; const elevLoss = detail.elevation_loss_m != null ? Math.round(Math.abs(detail.elevation_loss_m)) : null;
const elev = detail.elevation_gain_m != null const movingTime = detail.moving_time_s != null ? formatDuration(detail.moving_time_s) : null;
? Math.round(detail.elevation_gain_m) const speed = detail.avg_speed_kmh != null ? detail.avg_speed_kmh.toFixed(1) : null;
: null; const hr = detail.avg_hr_bpm != null ? Math.round(detail.avg_hr_bpm) : null;
const elevLoss = detail.elevation_loss_m != null const power = detail.avg_power_w != null ? Math.round(detail.avg_power_w) : null;
? Math.round(Math.abs(detail.elevation_loss_m))
: null;
const movingTime = detail.moving_time_s != null
? formatDuration(detail.moving_time_s)
: null;
const speed = detail.avg_speed_kmh != null
? detail.avg_speed_kmh.toFixed(1)
: null;
const hr = detail.avg_hr_bpm != null
? Math.round(detail.avg_hr_bpm)
: null;
const power = detail.avg_power_w != null
? Math.round(detail.avg_power_w)
: null;
const date = new Date(detail.started_at).toLocaleDateString(undefined, { const date = new Date(detail.started_at).toLocaleDateString(undefined, {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
}); });
@@ -52,29 +90,24 @@ export default function ActivityScreen() {
<Text style={styles.title}>{detail.title}</Text> <Text style={styles.title}>{detail.title}</Text>
<Text style={styles.date}>{date}</Text> <Text style={styles.date}>{date}</Text>
{/* Map placeholder — Phase 1 */} {/* Map */}
<View style={styles.mapPlaceholder}> <RouteMap geojson={geojson} loading={loadingMap} />
<Text style={styles.mapPlaceholderText}>Map · Phase 1</Text>
</View>
{/* Stats grid */} {/* Stats grid */}
<View style={styles.grid}> <View style={styles.grid}>
{km && <StatCell label="Distance" value={km} unit="km" />} {km && <StatCell label="Distance" value={km} unit="km" />}
{movingTime && <StatCell label="Moving time" value={movingTime} unit="" />} {movingTime && <StatCell label="Moving time" value={movingTime} unit="" />}
{elev != null && <StatCell label="Elevation gain" value={String(elev)} unit="m" />} {elev != null && <StatCell label="Elev gain" value={String(elev)} unit="m" />}
{elevLoss != null && <StatCell label="Elevation loss" value={String(elevLoss)} unit="m" />} {elevLoss != null && <StatCell label="Elev loss" value={String(elevLoss)} unit="m" />}
{speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>} {speed && <StatCell label="Avg speed" value={speed} unit="km/h"/>}
{hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />} {hr && <StatCell label="Avg HR" value={String(hr)} unit="bpm" />}
{power && <StatCell label="Avg power" value={String(power)} unit="W" />} {power && <StatCell label="Avg power" value={String(power)} unit="W" />}
</View> </View>
{/* Elevation chart placeholder — Phase 1 */} {/* Elevation chart */}
{row.timeseries_json && ( <ElevationChart timeseries={timeseries} loading={loadingChart} />
<View style={styles.chartPlaceholder}>
<Text style={styles.mapPlaceholderText}>Elevation chart · Phase 1</Text>
</View>
)}
{/* Meta */}
<View style={styles.meta}> <View style={styles.meta}>
<MetaRow label="Source" value={detail.source ?? '—'} /> <MetaRow label="Source" value={detail.source ?? '—'} />
<MetaRow label="Device" value={detail.device ?? '—'} /> <MetaRow label="Device" value={detail.device ?? '—'} />
@@ -85,6 +118,133 @@ export default function ActivityScreen() {
); );
} }
// ── Map ───────────────────────────────────────────────────────────────────────
function RouteMap({ geojson, loading }: { geojson: object | null; loading: boolean }) {
if (loading) {
return (
<View style={styles.mapPlaceholder}>
<ActivityIndicator color="#60a5fa" />
</View>
);
}
if (!geojson) return null;
const bounds = geoJsonBounds(geojson);
return (
<View style={styles.mapContainer}>
<Map
style={styles.map}
mapStyle={MAP_STYLE}
dragPan={false}
touchZoom={false}
touchPitch={false}
touchRotate={false}
>
{bounds && (
<Camera
initialViewState={{
bounds,
padding: { top: 24, bottom: 24, left: 24, right: 24 },
}}
/>
)}
<GeoJSONSource id="route" data={geojson as GeoJSON.FeatureCollection}>
<Layer
type="line"
id="route-line"
paint={{ 'line-color': '#60a5fa', 'line-width': 3 }}
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
/>
</GeoJSONSource>
</Map>
</View>
);
}
// ── Elevation chart ───────────────────────────────────────────────────────────
function ElevationChart({ timeseries, loading }: { timeseries: Timeseries | null; loading: boolean }) {
const W = 340;
const H = 100;
const PAD = 4;
if (loading) {
return (
<View style={styles.chartPlaceholder}>
<ActivityIndicator color="#60a5fa" />
</View>
);
}
if (!timeseries) return null;
const raw = timeseries.elevation_m;
if (!raw || raw.length < 2) return null;
// Downsample to ≤300 points
const step = Math.max(1, Math.floor(raw.length / 300));
const times = timeseries.t.filter((_, i) => i % step === 0);
const eles = raw.filter((_, i) => i % step === 0).map(v => v ?? 0);
const minE = Math.min(...eles);
const maxE = Math.max(...eles);
const range = maxE - minE || 1;
const maxT = times[times.length - 1] || 1;
const x = (t: number) => PAD + (t / maxT) * (W - PAD * 2);
const y = (e: number) => PAD + (1 - (e - minE) / range) * (H - PAD * 2);
const pts = times.map((t, i) => `${x(t).toFixed(1)},${y(eles[i]).toFixed(1)}`);
const linePath = `M ${pts.join(' L ')}`;
const areaPath = `M ${x(times[0])},${H} L ${pts.join(' L ')} L ${x(maxT)},${H} Z`;
return (
<View style={styles.chartContainer}>
<Text style={styles.chartLabel}>{Math.round(maxE)} m</Text>
<Svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
<Defs>
<LinearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
<Stop offset="0" stopColor="#60a5fa" stopOpacity="0.35" />
<Stop offset="1" stopColor="#60a5fa" stopOpacity="0.02" />
</LinearGradient>
</Defs>
<Path d={areaPath} fill="url(#grad)" />
<Path d={linePath} fill="none" stroke="#60a5fa" strokeWidth="1.5" strokeLinejoin="round" />
</Svg>
<Text style={styles.chartLabel}>{Math.round(minE)} m</Text>
</View>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// Returns [west, south, east, north] per LngLatBounds spec
function geoJsonBounds(gj: object): [number, number, number, number] | null {
const coords: [number, number][] = [];
function collect(obj: unknown) {
if (!obj || typeof obj !== 'object') return;
const o = obj as Record<string, unknown>;
if (o.type === 'Feature') { collect(o.geometry); return; }
if (o.type === 'FeatureCollection') { (o.features as unknown[]).forEach(collect); return; }
if (o.type === 'LineString') { coords.push(...(o.coordinates as [number, number][])); return; }
if (o.type === 'MultiLineString') { (o.coordinates as [number, number][][]).forEach(c => coords.push(...c)); return; }
}
collect(gj);
if (!coords.length) return null;
const lons = coords.map(c => c[0]);
const lats = coords.map(c => c[1]);
return [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];
}
function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) { function StatCell({ label, value, unit }: { label: string; value: string; unit: string }) {
return ( return (
<View style={styles.statCell}> <View style={styles.statCell}>
@@ -106,13 +266,7 @@ function MetaRow({ label, value }: { label: string; value: string }) {
); );
} }
function formatDuration(seconds: number): string { // ── Styles ────────────────────────────────────────────────────────────────────
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#09090b' }, container: { flex: 1, backgroundColor: '#09090b' },
@@ -124,41 +278,20 @@ const styles = StyleSheet.create({
sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 }, sport: { color: '#71717a', fontSize: 12, fontWeight: '600', letterSpacing: 0.8, paddingHorizontal: 16, marginBottom: 4 },
title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 }, title: { color: '#f4f4f5', fontSize: 22, fontWeight: '700', paddingHorizontal: 16, marginBottom: 4 },
date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 }, date: { color: '#71717a', fontSize: 13, paddingHorizontal: 16, marginBottom: 16 },
mapPlaceholder: { mapContainer: { height: 220, marginBottom: 16, borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a' },
height: 200, backgroundColor: '#18181b', map: { flex: 1 },
alignItems: 'center', justifyContent: 'center', mapPlaceholder: { height: 220, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', marginBottom: 16 },
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a', chartContainer: { marginHorizontal: 16, marginBottom: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 12, alignItems: 'flex-start' },
marginBottom: 16, chartPlaceholder: { height: 120, backgroundColor: '#18181b', alignItems: 'center', justifyContent: 'center', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', marginHorizontal: 16, marginBottom: 16 },
}, chartLabel: { color: '#3f3f46', fontSize: 10, marginBottom: 2 },
chartPlaceholder: { grid: { flexDirection: 'row', flexWrap: 'wrap', paddingHorizontal: 12, gap: 8, marginBottom: 16 },
height: 120, backgroundColor: '#18181b', statCell: { backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a', padding: 14, width: '47%' },
alignItems: 'center', justifyContent: 'center',
borderTopWidth: 1, borderBottomWidth: 1, borderColor: '#27272a',
marginBottom: 16,
},
mapPlaceholderText: { color: '#3f3f46', fontSize: 13 },
grid: {
flexDirection: 'row', flexWrap: 'wrap',
paddingHorizontal: 12, gap: 8, marginBottom: 16,
},
statCell: {
backgroundColor: '#18181b', borderRadius: 10,
borderWidth: 1, borderColor: '#27272a',
padding: 14, width: '47%',
},
statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 }, statValueRow: { flexDirection: 'row', alignItems: 'baseline', gap: 4, marginBottom: 4 },
statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' }, statValue: { color: '#f4f4f5', fontSize: 24, fontWeight: '700' },
statUnit: { color: '#71717a', fontSize: 13 }, statUnit: { color: '#71717a', fontSize: 13 },
statLabel: { color: '#71717a', fontSize: 12 }, statLabel: { color: '#71717a', fontSize: 12 },
meta: { meta: { marginHorizontal: 16, backgroundColor: '#18181b', borderRadius: 10, borderWidth: 1, borderColor: '#27272a' },
marginHorizontal: 16, backgroundColor: '#18181b', metaRow: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 14, paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#27272a' },
borderRadius: 10, borderWidth: 1, borderColor: '#27272a',
},
metaRow: {
flexDirection: 'row', justifyContent: 'space-between',
paddingHorizontal: 14, paddingVertical: 10,
borderBottomWidth: 1, borderBottomColor: '#27272a',
},
metaLabel: { color: '#71717a', fontSize: 13 }, metaLabel: { color: '#71717a', fontSize: 13 },
metaValue: { color: '#a1a1aa', fontSize: 13 }, metaValue: { color: '#a1a1aa', fontSize: 13 },
}); });
+157
View File
@@ -26,6 +26,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "~15.8.0",
"react-native-webview": "13.15.0" "react-native-webview": "13.15.0"
}, },
"devDependencies": { "devDependencies": {
@@ -3555,6 +3556,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/bplist-creator": { "node_modules/bplist-creator": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz",
@@ -4046,6 +4053,56 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4186,6 +4243,61 @@
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.4.7", "version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -4254,6 +4366,18 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/env-editor": { "node_modules/env-editor": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz",
@@ -6477,6 +6601,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/memoize-one": { "node_modules/memoize-one": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -7007,6 +7137,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nullthrows": { "node_modules/nullthrows": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz",
@@ -7768,6 +7910,21 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/react-native-svg": {
"version": "15.8.0",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.8.0.tgz",
"integrity": "sha512-KHJzKpgOjwj1qeZzsBjxNdoIgv2zNCO9fVcoq2TEhTRsVV5DGTZ9JzUZwybd7q4giT/H3RdtqC3u44dWdO0Ffw==",
"license": "MIT",
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
"warn-once": "0.1.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-webview": { "node_modules/react-native-webview": {
"version": "13.15.0", "version": "13.15.0",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz",
+1
View File
@@ -27,6 +27,7 @@
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0", "react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0", "react-native-screens": "~4.16.0",
"react-native-svg": "~15.8.0",
"react-native-webview": "13.15.0", "react-native-webview": "13.15.0",
"@maplibre/maplibre-react-native": "~11.0.0" "@maplibre/maplibre-react-native": "~11.0.0"
}, },