planning
This commit is contained in:
@@ -28,5 +28,11 @@ bincio_data/
|
|||||||
.env
|
.env
|
||||||
extract_config.yaml
|
extract_config.yaml
|
||||||
|
|
||||||
|
# Capacitor native projects
|
||||||
|
# Commit these if you want to track native code changes;
|
||||||
|
# omit these lines if you regenerate them from `npx cap add`
|
||||||
|
site/android/
|
||||||
|
site/ios/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+445
@@ -0,0 +1,445 @@
|
|||||||
|
# BincioActivity — Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
BincioActivity is a two-stage pipeline that turns raw activity files (GPX, FIT, TCX) into a self-hosted static website. There is no database and no application server — everything is files.
|
||||||
|
|
||||||
|
```
|
||||||
|
Raw files ──► BAS data store ──► Static site
|
||||||
|
extract render
|
||||||
|
(Python) (Astro/Node)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 1 — Extract
|
||||||
|
|
||||||
|
`bincio extract` reads your activity files and writes a **BAS (BincioActivity Schema)** data store: a directory of JSON files.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Input
|
||||||
|
A[GPX files]
|
||||||
|
B[FIT files]
|
||||||
|
C[TCX files]
|
||||||
|
D[activities.csv\nStrava metadata]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Extract ["bincio extract (Python)"]
|
||||||
|
E[Parse]
|
||||||
|
F[Compute metrics]
|
||||||
|
G[Deduplicate]
|
||||||
|
H[Write BAS JSON]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Output ["BAS data store ~/bincio_data/"]
|
||||||
|
I[activities/\n*.json *.geojson]
|
||||||
|
J[index.json\nall summaries]
|
||||||
|
K[athlete.json\nzones + records]
|
||||||
|
end
|
||||||
|
|
||||||
|
A & B & C --> E
|
||||||
|
D --> E
|
||||||
|
E --> F --> G --> H
|
||||||
|
H --> I & J & K
|
||||||
|
```
|
||||||
|
|
||||||
|
The data store is **immutable extract output** — never edited directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stage 2 — Render
|
||||||
|
|
||||||
|
`bincio render` merges any user edits, then runs an Astro build to produce a static site.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph DataStore ["BAS data store"]
|
||||||
|
A[activities/*.json]
|
||||||
|
B[edits/*.md\nsidecar files]
|
||||||
|
C[edits/images/]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Render ["bincio render (Astro/Node)"]
|
||||||
|
D[merge_all\napply sidecars]
|
||||||
|
E[Astro build\nSSG]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Site ["Static site dist/"]
|
||||||
|
F[HTML pages]
|
||||||
|
G[JS bundles]
|
||||||
|
H[data/\nBAS JSON]
|
||||||
|
end
|
||||||
|
|
||||||
|
A & B & C --> D --> E --> F & G & H
|
||||||
|
```
|
||||||
|
|
||||||
|
The rendered site is **fully static**: no server needed to serve it. GitHub Pages, Netlify, nginx — all work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The edit flow
|
||||||
|
|
||||||
|
When `bincio edit` is running locally, an **Edit** button appears in the site. It opens a drawer that writes sidecar files without touching the immutable extract output.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User
|
||||||
|
participant Site as Static site\n(Astro dev server)
|
||||||
|
participant EditServer as bincio edit server\n(FastAPI, port 4041)
|
||||||
|
participant DataStore as BAS data store
|
||||||
|
|
||||||
|
User->>Site: clicks Edit on activity
|
||||||
|
Site->>EditServer: GET /api/activity/{id}
|
||||||
|
EditServer->>DataStore: reads .json + sidecar .md
|
||||||
|
EditServer-->>Site: current values
|
||||||
|
|
||||||
|
User->>Site: edits title / description / sport
|
||||||
|
Site->>EditServer: POST /api/activity/{id}
|
||||||
|
EditServer->>DataStore: writes edits/{id}.md
|
||||||
|
EditServer->>DataStore: runs merge_all()
|
||||||
|
EditServer-->>Site: ok
|
||||||
|
|
||||||
|
Note over Site: feed reloads with merged data
|
||||||
|
```
|
||||||
|
|
||||||
|
The edit server is **never public-facing** — it only binds to `127.0.0.1` and is only enabled when `PUBLIC_EDIT_URL` is set in `site/.env`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data sources
|
||||||
|
|
||||||
|
There are three ways activities enter the data store:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Sources
|
||||||
|
A[📁 Local files\nGPX / FIT / TCX]
|
||||||
|
B[🟠 Strava API\nOAuth sync]
|
||||||
|
C[📱 Convert page\nPyodide in-browser]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph EditServer ["bincio edit server\n(when running)"]
|
||||||
|
D[POST /api/upload]
|
||||||
|
E[POST /api/strava/sync]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph CLI ["bincio extract CLI"]
|
||||||
|
F[batch extract]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph DataStore ["BAS data store"]
|
||||||
|
G[activities/*.json]
|
||||||
|
H[index.json]
|
||||||
|
end
|
||||||
|
|
||||||
|
A -->|bulk| F --> G & H
|
||||||
|
A -->|single file via UI| D --> G & H
|
||||||
|
B --> E --> G & H
|
||||||
|
C -->|download JSON| A
|
||||||
|
C -->|POST to edit server\nif configured| D
|
||||||
|
```
|
||||||
|
|
||||||
|
**Local files → CLI** is the primary path for bulk imports.
|
||||||
|
**Strava sync** and **file upload** go through the edit server for single activities.
|
||||||
|
**Convert page** runs the extract pipeline in-browser — output is either downloaded or sent to the edit server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The convert page
|
||||||
|
|
||||||
|
`/convert/` is a page in the static site that runs the full extract pipeline **inside the browser** using Pyodide (Python compiled to WebAssembly).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
actor User as User\n(on phone or desktop)
|
||||||
|
participant Page as /convert/ page\n(browser)
|
||||||
|
participant Pyodide as Pyodide runtime\n(Python in WASM)
|
||||||
|
participant EditServer as bincio edit server\n(optional)
|
||||||
|
|
||||||
|
User->>Page: opens /convert/
|
||||||
|
Page->>Pyodide: load Pyodide + packages\n(lxml, fitdecode, bincio wheel)
|
||||||
|
Note over Page,Pyodide: ~8MB, cached after first visit
|
||||||
|
|
||||||
|
User->>Page: selects GPX / FIT / TCX file
|
||||||
|
Page->>Pyodide: write file to virtual FS\nrun parse → metrics → write
|
||||||
|
Pyodide-->>Page: BAS JSON + GeoJSON strings
|
||||||
|
|
||||||
|
alt Download
|
||||||
|
Page->>User: download activity.json\n(+ activity.geojson if GPS)
|
||||||
|
else Save to bincio
|
||||||
|
Page->>EditServer: POST /api/upload
|
||||||
|
EditServer-->>Page: {id}
|
||||||
|
Page->>User: redirect to activity page
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Save to bincio"** only appears when the page is served from an instance with `PUBLIC_EDIT_URL` set — the same flag that enables the edit drawer. For anyone else, downloading the JSON is the output.
|
||||||
|
|
||||||
|
### Why the convert page belongs to the instance
|
||||||
|
|
||||||
|
The convert page is part of the static site build — it uses the same styling, the same Pyodide wheel, and (if enabled) the same edit server. It is a tool **for the people who use this instance**:
|
||||||
|
|
||||||
|
- The instance owner uses it to convert files on mobile without needing a computer
|
||||||
|
- (Future) other users of a multi-user instance use it to upload their own activities
|
||||||
|
|
||||||
|
It is not a standalone public tool (though technically anyone with the URL could use the download path, since Pyodide runs locally in their browser).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Federation
|
||||||
|
|
||||||
|
Federation is how users follow each other across different bincio instances without any central server.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph InstanceA ["Instance A — bincio.alice.com"]
|
||||||
|
A1[BAS data store]
|
||||||
|
A2[Static site]
|
||||||
|
A1 --> A2
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph InstanceB ["Instance B — bincio.bob.com"]
|
||||||
|
B1[BAS data store]
|
||||||
|
B2[Static site]
|
||||||
|
B1 --> B2
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Browser ["Bob's browser"]
|
||||||
|
C[Feed]
|
||||||
|
end
|
||||||
|
|
||||||
|
A2 -->|BAS JSON URL| C
|
||||||
|
B2 -->|BAS JSON URL| C
|
||||||
|
|
||||||
|
Note1["Bob adds Alice's index.json URL\nto his followed feeds.\nNo accounts. No central server.\nJust a URL."]
|
||||||
|
```
|
||||||
|
|
||||||
|
Federation is a planned feature — the data format (BAS JSON) is designed for it, but the site UI doesn't yet support adding followed feeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mobile app (Capacitor)
|
||||||
|
|
||||||
|
The existing Astro/Svelte site is wrapped in a **Capacitor** native shell to produce iOS and Android apps. No code is rewritten — Capacitor provides the native APIs that a plain browser PWA cannot.
|
||||||
|
|
||||||
|
### What Capacitor adds over a browser PWA
|
||||||
|
|
||||||
|
| Capability | Browser PWA | Capacitor app |
|
||||||
|
|---|---|---|
|
||||||
|
| Background GPS (iOS) | ✗ killed by OS | ✅ native entitlement |
|
||||||
|
| Background GPS (Android) | ⚠️ limited | ✅ foreground service |
|
||||||
|
| Filesystem access | ✗ sandboxed | ✅ full device storage |
|
||||||
|
| Install without App Store | ✅ | iOS: App Store / TestFlight |
|
||||||
|
| Local HTTP server | ✗ | ✅ (future, via native plugin) |
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph App ["Capacitor app (iOS / Android)"]
|
||||||
|
subgraph WebView ["WebView — existing Astro/Svelte site"]
|
||||||
|
A[/record/ — GPS recorder]
|
||||||
|
B[/convert/ — Pyodide converter]
|
||||||
|
C[/feed/, /activity/, etc.]
|
||||||
|
end
|
||||||
|
subgraph Native ["Native layer"]
|
||||||
|
D[Geolocation plugin\nbackground GPS]
|
||||||
|
E[Filesystem plugin\nread/write device storage]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Cloud ["Cloud instance (optional)"]
|
||||||
|
F[Static site]
|
||||||
|
G[bincio edit server]
|
||||||
|
end
|
||||||
|
|
||||||
|
A -->|JS bridge| D
|
||||||
|
B -->|JS bridge| E
|
||||||
|
B -->|POST /api/import-bas| G
|
||||||
|
C --> F
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile workflows
|
||||||
|
|
||||||
|
**Workflow 1 — Record on phone, save to cloud**
|
||||||
|
```
|
||||||
|
Phone app: /record/ → GPS recording (Capacitor Geolocation)
|
||||||
|
→ export GPX → /convert/
|
||||||
|
/convert/ → Pyodide runs extract pipeline in WebView
|
||||||
|
→ POST /api/import-bas to cloud edit server
|
||||||
|
Cloud instance → saves activity, site updates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workflow 2 — Record on phone, save locally (offline)**
|
||||||
|
```
|
||||||
|
Phone app: /record/ → GPS recording
|
||||||
|
→ export GPX → /convert/
|
||||||
|
/convert/ → Pyodide converts in WebView
|
||||||
|
→ download BAS JSON to device storage
|
||||||
|
(upload to cloud later when online)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workflow 3 — Import from OsmAnd / Organic Maps**
|
||||||
|
```
|
||||||
|
OsmAnd / Organic Maps → exports GPX to device storage
|
||||||
|
Phone app: /convert/ → user picks GPX file
|
||||||
|
→ Pyodide converts in WebView
|
||||||
|
→ save to cloud or download
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workflow 4 — Fully offline on phone (future)**
|
||||||
|
```
|
||||||
|
Phone app: /record/ → GPS recording (native Geolocation)
|
||||||
|
→ export GPX → /convert/
|
||||||
|
/convert/ → Pyodide converts (loaded from local cache)
|
||||||
|
→ writes BAS JSON to device storage (Filesystem plugin)
|
||||||
|
Local Node server → serves BAS JSON from device storage to WebView
|
||||||
|
WebView → feed + activity pages read from local server
|
||||||
|
→ edit drawer saves sidecars to device storage
|
||||||
|
```
|
||||||
|
See "Fully offline — missing pieces" below for implementation status.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd site
|
||||||
|
|
||||||
|
# First time: initialise native projects
|
||||||
|
npx cap add android # creates site/android/
|
||||||
|
npx cap add ios # creates site/ios/ — requires macOS + Xcode
|
||||||
|
|
||||||
|
# Daily workflow
|
||||||
|
npm run cap:sync # build Astro + sync to native projects
|
||||||
|
npm run cap:android # build + open Android Studio
|
||||||
|
npm run cap:ios # build + open Xcode
|
||||||
|
```
|
||||||
|
|
||||||
|
### The bincio Python wheel
|
||||||
|
|
||||||
|
The `/convert/` page loads the extract pipeline via **Pyodide** (Python compiled to WebAssembly). The pipeline is bundled as `site/public/bincio.whl` — a standard Python wheel that Pyodide loads via `micropip`.
|
||||||
|
|
||||||
|
To rebuild the wheel after changing the extract code:
|
||||||
|
```bash
|
||||||
|
# from the repo root
|
||||||
|
uv build --wheel
|
||||||
|
cp dist/bincio-*.whl site/public/bincio.whl
|
||||||
|
```
|
||||||
|
|
||||||
|
Pyodide loads these packages on first visit (cached by the browser after that):
|
||||||
|
- `lxml`, `pyyaml` — prebuilt Pyodide packages (~2 MB)
|
||||||
|
- `fitdecode`, `gpxpy`, `rdp` — pure Python, installed via micropip
|
||||||
|
- `bincio.whl` — our wheel, ~150 KB
|
||||||
|
|
||||||
|
Total cold-start download: ~10 MB. Subsequent visits: instant (all cached).
|
||||||
|
|
||||||
|
### Fully offline — missing pieces
|
||||||
|
|
||||||
|
Five things need to be in place before Workflow 4 works end-to-end.
|
||||||
|
|
||||||
|
#### 1. Pyodide available offline
|
||||||
|
|
||||||
|
Currently loads from CDN on first use. Two options:
|
||||||
|
|
||||||
|
- **Service worker cache** *(recommended)* — on first online visit the service worker caches all Pyodide assets; subsequent visits work offline. Standard PWA pattern, no app size increase.
|
||||||
|
- **Bundle in app assets** — copy `pyodide/` into `site/public/` at build time. Adds ~25 MB to the app but works with zero network dependency. Use if targeting environments with no initial connectivity.
|
||||||
|
|
||||||
|
Status: not yet implemented.
|
||||||
|
|
||||||
|
#### 2. Write converted activities to device storage
|
||||||
|
|
||||||
|
`@capacitor/filesystem` is already installed. After Pyodide converts a file, instead of (or in addition to) POSTing to the edit server, the convert page should:
|
||||||
|
1. Write `{id}.json` and `{id}.geojson` to a local `activities/` directory via the Filesystem plugin
|
||||||
|
2. Append the summary to a local `index.json`
|
||||||
|
|
||||||
|
This is self-contained JS work — no native changes needed.
|
||||||
|
|
||||||
|
Status: not yet implemented.
|
||||||
|
|
||||||
|
#### 3. Serve local activity data to the WebView *(the core problem)*
|
||||||
|
|
||||||
|
The Astro site reads `/data/index.json` and `/data/activities/*.json` via `fetch()`. In the current setup these come from either bundled static assets (baked in at build time) or a remote server. There is no built-in mechanism for a WebView to dynamically serve locally-stored files at those URLs.
|
||||||
|
|
||||||
|
Two approaches:
|
||||||
|
|
||||||
|
**Option A — Service worker interception** *(web-only, no native code)*
|
||||||
|
A service worker intercepts `fetch('/data/*')` and responds with data from IndexedDB. No native plugin required. Limitation: iOS WKWebView has historically restricted service worker scope in Capacitor apps, though this has improved in recent iOS versions. Best option if cross-platform parity is not critical.
|
||||||
|
|
||||||
|
**Option B — Local HTTP server via `capacitor-nodejs`** *(recommended for iOS reliability)*
|
||||||
|
[`capacitor-nodejs`](https://github.com/hampoelz/Capacitor-NodeJS) embeds a Node.js runtime inside the Capacitor app. A small Express/Fastify server:
|
||||||
|
- Serves the static site assets (or proxies to the WebView asset loader)
|
||||||
|
- Serves BAS JSON from device storage at `/data/*`
|
||||||
|
- Handles the edit/save API (`/api/import-bas`, `/api/activity/{id}`, etc.)
|
||||||
|
The WebView points to `http://localhost:PORT/`. Reliable on both iOS and Android.
|
||||||
|
|
||||||
|
Status: not yet implemented. Option B is the recommended path.
|
||||||
|
|
||||||
|
#### 4. Port edit server API to local JS
|
||||||
|
|
||||||
|
Once Option B above is in place, the Node.js server needs to implement the same API surface as the current FastAPI edit server — at minimum:
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `POST /api/import-bas` | Save converted activity to device storage |
|
||||||
|
| `GET /api/activity/{id}` | Read activity + sidecar |
|
||||||
|
| `POST /api/activity/{id}` | Write sidecar to device storage |
|
||||||
|
| `GET /data/index.json` | Serve merged index from device storage |
|
||||||
|
| `GET /data/activities/*` | Serve individual activity files |
|
||||||
|
|
||||||
|
The sidecar merge logic (`merge_all`) can either be reimplemented in JS (~150 lines) or invoked via Pyodide (already present in the app).
|
||||||
|
|
||||||
|
Strava sync and image upload require network access by definition — they are out of scope for offline mode.
|
||||||
|
|
||||||
|
Status: not yet implemented.
|
||||||
|
|
||||||
|
#### 5. Summary
|
||||||
|
|
||||||
|
| Step | Effort | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Pyodide service worker cache | Medium | Not started |
|
||||||
|
| Write to Filesystem after convert | Easy | Not started |
|
||||||
|
| Local Node.js server (`capacitor-nodejs`) | Hard | Not started |
|
||||||
|
| Port edit API to local JS | Medium | Not started |
|
||||||
|
| Sidecar merge in JS (or via Pyodide) | Medium | Not started |
|
||||||
|
|
||||||
|
The local Node.js server (step 3B) is the critical path item — it unblocks steps 4 and makes the feed dynamic. Steps 1 and 2 are independent and can be done in any order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### The `/api/import-bas` endpoint
|
||||||
|
|
||||||
|
The `/convert/` page sends pre-converted BAS JSON directly to the edit server, avoiding the need to re-parse a file on the server side. The endpoint:
|
||||||
|
1. Validates the `id` field
|
||||||
|
2. Writes `activities/{id}.json` (and `.geojson` if provided)
|
||||||
|
3. Rebuilds `index.json`
|
||||||
|
4. Runs `merge_all()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Local ["Local machine"]
|
||||||
|
A[bincio extract]
|
||||||
|
B[bincio edit\noptional]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph VPS ["VPS or static host"]
|
||||||
|
C[Static site\ndist/]
|
||||||
|
D[nginx]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Phone ["Phone"]
|
||||||
|
E[OsmAnd /\nOrganic Maps]
|
||||||
|
F[Browser:\n/convert/]
|
||||||
|
end
|
||||||
|
|
||||||
|
A -->|rsync / CI| C
|
||||||
|
C --> D
|
||||||
|
E -->|GPX export| F
|
||||||
|
F -->|upload| B
|
||||||
|
B --> A
|
||||||
|
```
|
||||||
|
|
||||||
|
The edit server is **always local** in the single-user setup. In a future multi-user deployment on a VPS, it would run as a service alongside nginx.
|
||||||
@@ -607,6 +607,61 @@ async def upload_activity(file: UploadFile = File(...)) -> JSONResponse:
|
|||||||
return JSONResponse({"ok": True, "id": activity_id})
|
return JSONResponse({"ok": True, "id": activity_id})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/import-bas")
|
||||||
|
async def import_bas(payload: dict[str, Any]) -> JSONResponse:
|
||||||
|
"""Accept a pre-converted BAS detail JSON (from the /convert/ page) and save it."""
|
||||||
|
dd = _get_data_dir()
|
||||||
|
detail = payload.get("detail")
|
||||||
|
geojson = payload.get("geojson")
|
||||||
|
|
||||||
|
if not isinstance(detail, dict) or not detail.get("id"):
|
||||||
|
raise HTTPException(400, "Missing or invalid 'detail' field")
|
||||||
|
|
||||||
|
activity_id = detail["id"]
|
||||||
|
_check_id(activity_id)
|
||||||
|
|
||||||
|
acts_dir = dd / "activities"
|
||||||
|
acts_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
dest = acts_dir / f"{activity_id}.json"
|
||||||
|
if dest.exists():
|
||||||
|
raise HTTPException(409, f"Activity already exists: {activity_id}")
|
||||||
|
|
||||||
|
dest.write_text(json.dumps(detail, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
|
if geojson:
|
||||||
|
(acts_dir / f"{activity_id}.geojson").write_text(
|
||||||
|
json.dumps(geojson, indent=2, ensure_ascii=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rebuild index
|
||||||
|
index_path = dd / "index.json"
|
||||||
|
if index_path.exists():
|
||||||
|
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||||
|
else:
|
||||||
|
index_data = {"owner": {"handle": "unknown"}, "activities": []}
|
||||||
|
owner = index_data.get("owner", {})
|
||||||
|
existing = {s["id"]: s for s in index_data.get("activities", [])}
|
||||||
|
|
||||||
|
# Build a minimal summary from the detail
|
||||||
|
summary_keys = [
|
||||||
|
"id", "title", "sport", "sub_sport", "started_at", "distance_m",
|
||||||
|
"duration_s", "moving_time_s", "elevation_gain_m", "avg_speed_kmh",
|
||||||
|
"avg_hr_bpm", "avg_cadence_rpm", "avg_power_w", "privacy",
|
||||||
|
"detail_url", "track_url", "preview_coords", "highlight", "duplicate_of",
|
||||||
|
]
|
||||||
|
summary = {k: detail[k] for k in summary_keys if k in detail}
|
||||||
|
existing[activity_id] = summary
|
||||||
|
|
||||||
|
from bincio.extract.writer import write_index
|
||||||
|
write_index(list(existing.values()), dd, owner)
|
||||||
|
|
||||||
|
from bincio.render.merge import merge_all
|
||||||
|
merge_all(dd)
|
||||||
|
|
||||||
|
return JSONResponse({"ok": True, "id": activity_id})
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/activity/{activity_id}/images/{filename}")
|
@app.delete("/api/activity/{activity_id}/images/{filename}")
|
||||||
async def delete_image(activity_id: str, filename: str) -> JSONResponse:
|
async def delete_image(activity_id: str, filename: str) -> JSONResponse:
|
||||||
dd = _get_data_dir()
|
dd = _get_data_dir()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.bincio.activity',
|
||||||
|
appName: 'BincioActivity',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
// Use https scheme on Android so cookies and service workers behave like a real origin
|
||||||
|
androidScheme: 'https',
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
Geolocation: {
|
||||||
|
// iOS: keys are added in ios/App/App/Info.plist by `cap add ios`
|
||||||
|
// Android: permissions added in AndroidManifest.xml by `cap add android`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Generated
+1011
-4
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -7,11 +7,21 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro",
|
||||||
|
"cap:sync": "astro build && npx cap sync",
|
||||||
|
"cap:android": "astro build && npx cap sync && npx cap open android",
|
||||||
|
"cap:ios": "astro build && npx cap sync && npx cap open ios"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/svelte": "^7.0.0",
|
"@astrojs/svelte": "^7.0.0",
|
||||||
"@astrojs/tailwind": "^5.1.0",
|
"@astrojs/tailwind": "^5.1.0",
|
||||||
|
"@capacitor/android": "^8.3.0",
|
||||||
|
"@capacitor/app": "^8.1.0",
|
||||||
|
"@capacitor/cli": "^8.3.0",
|
||||||
|
"@capacitor/core": "^8.3.0",
|
||||||
|
"@capacitor/filesystem": "^8.1.2",
|
||||||
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
"@capacitor/ios": "^8.3.0",
|
||||||
"@observablehq/plot": "^0.6.0",
|
"@observablehq/plot": "^0.6.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"astro": "^5.0.0",
|
"astro": "^5.0.0",
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
<a href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
<a href={baseUrl} class="text-sm text-zinc-400 hover:text-white transition-colors">Feed</a>
|
||||||
<a href={`${baseUrl}stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
<a href={`${baseUrl}stats/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Stats</a>
|
||||||
<a href={`${baseUrl}athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
<a href={`${baseUrl}athlete/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Athlete</a>
|
||||||
|
<a href={`${baseUrl}record/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Record</a>
|
||||||
|
<a href={`${baseUrl}convert/`} class="text-sm text-zinc-400 hover:text-white transition-colors">Convert</a>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
{editUrl && (
|
{editUrl && (
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
const editUrl = import.meta.env.PUBLIC_EDIT_URL ?? '';
|
||||||
|
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||||
|
---
|
||||||
|
<Base title="Convert activity — BincioActivity">
|
||||||
|
<div class="max-w-lg mx-auto">
|
||||||
|
<h1 class="text-xl font-bold text-white mb-1">Convert activity</h1>
|
||||||
|
<p class="text-sm text-zinc-400 mb-6">
|
||||||
|
Convert a GPX, FIT, or TCX file to BAS format — entirely in your browser, nothing uploaded.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Step 1: pick file -->
|
||||||
|
<div id="step-pick">
|
||||||
|
<div
|
||||||
|
id="conv-drop"
|
||||||
|
class="border-2 border-dashed border-zinc-700 rounded-xl p-10 text-center text-zinc-500 text-sm cursor-pointer hover:border-zinc-500 hover:text-zinc-300 transition-colors"
|
||||||
|
>
|
||||||
|
<div class="text-4xl mb-3">📁</div>
|
||||||
|
<div id="conv-drop-label">Drop a FIT, GPX, or TCX file<br/>or tap to browse</div>
|
||||||
|
<input id="conv-input" type="file" accept=".fit,.gpx,.tcx,.fit.gz,.gpx.gz,.tcx.gz" class="hidden" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: loading / converting -->
|
||||||
|
<div id="step-loading" style="display:none" class="text-center py-12">
|
||||||
|
<div id="conv-spinner" class="text-4xl mb-4 animate-spin inline-block">⚙</div>
|
||||||
|
<p id="conv-loading-msg" class="text-zinc-400 text-sm">Loading converter…</p>
|
||||||
|
<div class="mt-4 w-full bg-zinc-800 rounded-full h-1.5">
|
||||||
|
<div id="conv-progress-bar" class="bg-blue-500 h-1.5 rounded-full transition-all duration-300" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: result -->
|
||||||
|
<div id="step-result" style="display:none">
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-5 mb-4">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<span id="res-sport-icon" class="text-2xl"></span>
|
||||||
|
<div>
|
||||||
|
<h2 id="res-title" class="font-semibold text-white"></h2>
|
||||||
|
<p id="res-date" class="text-xs text-zinc-500"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-center mb-4">
|
||||||
|
<div>
|
||||||
|
<p id="res-dist" class="text-lg font-bold text-white">—</p>
|
||||||
|
<p class="text-xs text-zinc-500">Distance</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p id="res-time" class="text-lg font-bold text-white">—</p>
|
||||||
|
<p class="text-xs text-zinc-500">Duration</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p id="res-elev" class="text-lg font-bold text-white">—</p>
|
||||||
|
<p class="text-xs text-zinc-500">Elevation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<button id="btn-download-json" class="w-full py-2 px-4 rounded-lg text-sm font-medium bg-zinc-700 hover:bg-zinc-600 text-white transition-colors">
|
||||||
|
⬇ Download BAS JSON
|
||||||
|
</button>
|
||||||
|
<button id="btn-download-geojson" class="w-full py-2 px-4 rounded-lg text-sm font-medium bg-zinc-700 hover:bg-zinc-600 text-white transition-colors" style="display:none">
|
||||||
|
⬇ Download GeoJSON track
|
||||||
|
</button>
|
||||||
|
{editUrl && (
|
||||||
|
<button id="btn-save" class="w-full py-2 px-4 rounded-lg text-sm font-medium text-white transition-colors" style="background:#3b82f6">
|
||||||
|
☁ Save to my bincio
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p id="conv-save-status" class="mt-2 text-xs text-center" style="min-height:1.25rem"></p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-convert-another" class="text-sm text-zinc-500 hover:text-white transition-colors">
|
||||||
|
← Convert another file
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: error -->
|
||||||
|
<div id="step-error" style="display:none" class="text-center py-12">
|
||||||
|
<div class="text-4xl mb-3">⚠</div>
|
||||||
|
<p id="conv-error-msg" class="text-red-400 text-sm mb-4"></p>
|
||||||
|
<button id="btn-retry" class="text-sm text-zinc-500 hover:text-white transition-colors">← Try another file</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<script define:vars={{ editUrl, baseUrl }}>
|
||||||
|
// ── DOM refs ────────────────────────────────────────────────────────────────
|
||||||
|
const stepPick = document.getElementById('step-pick');
|
||||||
|
const stepLoading = document.getElementById('step-loading');
|
||||||
|
const stepResult = document.getElementById('step-result');
|
||||||
|
const stepError = document.getElementById('step-error');
|
||||||
|
const drop = document.getElementById('conv-drop');
|
||||||
|
const input = document.getElementById('conv-input');
|
||||||
|
const dropLabel = document.getElementById('conv-drop-label');
|
||||||
|
const loadingMsg = document.getElementById('conv-loading-msg');
|
||||||
|
const progressBar = document.getElementById('conv-progress-bar');
|
||||||
|
const errorMsg = document.getElementById('conv-error-msg');
|
||||||
|
const saveStatus = document.getElementById('conv-save-status');
|
||||||
|
|
||||||
|
function showStep(name) {
|
||||||
|
stepPick.style.display = name === 'pick' ? '' : 'none';
|
||||||
|
stepLoading.style.display = name === 'loading' ? '' : 'none';
|
||||||
|
stepResult.style.display = name === 'result' ? '' : 'none';
|
||||||
|
stepError.style.display = name === 'error' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(pct, msg) {
|
||||||
|
progressBar.style.width = pct + '%';
|
||||||
|
if (msg) loadingMsg.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-load from recorder (sessionStorage handoff) ───────────────────────
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const pending = sessionStorage.getItem('pending_convert');
|
||||||
|
if (pending) {
|
||||||
|
sessionStorage.removeItem('pending_convert');
|
||||||
|
try {
|
||||||
|
const { name, dataUrl } = JSON.parse(pending);
|
||||||
|
fetch(dataUrl).then(r => r.blob()).then(blob => {
|
||||||
|
startConversion(new File([blob], name, { type: 'application/gpx+xml' }));
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── File picking ────────────────────────────────────────────────────────────
|
||||||
|
drop.addEventListener('click', () => input.click());
|
||||||
|
drop.addEventListener('dragover', e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; });
|
||||||
|
drop.addEventListener('dragleave', () => { drop.style.borderColor = ''; });
|
||||||
|
drop.addEventListener('drop', e => {
|
||||||
|
e.preventDefault(); drop.style.borderColor = '';
|
||||||
|
if (e.dataTransfer?.files[0]) startConversion(e.dataTransfer.files[0]);
|
||||||
|
});
|
||||||
|
input.addEventListener('change', () => { if (input.files?.[0]) startConversion(input.files[0]); });
|
||||||
|
document.getElementById('btn-retry').addEventListener('click', reset);
|
||||||
|
document.getElementById('btn-convert-another').addEventListener('click', reset);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
input.value = '';
|
||||||
|
dropLabel.innerHTML = 'Drop a FIT, GPX, or TCX file<br/>or tap to browse';
|
||||||
|
showStep('pick');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pyodide state ───────────────────────────────────────────────────────────
|
||||||
|
let pyodide = null;
|
||||||
|
let pyodideReady = false;
|
||||||
|
let pyodideLoading = false;
|
||||||
|
|
||||||
|
async function ensurePyodide() {
|
||||||
|
if (pyodideReady) return;
|
||||||
|
if (pyodideLoading) {
|
||||||
|
// Wait for the ongoing load
|
||||||
|
while (!pyodideReady) await new Promise(r => setTimeout(r, 100));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pyodideLoading = true;
|
||||||
|
|
||||||
|
setProgress(5, 'Loading Python runtime…');
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/pyodide.js';
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
script.onload = resolve; script.onerror = reject;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
setProgress(20, 'Initialising Python…');
|
||||||
|
pyodide = await window.loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.26.4/full/' });
|
||||||
|
|
||||||
|
setProgress(40, 'Loading packages (lxml, pyyaml)…');
|
||||||
|
await pyodide.loadPackage(['lxml', 'pyyaml']);
|
||||||
|
|
||||||
|
setProgress(60, 'Installing fitdecode, gpxpy, rdp…');
|
||||||
|
await pyodide.runPythonAsync(`
|
||||||
|
import micropip
|
||||||
|
await micropip.install(['fitdecode', 'gpxpy', 'rdp'])
|
||||||
|
`);
|
||||||
|
|
||||||
|
setProgress(80, 'Loading bincio extract pipeline…');
|
||||||
|
const wheelUrl = new URL('/bincio.whl', window.location.origin).href;
|
||||||
|
await pyodide.runPythonAsync(`
|
||||||
|
import micropip
|
||||||
|
await micropip.install('${wheelUrl}', deps=False)
|
||||||
|
`);
|
||||||
|
|
||||||
|
setProgress(100, 'Ready.');
|
||||||
|
pyodideReady = true;
|
||||||
|
pyodideLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conversion ──────────────────────────────────────────────────────────────
|
||||||
|
let lastResult = null;
|
||||||
|
|
||||||
|
async function startConversion(file) {
|
||||||
|
showStep('loading');
|
||||||
|
setProgress(0, 'Loading converter…');
|
||||||
|
|
||||||
|
let arrayBuffer;
|
||||||
|
try {
|
||||||
|
arrayBuffer = await file.arrayBuffer();
|
||||||
|
} catch (e) {
|
||||||
|
showError('Could not read file: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensurePyodide();
|
||||||
|
} catch (e) {
|
||||||
|
showError('Failed to load Python runtime: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(100, 'Converting…');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Write file to Pyodide virtual filesystem
|
||||||
|
const fname = file.name;
|
||||||
|
pyodide.FS.writeFile('/tmp/' + fname, new Uint8Array(arrayBuffer));
|
||||||
|
|
||||||
|
const resultJson = await pyodide.runPythonAsync(`
|
||||||
|
import json, os, shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from bincio.extract.parsers.factory import parse_file
|
||||||
|
from bincio.extract.metrics import compute
|
||||||
|
from bincio.extract.writer import build_summary, make_activity_id, write_activity
|
||||||
|
|
||||||
|
outdir = Path('/tmp/bincio_out')
|
||||||
|
if outdir.exists(): shutil.rmtree(outdir)
|
||||||
|
outdir.mkdir()
|
||||||
|
|
||||||
|
activity = parse_file(Path('/tmp/${fname.replace(/'/g, "\\'")}'))
|
||||||
|
metrics = compute(activity)
|
||||||
|
act_id = make_activity_id(activity)
|
||||||
|
write_activity(activity, metrics, outdir, privacy='public', rdp_epsilon=0.0001)
|
||||||
|
|
||||||
|
detail_path = outdir / 'activities' / f'{act_id}.json'
|
||||||
|
geojson_path = outdir / 'activities' / f'{act_id}.geojson'
|
||||||
|
|
||||||
|
detail = json.loads(detail_path.read_text())
|
||||||
|
geojson = json.loads(geojson_path.read_text()) if geojson_path.exists() else None
|
||||||
|
|
||||||
|
json.dumps({'id': act_id, 'detail': detail, 'geojson': geojson})
|
||||||
|
`);
|
||||||
|
|
||||||
|
lastResult = JSON.parse(resultJson);
|
||||||
|
showResult(lastResult);
|
||||||
|
} catch (e) {
|
||||||
|
showError(e.message || String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result display ──────────────────────────────────────────────────────────
|
||||||
|
const SPORT_ICONS = { cycling: '🚴', running: '🏃', hiking: '🥾', walking: '🚶', swimming: '🏊', skiing: '⛷️', other: '⚡' };
|
||||||
|
|
||||||
|
function showResult(r) {
|
||||||
|
const d = r.detail;
|
||||||
|
document.getElementById('res-sport-icon').textContent = SPORT_ICONS[d.sport] ?? '⚡';
|
||||||
|
document.getElementById('res-title').textContent = d.title || 'Untitled';
|
||||||
|
document.getElementById('res-date').textContent = d.started_at ? new Date(d.started_at).toLocaleString() : '';
|
||||||
|
|
||||||
|
const distKm = d.distance_m ? (d.distance_m / 1000).toFixed(1) + ' km' : '—';
|
||||||
|
const dur = d.moving_time_s ?? d.duration_s;
|
||||||
|
const durStr = dur ? `${Math.floor(dur/3600)}h ${Math.floor((dur%3600)/60)}m` : '—';
|
||||||
|
const elevStr = d.elevation_gain_m ? Math.round(d.elevation_gain_m) + ' m' : '—';
|
||||||
|
document.getElementById('res-dist').textContent = distKm;
|
||||||
|
document.getElementById('res-time').textContent = durStr;
|
||||||
|
document.getElementById('res-elev').textContent = elevStr;
|
||||||
|
|
||||||
|
document.getElementById('btn-download-geojson').style.display = r.geojson ? '' : 'none';
|
||||||
|
showStep('result');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorMsg.textContent = msg;
|
||||||
|
showStep('error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Downloads ───────────────────────────────────────────────────────────────
|
||||||
|
function downloadJson(data, filename) {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url; a.download = filename; a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-download-json').addEventListener('click', () => {
|
||||||
|
if (lastResult) downloadJson(lastResult.detail, lastResult.id + '.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-download-geojson').addEventListener('click', () => {
|
||||||
|
if (lastResult?.geojson) downloadJson(lastResult.geojson, lastResult.id + '.geojson');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Save to bincio ──────────────────────────────────────────────────────────
|
||||||
|
const saveBtn = document.getElementById('btn-save');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
if (!lastResult) return;
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveStatus.textContent = 'Saving…';
|
||||||
|
saveStatus.style.color = 'var(--text-4)';
|
||||||
|
|
||||||
|
// Reconstruct a file blob from the detail JSON and POST as a virtual upload
|
||||||
|
// The edit server expects a raw activity file, so we use a dedicated endpoint instead
|
||||||
|
const target = (localStorage.getItem('bincio_edit_url') || editUrl).replace(/\/$/, '');
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${target}/api/import-bas`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ detail: lastResult.detail, geojson: lastResult.geojson }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(await r.text());
|
||||||
|
const data = await r.json();
|
||||||
|
saveStatus.textContent = 'Saved!';
|
||||||
|
saveStatus.style.color = '#4ade80';
|
||||||
|
setTimeout(() => { window.location.href = `${baseUrl}activity/${data.id}/`; }, 800);
|
||||||
|
} catch (e) {
|
||||||
|
saveStatus.textContent = 'Error: ' + e.message;
|
||||||
|
saveStatus.style.color = '#f87171';
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
---
|
||||||
|
import Base from '../../layouts/Base.astro';
|
||||||
|
const baseUrl = import.meta.env.BASE_URL ?? '/';
|
||||||
|
---
|
||||||
|
<Base title="Record activity — BincioActivity">
|
||||||
|
<div class="max-w-lg mx-auto">
|
||||||
|
<h1 class="text-xl font-bold text-white mb-1">Record activity</h1>
|
||||||
|
<p class="text-sm text-zinc-400 mb-6">
|
||||||
|
Record a GPS track and convert it to BAS format directly on your device.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Sport selector -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs text-zinc-500 mb-1">Sport</label>
|
||||||
|
<div class="flex flex-wrap gap-2" id="sport-picker">
|
||||||
|
{[
|
||||||
|
['cycling', '🚴'], ['running', '🏃'], ['hiking', '🥾'],
|
||||||
|
['walking', '🚶'], ['swimming', '🏊'], ['skiing', '⛷️'], ['other', '⚡'],
|
||||||
|
].map(([s, icon]) => (
|
||||||
|
<button
|
||||||
|
data-sport={s}
|
||||||
|
class="sport-btn px-3 py-1.5 rounded-full text-sm border border-zinc-700 text-zinc-400 transition-colors"
|
||||||
|
>{icon} {s.charAt(0).toUpperCase() + s.slice(1)}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs text-zinc-500 mb-1">Activity title (optional)</label>
|
||||||
|
<input
|
||||||
|
id="rec-title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Morning ride"
|
||||||
|
class="w-full px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-600 outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live stats -->
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-center mb-4">
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3">
|
||||||
|
<p id="stat-dist" class="text-2xl font-bold text-white">0.0</p>
|
||||||
|
<p class="text-xs text-zinc-500">km</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3">
|
||||||
|
<p id="stat-time" class="text-2xl font-bold text-white">0:00</p>
|
||||||
|
<p class="text-xs text-zinc-500">duration</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3">
|
||||||
|
<p id="stat-pts" class="text-2xl font-bold text-white">0</p>
|
||||||
|
<p class="text-xs text-zinc-500">points</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GPS accuracy indicator -->
|
||||||
|
<p id="gps-accuracy" class="text-xs text-zinc-500 text-center mb-4"></p>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
id="btn-start"
|
||||||
|
class="flex-1 py-3 rounded-xl font-semibold text-sm text-white transition-colors"
|
||||||
|
style="background:#22c55e"
|
||||||
|
>▶ Start</button>
|
||||||
|
<button
|
||||||
|
id="btn-pause"
|
||||||
|
style="display:none"
|
||||||
|
class="flex-1 py-3 rounded-xl font-semibold text-sm text-white bg-yellow-600 hover:bg-yellow-500 transition-colors"
|
||||||
|
>⏸ Pause</button>
|
||||||
|
<button
|
||||||
|
id="btn-stop"
|
||||||
|
style="display:none"
|
||||||
|
class="flex-1 py-3 rounded-xl font-semibold text-sm text-white bg-red-600 hover:bg-red-500 transition-colors"
|
||||||
|
>⏹ Stop & convert</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="rec-status" class="mt-3 text-xs text-zinc-500 text-center" style="min-height:1.25rem"></p>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<script define:vars={{ baseUrl }}>
|
||||||
|
// ── Sport picker ────────────────────────────────────────────────────────────
|
||||||
|
let selectedSport = 'cycling';
|
||||||
|
document.querySelectorAll('.sport-btn').forEach(btn => {
|
||||||
|
if (btn.dataset.sport === selectedSport) btn.classList.add('border-blue-500', 'text-white');
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.sport-btn').forEach(b => b.classList.remove('border-blue-500', 'text-white'));
|
||||||
|
btn.classList.add('border-blue-500', 'text-white');
|
||||||
|
selectedSport = btn.dataset.sport;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────────────────────────
|
||||||
|
let recording = false;
|
||||||
|
let paused = false;
|
||||||
|
let points = []; // {lat, lon, ele, speed, time, accuracy}
|
||||||
|
let watchId = null;
|
||||||
|
let startTime = null;
|
||||||
|
let timerInterval = null;
|
||||||
|
let totalDistance = 0;
|
||||||
|
|
||||||
|
const btnStart = document.getElementById('btn-start');
|
||||||
|
const btnPause = document.getElementById('btn-pause');
|
||||||
|
const btnStop = document.getElementById('btn-stop');
|
||||||
|
const recStatus = document.getElementById('rec-status');
|
||||||
|
|
||||||
|
// ── GPS ─────────────────────────────────────────────────────────────────────
|
||||||
|
async function getGeolocation() {
|
||||||
|
// Use Capacitor Geolocation if available, fall back to browser API
|
||||||
|
if (window.Capacitor?.isNativePlatform()) {
|
||||||
|
const { Geolocation } = await import('@capacitor/geolocation');
|
||||||
|
return Geolocation;
|
||||||
|
}
|
||||||
|
// Browser fallback — wraps navigator.geolocation in the same interface
|
||||||
|
return {
|
||||||
|
watchPosition: (opts, callback) => {
|
||||||
|
const id = navigator.geolocation.watchPosition(
|
||||||
|
pos => callback(pos, null),
|
||||||
|
err => callback(null, err),
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 },
|
||||||
|
);
|
||||||
|
return Promise.resolve(id);
|
||||||
|
},
|
||||||
|
clearWatch: ({ id }) => navigator.geolocation.clearWatch(id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineM(lat1, lon1, lat2, lon2) {
|
||||||
|
const R = 6371000, r = Math.PI / 180;
|
||||||
|
const dLat = (lat2 - lat1) * r, dLon = (lon2 - lon1) * r;
|
||||||
|
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*r)*Math.cos(lat2*r)*Math.sin(dLon/2)**2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ─────────────────────────────────────────────────────────────────
|
||||||
|
btnStart.addEventListener('click', async () => {
|
||||||
|
if (!navigator.geolocation && !window.Capacitor?.isNativePlatform()) {
|
||||||
|
recStatus.textContent = 'GPS not available on this device.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btnStart.style.display = 'none';
|
||||||
|
btnPause.style.display = '';
|
||||||
|
btnStop.style.display = '';
|
||||||
|
recording = true;
|
||||||
|
paused = false;
|
||||||
|
points = [];
|
||||||
|
totalDistance = 0;
|
||||||
|
startTime = Date.now();
|
||||||
|
|
||||||
|
timerInterval = setInterval(updateTimer, 1000);
|
||||||
|
recStatus.textContent = 'Waiting for GPS fix…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const geo = await getGeolocation();
|
||||||
|
watchId = await geo.watchPosition(
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 },
|
||||||
|
(pos, err) => {
|
||||||
|
if (err) { recStatus.textContent = 'GPS error: ' + (err.message || err); return; }
|
||||||
|
if (!recording || paused) return;
|
||||||
|
const { latitude: lat, longitude: lon, altitude: ele, speed, accuracy } = pos.coords;
|
||||||
|
if (points.length > 0) {
|
||||||
|
const prev = points[points.length - 1];
|
||||||
|
totalDistance += haversineM(prev.lat, prev.lon, lat, lon);
|
||||||
|
}
|
||||||
|
points.push({ lat, lon, ele: ele ?? null, speed: speed ?? null, time: new Date().toISOString(), accuracy });
|
||||||
|
document.getElementById('stat-pts').textContent = points.length;
|
||||||
|
document.getElementById('stat-dist').textContent = (totalDistance / 1000).toFixed(2);
|
||||||
|
document.getElementById('gps-accuracy').textContent = accuracy ? `GPS accuracy: ±${Math.round(accuracy)} m` : '';
|
||||||
|
recStatus.textContent = 'Recording…';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
recStatus.textContent = 'Could not start GPS: ' + e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPause.addEventListener('click', () => {
|
||||||
|
paused = !paused;
|
||||||
|
btnPause.textContent = paused ? '▶ Resume' : '⏸ Pause';
|
||||||
|
recStatus.textContent = paused ? 'Paused' : 'Recording…';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnStop.addEventListener('click', async () => {
|
||||||
|
recording = false;
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
if (watchId !== null) {
|
||||||
|
try {
|
||||||
|
const geo = await getGeolocation();
|
||||||
|
await geo.clearWatch({ id: watchId });
|
||||||
|
} catch (_) {}
|
||||||
|
watchId = null;
|
||||||
|
}
|
||||||
|
if (points.length < 2) {
|
||||||
|
recStatus.textContent = 'Not enough points recorded (minimum 2).';
|
||||||
|
btnStart.style.display = '';
|
||||||
|
btnPause.style.display = 'none';
|
||||||
|
btnStop.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exportAndConvert();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Timer ───────────────────────────────────────────────────────────────────
|
||||||
|
function updateTimer() {
|
||||||
|
if (!startTime) return;
|
||||||
|
const s = Math.floor((Date.now() - startTime) / 1000);
|
||||||
|
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||||
|
document.getElementById('stat-time').textContent =
|
||||||
|
h > 0 ? `${h}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`
|
||||||
|
: `${m}:${String(sec).padStart(2,'0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GPX export → /convert/ ──────────────────────────────────────────────────
|
||||||
|
function exportAndConvert() {
|
||||||
|
const title = document.getElementById('rec-title').value.trim() || 'Recorded activity';
|
||||||
|
const gpx = buildGPX(points, title, selectedSport);
|
||||||
|
const blob = new Blob([gpx], { type: 'application/gpx+xml' });
|
||||||
|
|
||||||
|
// Pass the GPX to the convert page via a temporary object URL stored in sessionStorage
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
sessionStorage.setItem('pending_convert', JSON.stringify({
|
||||||
|
name: `${title.replace(/[^a-z0-9]/gi, '_')}.gpx`,
|
||||||
|
dataUrl: reader.result,
|
||||||
|
}));
|
||||||
|
window.location.href = `${baseUrl}convert/`;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGPX(pts, name, sport) {
|
||||||
|
const lines = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<gpx version="1.1" creator="BincioActivity" xmlns="http://www.topografix.com/GPX/1/1">',
|
||||||
|
` <metadata><name>${escXml(name)}</name></metadata>`,
|
||||||
|
` <trk><name>${escXml(name)}</name><type>${escXml(sport)}</type><trkseg>`,
|
||||||
|
];
|
||||||
|
for (const p of pts) {
|
||||||
|
const ele = p.ele !== null ? `<ele>${p.ele.toFixed(1)}</ele>` : '';
|
||||||
|
const spd = p.speed !== null ? `<extensions><speed>${p.speed.toFixed(3)}</speed></extensions>` : '';
|
||||||
|
lines.push(` <trkpt lat="${p.lat}" lon="${p.lon}"><time>${p.time}</time>${ele}${spd}</trkpt>`);
|
||||||
|
}
|
||||||
|
lines.push(' </trkseg></trk>', '</gpx>');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escXml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user