second pass. medium
This commit is contained in:
@@ -110,8 +110,8 @@ needed to render an activity card in a feed — no timeseries, no full track.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `id` | string | yes | Unique identifier. See **Activity ID** section. |
|
| `id` | string | yes | Unique identifier. See **Activity ID** section. |
|
||||||
| `title` | string | yes | Human-readable name. May be auto-generated if not in source. |
|
| `title` | string | yes | Human-readable name. May be auto-generated if not in source. |
|
||||||
| `sport` | string | yes | One of: `cycling`, `running`, `hiking`, `walking`, `swimming`, `other`. |
|
| `sport` | string | yes | One of: `cycling`, `running`, `hiking`, `walking`, `swimming`, `skiing`, `other`. |
|
||||||
| `sub_sport` | string\|null | no | e.g. `road`, `mountain`, `gravel`, `indoor`, `trail`. |
|
| `sub_sport` | string\|null | no | e.g. `road`, `mountain`, `gravel`, `indoor`, `trail`, `track`, `nordic`, `alpine`, `open_water`, `pool`. |
|
||||||
| `started_at` | string | yes | ISO 8601 timestamp with timezone. |
|
| `started_at` | string | yes | ISO 8601 timestamp with timezone. |
|
||||||
| `distance_m` | number\|null | no | Total distance in metres. |
|
| `distance_m` | number\|null | no | Total distance in metres. |
|
||||||
| `duration_s` | integer\|null | no | Total elapsed time in seconds. |
|
| `duration_s` | integer\|null | no | Total elapsed time in seconds. |
|
||||||
@@ -125,8 +125,12 @@ needed to render an activity card in a feed — no timeseries, no full track.
|
|||||||
| `avg_power_w` | integer\|null | no | Average power in watts. |
|
| `avg_power_w` | integer\|null | no | Average power in watts. |
|
||||||
| `source` | string\|null | no | Origin of data. See **Source values**. |
|
| `source` | string\|null | no | Origin of data. See **Source values**. |
|
||||||
| `privacy` | string | yes | One of: `public`, `blur_start`, `no_gps`, `private`. |
|
| `privacy` | string | yes | One of: `public`, `blur_start`, `no_gps`, `private`. |
|
||||||
|
| `mmp` | array\|null | no | Mean Maximal Power curve — `[[duration_s, avg_watts], ...]`. |
|
||||||
|
| `best_efforts` | array\|null | no | Best efforts by distance — `[[distance_km, time_s], ...]`. |
|
||||||
|
| `best_climb_m` | number\|null | no | Best single climb in metres (Kadane's algorithm). |
|
||||||
| `detail_url` | string\|null | no | Relative or absolute URL to the full activity JSON. |
|
| `detail_url` | string\|null | no | Relative or absolute URL to the full activity JSON. |
|
||||||
| `track_url` | string\|null | no | Relative or absolute URL to the GeoJSON track. `null` if `privacy` is `no_gps`. |
|
| `track_url` | string\|null | no | Relative or absolute URL to the GeoJSON track. `null` if `privacy` is `no_gps`. |
|
||||||
|
| `preview_coords` | array\|null | no | Simplified track preview — `[[lon, lat], ...]` for card thumbnails. |
|
||||||
|
|
||||||
### Activity ID
|
### Activity ID
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -265,11 +265,15 @@ async function uploadFiles(files) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||||
|
}
|
||||||
|
|
||||||
function renderImageList() {
|
function renderImageList() {
|
||||||
const list = document.getElementById('image-list');
|
const list = document.getElementById('image-list');
|
||||||
list.innerHTML = uploadedImages.map(f =>
|
list.innerHTML = uploadedImages.map(f =>
|
||||||
`<span class="image-chip">${f}
|
`<span class="image-chip">${escapeHtml(f)}
|
||||||
<button type="button" onclick="removeImage('${f}')" title="Remove">×</button>
|
<button type="button" onclick="removeImage('${escapeHtml(f)}')" title="Remove">×</button>
|
||||||
</span>`
|
</span>`
|
||||||
).join('');
|
).join('');
|
||||||
}
|
}
|
||||||
@@ -405,7 +409,7 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon
|
|||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
if payload.get("title"):
|
if payload.get("title"):
|
||||||
lines.append(f"title: {json.dumps(payload['title'])}")
|
lines.append(f"title: {json.dumps(payload['title'])}")
|
||||||
if payload.get("sport") and payload["sport"] != "other":
|
if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other":
|
||||||
lines.append(f"sport: {payload['sport']}")
|
lines.append(f"sport: {payload['sport']}")
|
||||||
if payload.get("gear"):
|
if payload.get("gear"):
|
||||||
lines.append(f"gear: {json.dumps(payload['gear'])}")
|
lines.append(f"gear: {json.dumps(payload['gear'])}")
|
||||||
@@ -443,7 +447,12 @@ async def upload_image(activity_id: str, file: UploadFile = File(...)) -> JSONRe
|
|||||||
|
|
||||||
images_dir = dd / "edits" / "images" / activity_id
|
images_dir = dd / "edits" / "images" / activity_id
|
||||||
images_dir.mkdir(parents=True, exist_ok=True)
|
images_dir.mkdir(parents=True, exist_ok=True)
|
||||||
dest = images_dir / Path(file.filename).name
|
safe_name = Path(file.filename).name
|
||||||
|
# Only allow image content types
|
||||||
|
ct = file.content_type or ""
|
||||||
|
if not ct.startswith("image/"):
|
||||||
|
raise HTTPException(400, f"Only image files are accepted (got {ct})")
|
||||||
|
dest = images_dir / safe_name
|
||||||
dest.write_bytes(await file.read())
|
dest.write_bytes(await file.read())
|
||||||
return JSONResponse({"ok": True, "filename": dest.name})
|
return JSONResponse({"ok": True, "filename": dest.name})
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class ExtractConfig:
|
|||||||
|
|
||||||
|
|
||||||
def load_config(path: Path) -> ExtractConfig:
|
def load_config(path: Path) -> ExtractConfig:
|
||||||
raw = yaml.safe_load(path.read_text())
|
raw = yaml.safe_load(path.read_text()) or {}
|
||||||
|
|
||||||
inp = raw.get("input", {})
|
inp = raw.get("input", {})
|
||||||
dirs = [Path(d).expanduser() for d in inp.get("dirs", [])]
|
dirs = [Path(d).expanduser() for d in inp.get("dirs", [])]
|
||||||
|
|||||||
@@ -155,9 +155,10 @@ def merge_all(data_dir: Path) -> int:
|
|||||||
edits = {}
|
edits = {}
|
||||||
else:
|
else:
|
||||||
edits = {}
|
edits = {}
|
||||||
|
_ATHLETE_EDITABLE = {"max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"}
|
||||||
if edits:
|
if edits:
|
||||||
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
|
athlete_data = json.loads(athlete_src.read_text(encoding="utf-8"))
|
||||||
athlete_data.update(edits)
|
athlete_data.update({k: v for k, v in edits.items() if k in _ATHLETE_EDITABLE})
|
||||||
athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False))
|
athlete_dest.write_text(json.dumps(athlete_data, indent=2, ensure_ascii=False))
|
||||||
else:
|
else:
|
||||||
athlete_dest.symlink_to(athlete_src.resolve())
|
athlete_dest.symlink_to(athlete_src.resolve())
|
||||||
|
|||||||
@@ -149,9 +149,14 @@
|
|||||||
|
|
||||||
$: renderChart(plotData, colorMap);
|
$: renderChart(plotData, colorMap);
|
||||||
|
|
||||||
// Re-render on resize
|
// Re-render on resize — use indirect call so we always get current reactive values
|
||||||
|
let currentPlotData = plotData;
|
||||||
|
let currentColorMap = colorMap;
|
||||||
|
$: currentPlotData = plotData;
|
||||||
|
$: currentColorMap = colorMap;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const ro = new ResizeObserver(() => renderChart(plotData, colorMap));
|
const ro = new ResizeObserver(() => renderChart(currentPlotData, currentColorMap));
|
||||||
ro.observe(chartEl);
|
ro.observe(chartEl);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -98,12 +98,12 @@ const baseUrl = import.meta.env.BASE_URL ?? '/';
|
|||||||
style="border-color: var(--border)"
|
style="border-color: var(--border)"
|
||||||
>
|
>
|
||||||
<div class="max-w-7xl mx-auto px-4 h-12 flex items-center gap-6">
|
<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">
|
<a href={baseUrl} class="font-bold text-white tracking-tight hover:text-[--accent] transition-colors">
|
||||||
Bincio<span class="text-[--accent]">Activity</span>
|
Bincio<span class="text-[--accent]">Activity</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/" 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="/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="/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>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
{editUrl && (
|
{editUrl && (
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export interface Timeseries {
|
|||||||
temperature_c: (number | null)[];
|
temperature_c: (number | null)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivityDetail extends ActivitySummary {
|
export interface ActivityDetail extends Omit<ActivitySummary, 'detail_url' | 'track_url' | 'preview_coords'> {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
elevation_loss_m: number | null;
|
elevation_loss_m: number | null;
|
||||||
max_power_w: number | null;
|
max_power_w: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user