get default hr and power zones from config file
This commit is contained in:
@@ -129,8 +129,30 @@ incremental: true # false = re-process everything
|
|||||||
track:
|
track:
|
||||||
rdp_epsilon: 0.0001 # GPS simplification — larger = fewer points
|
rdp_epsilon: 0.0001 # GPS simplification — larger = fewer points
|
||||||
timeseries_hz: 1 # samples/sec in stored JSON (1 = 1 Hz)
|
timeseries_hz: 1 # samples/sec in stored JSON (1 = 1 Hz)
|
||||||
|
|
||||||
|
athlete:
|
||||||
|
max_hr: 182 # used for context; zones below are authoritative
|
||||||
|
ftp_w: 280 # functional threshold power in watts
|
||||||
|
hr_zones: # 5-zone Coggan, explicit bpm boundaries [[lo, hi], ...]
|
||||||
|
- [0, 115] # Z1 recovery
|
||||||
|
- [115, 137] # Z2 endurance
|
||||||
|
- [137, 155] # Z3 tempo
|
||||||
|
- [155, 169] # Z4 threshold
|
||||||
|
- [169, 999] # Z5 VO2max
|
||||||
|
power_zones: # 7-zone Coggan, explicit watt boundaries
|
||||||
|
- [0, 168] # Z1 active recovery (< 55% FTP)
|
||||||
|
- [168, 224] # Z2 endurance (55–75%)
|
||||||
|
- [224, 266] # Z3 tempo (75–90%)
|
||||||
|
- [266, 308] # Z4 threshold (90–105%)
|
||||||
|
- [308, 364] # Z5 VO2max (105–120%)
|
||||||
|
- [364, 420] # Z6 anaerobic (120–150%)
|
||||||
|
- [420, 9999] # Z7 neuromuscular (> 150%)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Zones are written into `index.json` under `owner.athlete` at extract time and
|
||||||
|
displayed as overlays on HR and Power histograms in the activity detail page.
|
||||||
|
After changing zones, re-run `uv run bincio extract` to update `index.json`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ site/ Astro project
|
|||||||
ActivityFeed.svelte Card grid, sport filter, pagination
|
ActivityFeed.svelte Card grid, sport filter, pagination
|
||||||
ActivityDetail.svelte Map + stats + charts wrapper
|
ActivityDetail.svelte Map + stats + charts wrapper
|
||||||
ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
|
ActivityMap.svelte MapLibre GL (gradient track, linked hover dot)
|
||||||
ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence tabs)
|
ActivityCharts.svelte Observable Plot (elevation/speed/HR/cadence/power tabs)
|
||||||
StatsView.svelte Yearly heatmap + totals
|
StatsView.svelte Yearly heatmap + totals
|
||||||
lib/
|
lib/
|
||||||
types.ts BAS TypeScript types
|
types.ts BAS TypeScript types
|
||||||
@@ -199,6 +199,46 @@ const intensity = 0.12 + pctRank(total, sortedDaily) * 0.88;
|
|||||||
detects the dependency change when the sport filter or scale method changes
|
detects the dependency change when the sport filter or scale method changes
|
||||||
(plain function calls with static args don't trigger Svelte re-renders).
|
(plain function calls with static args don't trigger Svelte re-renders).
|
||||||
|
|
||||||
|
## ActivityCharts — controls and athlete zones
|
||||||
|
|
||||||
|
`ActivityCharts.svelte` renders Observable Plot charts for the activity detail page.
|
||||||
|
|
||||||
|
### Chart controls
|
||||||
|
|
||||||
|
- **Metric tabs**: Elevation · Speed · Heart Rate · Cadence · Power
|
||||||
|
- **Chart type toggle** (right-aligned): `↗ Line` | `▭ Hist`
|
||||||
|
- **X-axis toggle** (line mode only, shown when speed data present): `Time` | `Dist`
|
||||||
|
- Distance is integrated from `speed_kmh` at 1 Hz — no extra data needed.
|
||||||
|
- **Histogram controls** (visible only in histogram mode):
|
||||||
|
- **Dual range slider** — trims the x domain; two overlapping `<input type="range">` with CSS track highlight.
|
||||||
|
- **Bins slider** — exact bin count using explicit evenly-spaced thresholds (not d3's "nice" count, which ignores narrow ranges).
|
||||||
|
|
||||||
|
### Athlete zones
|
||||||
|
|
||||||
|
Zones are configured in `extract_config.yaml` under `athlete:` and written into
|
||||||
|
`index.json` at extract time (`owner.athlete`). The Astro activity page reads them
|
||||||
|
from the index and passes them down: `[id].astro` → `ActivityDetail` → `ActivityCharts`.
|
||||||
|
|
||||||
|
When viewing HR or Power in histogram mode, zone boundaries are drawn as dashed
|
||||||
|
vertical rules with Z1–Z5/Z7 labels at the top of the chart.
|
||||||
|
Labels and rules are clipped to the current trim range automatically.
|
||||||
|
|
||||||
|
Zone color palettes:
|
||||||
|
- HR (5 zones): `#60a5fa #4ade80 #facc15 #fb923c #f87171`
|
||||||
|
- Power (7 zones): `#60a5fa #34d399 #facc15 #fb923c #f87171 #c084fc #f43f5e`
|
||||||
|
|
||||||
|
### Zone calculation reference (Coggan)
|
||||||
|
|
||||||
|
| Zone | HR (% max HR) | Power (% FTP) |
|
||||||
|
|------|--------------|---------------|
|
||||||
|
| Z1 | < 55% | < 55% |
|
||||||
|
| Z2 | 55–75% | 55–75% |
|
||||||
|
| Z3 | 75–87% | 75–90% |
|
||||||
|
| Z4 | 87–93% | 90–105% |
|
||||||
|
| Z5 | > 93% | 105–120% |
|
||||||
|
| Z6 | — | 120–150% |
|
||||||
|
| Z7 | — | > 150% |
|
||||||
|
|
||||||
## Activity sidecar edits — design spec
|
## Activity sidecar edits — design spec
|
||||||
|
|
||||||
Users edit activities via **sidecar markdown files** that live alongside BAS JSON in the data dir.
|
Users edit activities via **sidecar markdown files** that live alongside BAS JSON in the data dir.
|
||||||
@@ -348,6 +388,13 @@ are served at `data/activities/images/{id}/{filename}` by the Astro dev server.
|
|||||||
- [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled
|
- [x] `PUBLIC_EDIT_URL` feature flag — unset = no edit UI, set = drawer enabled
|
||||||
- [x] Markdown rendering in activity description with image path rewriting
|
- [x] Markdown rendering in activity description with image path rewriting
|
||||||
- [x] `hide_stats` support in activity detail stats panel
|
- [x] `hide_stats` support in activity detail stats panel
|
||||||
|
- [x] ActivityCharts power tab (elevation/speed/HR/cadence/power)
|
||||||
|
- [x] Chart type toggle: line ↔ histogram
|
||||||
|
- [x] X-axis toggle: time ↔ distance (integrated from speed)
|
||||||
|
- [x] Histogram dual range slider + bins slider (exact thresholds)
|
||||||
|
- [x] Athlete zones in `extract_config.yaml` → `index.json` → chart overlays
|
||||||
|
- [x] StatsView heatmap click-to-pin tooltip (Esc / click-outside to dismiss)
|
||||||
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
|
- [ ] `bincio render --watch` incremental rebuild on sidecar/data changes
|
||||||
- [ ] Highlight badge in activity feed cards
|
- [ ] Highlight badge in activity feed cards
|
||||||
- [ ] Image format warning (HEIC → JPEG conversion hint in the upload UI)
|
- [ ] Image format warning (HEIC → JPEG conversion hint in the upload UI)
|
||||||
|
- [ ] HR / power zone defaults from `max_hr` / `ftp_w` when explicit zones not set
|
||||||
|
|||||||
@@ -133,8 +133,31 @@ track:
|
|||||||
timeseries_hz: 1 # data samples per second stored in JSON
|
timeseries_hz: 1 # data samples per second stored in JSON
|
||||||
|
|
||||||
incremental: true # skip files whose hash hasn't changed
|
incremental: true # skip files whose hash hasn't changed
|
||||||
|
|
||||||
|
# Optional: athlete profile for zone overlays on HR/power charts
|
||||||
|
athlete:
|
||||||
|
max_hr: 182
|
||||||
|
ftp_w: 280
|
||||||
|
hr_zones: # [[lo, hi], ...] in bpm — 5-zone Coggan
|
||||||
|
- [0, 115]
|
||||||
|
- [115, 137]
|
||||||
|
- [137, 155]
|
||||||
|
- [155, 169]
|
||||||
|
- [169, 999]
|
||||||
|
power_zones: # [[lo, hi], ...] in watts — 7-zone Coggan
|
||||||
|
- [0, 168]
|
||||||
|
- [168, 224]
|
||||||
|
- [224, 266]
|
||||||
|
- [266, 308]
|
||||||
|
- [308, 364]
|
||||||
|
- [364, 420]
|
||||||
|
- [420, 9999]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Zones are written into `index.json` at extract time and displayed as overlays on
|
||||||
|
HR and Power histograms in the activity detail page. After changing zones, re-run
|
||||||
|
`uv run bincio extract` to regenerate `index.json`.
|
||||||
|
|
||||||
### Privacy levels
|
### Privacy levels
|
||||||
|
|
||||||
| Level | GPS track | Stats | Appears in index |
|
| Level | GPS track | Stats | Appears in index |
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ def extract(
|
|||||||
console.print(f"Using [bold]{n_workers}[/bold] worker processes.")
|
console.print(f"Using [bold]{n_workers}[/bold] worker processes.")
|
||||||
|
|
||||||
owner = {"handle": cfg.owner_handle, "display_name": cfg.owner_display_name}
|
owner = {"handle": cfg.owner_handle, "display_name": cfg.owner_display_name}
|
||||||
|
if cfg.athlete:
|
||||||
|
ath = cfg.athlete
|
||||||
|
owner["athlete"] = {
|
||||||
|
k: v for k, v in {
|
||||||
|
"max_hr": ath.max_hr,
|
||||||
|
"ftp_w": ath.ftp_w,
|
||||||
|
"hr_zones": ath.hr_zones,
|
||||||
|
"power_zones": ath.power_zones,
|
||||||
|
}.items() if v is not None
|
||||||
|
}
|
||||||
summaries: list[dict] = []
|
summaries: list[dict] = []
|
||||||
errors: list[tuple[str, str]] = []
|
errors: list[tuple[str, str]] = []
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ class ClassifierConfig:
|
|||||||
enabled: bool = False # off by default; opt-in
|
enabled: bool = False # off by default; opt-in
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AthleteConfig:
|
||||||
|
max_hr: int | None = None
|
||||||
|
ftp_w: int | None = None
|
||||||
|
hr_zones: list[list[int]] | None = None # [[lo, hi], ...]
|
||||||
|
power_zones: list[list[int]] | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ExtractConfig:
|
class ExtractConfig:
|
||||||
input_dirs: list[Path]
|
input_dirs: list[Path]
|
||||||
@@ -39,6 +47,7 @@ class ExtractConfig:
|
|||||||
incremental: bool = True
|
incremental: bool = True
|
||||||
owner_handle: str = "me"
|
owner_handle: str = "me"
|
||||||
owner_display_name: str = "Me"
|
owner_display_name: str = "Me"
|
||||||
|
athlete: AthleteConfig | None = None
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: Path) -> ExtractConfig:
|
def load_config(path: Path) -> ExtractConfig:
|
||||||
@@ -70,6 +79,14 @@ def load_config(path: Path) -> ExtractConfig:
|
|||||||
cls_raw = raw.get("classifier", {})
|
cls_raw = raw.get("classifier", {})
|
||||||
classifier = ClassifierConfig(enabled=cls_raw.get("enabled", False))
|
classifier = ClassifierConfig(enabled=cls_raw.get("enabled", False))
|
||||||
|
|
||||||
|
ath_raw = raw.get("athlete", {})
|
||||||
|
athlete = AthleteConfig(
|
||||||
|
max_hr=ath_raw.get("max_hr"),
|
||||||
|
ftp_w=ath_raw.get("ftp_w"),
|
||||||
|
hr_zones=ath_raw.get("hr_zones"),
|
||||||
|
power_zones=ath_raw.get("power_zones"),
|
||||||
|
) if ath_raw else None
|
||||||
|
|
||||||
return ExtractConfig(
|
return ExtractConfig(
|
||||||
input_dirs=dirs,
|
input_dirs=dirs,
|
||||||
output_dir=out,
|
output_dir=out,
|
||||||
@@ -81,6 +98,7 @@ def load_config(path: Path) -> ExtractConfig:
|
|||||||
incremental=raw.get("incremental", True),
|
incremental=raw.get("incremental", True),
|
||||||
owner_handle=owner.get("handle", "me"),
|
owner_handle=owner.get("handle", "me"),
|
||||||
owner_display_name=owner.get("display_name", "Me"),
|
owner_display_name=owner.get("display_name", "Me"),
|
||||||
|
athlete=athlete,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -30,3 +30,21 @@ classifier:
|
|||||||
enabled: false # ML activity type classifier (requires scikit-learn extra)
|
enabled: false # ML activity type classifier (requires scikit-learn extra)
|
||||||
|
|
||||||
incremental: true # skip files whose hash hasn't changed since last run
|
incremental: true # skip files whose hash hasn't changed since last run
|
||||||
|
|
||||||
|
# athlete:
|
||||||
|
# max_hr: 182 # used to derive default HR zone display
|
||||||
|
# ftp_w: 280 # functional threshold power in watts
|
||||||
|
# hr_zones: # explicit bpm boundaries [[lo, hi], ...]
|
||||||
|
# - [0, 115] # Z1 recovery
|
||||||
|
# - [115, 137] # Z2 endurance
|
||||||
|
# - [137, 155] # Z3 tempo
|
||||||
|
# - [155, 169] # Z4 threshold
|
||||||
|
# - [169, 999] # Z5 VO2max
|
||||||
|
# power_zones: # explicit watt boundaries
|
||||||
|
# - [0, 168] # Z1 active recovery (< 55% FTP)
|
||||||
|
# - [168, 224] # Z2 endurance (55–78%)
|
||||||
|
# - [224, 266] # Z3 tempo (78–95%)
|
||||||
|
# - [266, 308] # Z4 threshold (95–109%)
|
||||||
|
# - [308, 364] # Z5 VO2max (109–130%)
|
||||||
|
# - [364, 420] # Z6 anaerobic (130–150%)
|
||||||
|
# - [420, 9999] # Z7 neuromuscular (> 150%)
|
||||||
|
|||||||
@@ -30,3 +30,21 @@ classifier:
|
|||||||
enabled: false # ML activity type classifier (requires scikit-learn extra)
|
enabled: false # ML activity type classifier (requires scikit-learn extra)
|
||||||
|
|
||||||
incremental: true # skip files whose hash hasn't changed since last run
|
incremental: true # skip files whose hash hasn't changed since last run
|
||||||
|
|
||||||
|
athlete:
|
||||||
|
max_hr: 190
|
||||||
|
ftp_w: 210
|
||||||
|
hr_zones: # 5-zone Coggan, % of max HR 190 bpm
|
||||||
|
- [0, 104] # Z1 recovery < 55%
|
||||||
|
- [104, 142] # Z2 endurance 55–75%
|
||||||
|
- [142, 165] # Z3 tempo 75–87%
|
||||||
|
- [165, 176] # Z4 threshold 87–93%
|
||||||
|
- [176, 999] # Z5 VO2max > 93%
|
||||||
|
power_zones: # 7-zone Coggan, % of FTP 210 W
|
||||||
|
- [0, 115] # Z1 active recovery < 55%
|
||||||
|
- [115, 157] # Z2 endurance 55–75%
|
||||||
|
- [157, 189] # Z3 tempo 75–90%
|
||||||
|
- [189, 220] # Z4 threshold 90–105%
|
||||||
|
- [220, 252] # Z5 VO2max 105–120%
|
||||||
|
- [252, 315] # Z6 anaerobic 120–150%
|
||||||
|
- [315, 9999] # Z7 neuromuscular > 150%
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Plot from '@observablehq/plot';
|
import * as Plot from '@observablehq/plot';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { Timeseries } from '../lib/types';
|
import type { Timeseries, AthleteZones } from '../lib/types';
|
||||||
|
|
||||||
export let timeseries: Timeseries;
|
export let timeseries: Timeseries;
|
||||||
// Linked hover: emit/receive index into timeseries arrays
|
// Linked hover: emit/receive index into timeseries arrays
|
||||||
export let hoveredIdx: number | null = null;
|
export let hoveredIdx: number | null = null;
|
||||||
|
export let athlete: AthleteZones | null = null;
|
||||||
|
|
||||||
|
const HR_ZONE_COLORS = ['#60a5fa', '#4ade80', '#facc15', '#fb923c', '#f87171'];
|
||||||
|
const PWR_ZONE_COLORS = ['#60a5fa', '#34d399', '#facc15', '#fb923c', '#f87171', '#c084fc', '#f43f5e'];
|
||||||
|
|
||||||
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
type Tab = 'elevation' | 'speed' | 'hr' | 'cadence' | 'power';
|
||||||
type XMode = 'time' | 'distance';
|
type XMode = 'time' | 'distance';
|
||||||
@@ -181,18 +185,58 @@
|
|||||||
function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) {
|
function renderHistogram(w: number, h: number, yKey: string, yLabel: string, color: string) {
|
||||||
const yTickFormat = (v: number) => v >= 60 ? `${Math.round(v / 60)}m` : `${v}s`;
|
const yTickFormat = (v: number) => v >= 60 ? `${Math.round(v / 60)}m` : `${v}s`;
|
||||||
|
|
||||||
return Plot.plot({
|
const marks: any[] = [
|
||||||
width: w, height: h, marginLeft: 48, marginBottom: 32,
|
|
||||||
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
|
|
||||||
x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] },
|
|
||||||
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
|
|
||||||
marks: [
|
|
||||||
Plot.rectY(histData, Plot.binX(
|
Plot.rectY(histData, Plot.binX(
|
||||||
{ y: 'count' },
|
{ y: 'count' },
|
||||||
{ x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds },
|
{ x: yKey, fill: color, fillOpacity: 0.7, thresholds: histThresholds },
|
||||||
)),
|
)),
|
||||||
Plot.ruleY([0], { stroke: '#52525b' }),
|
Plot.ruleY([0], { stroke: '#52525b' }),
|
||||||
],
|
];
|
||||||
|
|
||||||
|
const rawZones = activeTab === 'hr' ? athlete?.hr_zones : activeTab === 'power' ? athlete?.power_zones : null;
|
||||||
|
const zoneColors = activeTab === 'hr' ? HR_ZONE_COLORS : PWR_ZONE_COLORS;
|
||||||
|
|
||||||
|
if (rawZones?.length) {
|
||||||
|
// Boundary vertical lines (interior boundaries only, skip first lo and last hi)
|
||||||
|
const boundaries = rawZones.slice(0, -1).map((z, i) => ({
|
||||||
|
x: z[1], // the upper bound of each zone = lower bound of the next
|
||||||
|
color: zoneColors[i + 1] ?? zoneColors[zoneColors.length - 1],
|
||||||
|
})).filter(b => b.x > trimMin && b.x < trimMax);
|
||||||
|
|
||||||
|
// Zone midpoints for labels
|
||||||
|
const labels = rawZones.map((z, i) => ({
|
||||||
|
mid: (Math.max(z[0], trimMin) + Math.min(z[1], trimMax)) / 2,
|
||||||
|
label: `Z${i + 1}`,
|
||||||
|
color: zoneColors[i] ?? zoneColors[zoneColors.length - 1],
|
||||||
|
visible: z[1] > trimMin && z[0] < trimMax,
|
||||||
|
})).filter(l => l.visible && l.mid >= trimMin && l.mid <= trimMax);
|
||||||
|
|
||||||
|
marks.push(
|
||||||
|
Plot.ruleX(boundaries, {
|
||||||
|
x: 'x',
|
||||||
|
stroke: (d: any) => d.color,
|
||||||
|
strokeWidth: 1,
|
||||||
|
strokeOpacity: 0.5,
|
||||||
|
strokeDasharray: '4,3',
|
||||||
|
}),
|
||||||
|
Plot.text(labels, {
|
||||||
|
x: 'mid',
|
||||||
|
text: 'label',
|
||||||
|
fill: (d: any) => d.color,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: '600',
|
||||||
|
frameAnchor: 'top',
|
||||||
|
dy: 6,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Plot.plot({
|
||||||
|
width: w, height: h, marginLeft: 48, marginBottom: 32,
|
||||||
|
style: { background: 'transparent', color: '#a1a1aa', fontSize: '11px' },
|
||||||
|
x: { label: yLabel, grid: false, ticks: 6, domain: [trimMin, trimMax] },
|
||||||
|
y: { label: 'Time', grid: true, tickCount: 4, tickFormat: yTickFormat },
|
||||||
|
marks,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import type { ActivitySummary, ActivityDetail } from '../lib/types';
|
import type { ActivitySummary, ActivityDetail, AthleteZones } from '../lib/types';
|
||||||
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
|
import { formatDistance, formatDuration, formatElevation, formatSpeed, formatDate, formatTime, sportIcon, sportLabel, sportColor } from '../lib/format';
|
||||||
import ActivityMap from './ActivityMap.svelte';
|
import ActivityMap from './ActivityMap.svelte';
|
||||||
import ActivityCharts from './ActivityCharts.svelte';
|
import ActivityCharts from './ActivityCharts.svelte';
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
export let activity: ActivitySummary;
|
export let activity: ActivitySummary;
|
||||||
export let base: string = '/';
|
export let base: string = '/';
|
||||||
|
export let athlete: AthleteZones | null = null;
|
||||||
|
|
||||||
const editUrl = import.meta.env.PUBLIC_EDIT_URL;
|
const editUrl = import.meta.env.PUBLIC_EDIT_URL;
|
||||||
|
|
||||||
@@ -237,7 +238,7 @@
|
|||||||
<p class="text-red-400 text-sm mt-4">{error}</p>
|
<p class="text-red-400 text-sm mt-4">{error}</p>
|
||||||
{:else if detail?.timeseries && detail.timeseries.t.length > 0}
|
{:else if detail?.timeseries && detail.timeseries.t.length > 0}
|
||||||
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
||||||
<ActivityCharts timeseries={detail.timeseries} bind:hoveredIdx />
|
<ActivityCharts timeseries={detail.timeseries} bind:hoveredIdx {athlete} />
|
||||||
</div>
|
</div>
|
||||||
{:else if !detail}
|
{:else if !detail}
|
||||||
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse" />
|
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse" />
|
||||||
|
|||||||
@@ -28,9 +28,16 @@ export interface ActivitySummary {
|
|||||||
preview_coords: [number, number][] | null;
|
preview_coords: [number, number][] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AthleteZones {
|
||||||
|
max_hr?: number;
|
||||||
|
ftp_w?: number;
|
||||||
|
hr_zones?: [number, number][];
|
||||||
|
power_zones?: [number, number][];
|
||||||
|
}
|
||||||
|
|
||||||
export interface BASIndex {
|
export interface BASIndex {
|
||||||
bas_version: string;
|
bas_version: string;
|
||||||
owner: { handle: string; display_name: string; avatar_url: string | null };
|
owner: { handle: string; display_name: string; avatar_url: string | null; athlete?: AthleteZones };
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
shards: Array<{ year: number; url: string; count: number }>;
|
shards: Array<{ year: number; url: string; count: number }>;
|
||||||
activities: ActivitySummary[];
|
activities: ActivitySummary[];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import Base from '../../layouts/Base.astro';
|
import Base from '../../layouts/Base.astro';
|
||||||
import ActivityDetail from '../../components/ActivityDetail.svelte';
|
import ActivityDetail from '../../components/ActivityDetail.svelte';
|
||||||
import type { BASIndex, ActivitySummary } from '../../lib/types';
|
import type { BASIndex, ActivitySummary, AthleteZones } from '../../lib/types';
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const dataDir = process.env.BINCIO_DATA_DIR
|
const dataDir = process.env.BINCIO_DATA_DIR
|
||||||
@@ -15,13 +15,13 @@ export async function getStaticPaths() {
|
|||||||
.filter(a => a.privacy !== 'private' && a.id)
|
.filter(a => a.privacy !== 'private' && a.id)
|
||||||
.map(a => ({
|
.map(a => ({
|
||||||
params: { id: a.id },
|
params: { id: a.id },
|
||||||
props: { activity: a },
|
props: { activity: a, athlete: index.owner.athlete ?? null },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { activity } = Astro.props as { activity: ActivitySummary };
|
const { activity, athlete } = Astro.props as { activity: ActivitySummary; athlete: AthleteZones | null };
|
||||||
const base = import.meta.env.BASE_URL;
|
const base = import.meta.env.BASE_URL;
|
||||||
---
|
---
|
||||||
<Base title={`${activity.title} — BincioActivity`}>
|
<Base title={`${activity.title} — BincioActivity`}>
|
||||||
<ActivityDetail {activity} {base} client:only="svelte" />
|
<ActivityDetail {activity} {base} {athlete} client:only="svelte" />
|
||||||
</Base>
|
</Base>
|
||||||
|
|||||||
Reference in New Issue
Block a user