diff --git a/AGENTS.md b/AGENTS.md index 8923e61..6896fbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # PROJECT KNOWLEDGE BASE -**Generated:** 2026-08-30 +**Generated:** 2026-09-02 ## OVERVIEW Project: **Cycling View** @@ -21,11 +21,20 @@ backend/app/ services/ streams.py (lru_cached parquet load + LTTB downsample), power_bests.py (mean-max power, windows, leaderboards, recompute) frontend/src/ - App.svelte svelte-routing shell (header, settings cog) - routes/ RidesList.svelte, RideDetail.svelte + App.svelte svelte-routing shell: header (desktop settings cog), mobile bottom tab bar + (Rides | + upload | Stats & Bests), floating settings gear FAB (mobile), + stats/bests BottomSheet (must stay inside ) + routes/ RidesList.svelte (dual layout: desktop table + aside filters / mobile cards + + toolbar + filter sheet; RangeSlider distance/duration filters, pagination + with mobile swipe-to-paginate (touch/mouse parallax + chevron arrow), + blur loading overlay), RideDetail.svelte lib/ api.ts (typed fetch client — single source of API truth), i18n.ts (de/en, - default de), settings.ts, charts/StreamChart.svelte (synced uPlot), - RouteMap.svelte, StatsPanel.svelte, UploadDialog.svelte, PowerBestsTable.svelte + default de), settings.ts, upload.ts (writable counter that opens UploadDialog + from anywhere), BottomSheet.svelte (backdrop sheet: Esc / backdrop click / + gated swipe-down), RangeSlider.svelte (dual-knob, two overlapping native + range inputs), charts/StreamChart.svelte (synced uPlot), RouteMap.svelte, + StatsPanel.svelte, PowerBestsTable.svelte, SettingsDialog.svelte, + UploadDialog.svelte tests/backend/ pytest suite mirroring app layout (parsers/, routers/, services/) data/ GENERATED, gitignored: cycling.db + streams/*.parquet + uploads/ docker/ backend.Dockerfile (python:3.12-slim), frontend.Dockerfile (node→nginx), nginx.conf @@ -56,7 +65,7 @@ compose.yaml docker compose: backend:8000, frontend:8080 ## API SURFACE (summary) - `GET /api/health` - Bikes: `GET/POST /api/bikes`, `PATCH/DELETE /api/bikes/{id}` (delete refused while rides assigned) -- Rides: `GET /api/rides?bike_id&date_from&date_to&limit&offset` → `{total, items}`; `GET/PATCH/DELETE /api/rides/{id}` +- Rides: `GET /api/rides?bike_id&date_from&date_to&min_km&max_km&min_hours&max_hours&limit&offset` → `{total, items}` (`limit=0` = no limit); `GET /api/rides/bounds` → distance/duration min-max for filter sliders; `GET/PATCH/DELETE /api/rides/{id}` (bounds route must stay before `/{id}`) - Streams: `GET /api/rides/{id}/streams?fields=a,b&n_points=0` (0 = all points; else LTTB) - Geo: `GET /api/rides/{id}/geo` → GeoJSON LineString + parallel `streams` for color overlays - Upload: `POST /api/uploads` (multipart: file, name, bike_id, activity_type, description) @@ -72,6 +81,15 @@ compose.yaml docker compose: backend:8000, frontend:8080 * **CI**: `.github/workflows/backend-tests.yml` (pytest on push/PR, Python 3.13) ## NOTES +* **Responsive design**: one codebase; mobile ≤768px (bottom tab bar, settings FAB, bottom sheets, ride cards) vs desktop ≥769px (table + aside filters). Shared tokens live in `app.css` (`--bg/--panel/--panel-2/--border/--text/--muted/--accent`, `--touch` ≥44px, `--elev-*`); component styles are scoped. **No hardcoded colors — use the tokens.** +* **Anything rendering `` must stay inside ``**: svelte-routing's Link dereferences router context and throws outside it (crashed the app-level stats sheet once; the sheet is mounted inside the Router on purpose). +* `BottomSheet.svelte` closes on Escape, backdrop click, and swipe-down — the swipe is gated: sheet content scrolled to top AND finger not on `input/button/select/textarea/a/summary`. +* Pagination convention: `limit=0` = no limit (rides API), same as `n_points=0` = all points (streams API); frontend per-page 25/50/100/0 ("All"). Keep the 0-means-all convention when adding new list endpoints. +* Distance/duration filters (RangeSlider): bounds come from `GET /api/rides/bounds`; a knob parked on the domain edge sends **no** param (unfiltered); the list refetches on knob release, not per drag. +* `lib/upload.ts` exports writable counter `uploadRequest`; bumping it opens UploadDialog (used by the mobile tab bar `+`). +* svelte-check baseline: 3 pre-existing errors + 12 warnings (RouteMap GeoJSON typing) — don't chase; `npm run build` is the gate. +* iOS Safari renders `input[type=date]` wider than its CSS box (segments + clear button scale with the text-size setting) — in the mobile filter sheet the date bar can sit flush to the sheet's right edge on some devices. Blind CSS fixes (grid `1fr`, `min-width: 0`, `fit-content`, `text-align: center`) were tried and reverted without resolving it; don't retry blind, get a real-device screenshot first. +* Known contrast debt: `--muted` and `--accent` pass WCAG AA but fail APCA Lc 75 for small text; re-tuning the palette is a design decision, not a code fix. * **Two ID schemes**: Strava-ingested activities keep their real Strava `id`; uploaded files get an id of first trackpoint timestamp in **microseconds**. Upload source files are copied to `data/uploads/` as `{id}{suffixes}`; parquet to `data/streams/{id}.parquet`. * `estimated_power` flag = stream has power but Strava marked "Device Watts" false (estimated, not measured). UI surfaces it. * `config.py` defaults: `data_dir = /data`, `repo_root = /`; override with `CV_DATA_DIR` / `CV_REPO_ROOT` env vars (Docker does this). CORS origins are hardcoded to localhost:5173/8080. diff --git a/backend/app/routers/rides.py b/backend/app/routers/rides.py index d713be3..01ddc97 100644 --- a/backend/app/routers/rides.py +++ b/backend/app/routers/rides.py @@ -30,7 +30,11 @@ def list_rides( bike_id: int | None = None, date_from: str | None = None, date_to: str | None = None, - limit: int = Query(100, ge=1, le=1000), + min_km: float | None = Query(None, ge=0), + max_km: float | None = Query(None, ge=0), + min_hours: float | None = Query(None, ge=0), + max_hours: float | None = Query(None, ge=0), + limit: int = Query(0, ge=0, description="Page size; 0 = no limit"), offset: int = Query(0, ge=0), ) -> dict[str, Any]: where = [] @@ -44,23 +48,37 @@ def list_rides( if date_to: where.append("a.start_time <= ?") params.append(date_to) + # Distance filters in km, duration filters in hours; DB stores m / s. + if min_km is not None: + where.append("a.distance_m >= ?") + params.append(min_km * 1000) + if max_km is not None: + where.append("a.distance_m <= ?") + params.append(max_km * 1000) + if min_hours is not None: + where.append("a.moving_s >= ?") + params.append(min_hours * 3600) + if max_hours is not None: + where.append("a.moving_s <= ?") + params.append(max_hours * 3600) where_sql = ("WHERE " + " AND ".join(where)) if where else "" with get_conn() as conn: total = conn.execute( f"SELECT COUNT(*) AS c FROM activities a {where_sql}", params ).fetchone()["c"] - rows = conn.execute( - f""" + sql = f""" SELECT a.*, b.name AS bike_name, b.brand AS bike_brand, b.model AS bike_model FROM activities a LEFT JOIN bikes b ON a.bike_id = b.id {where_sql} ORDER BY a.start_time DESC - LIMIT ? OFFSET ? - """, - [*params, limit, offset], - ).fetchall() + """ + p: list[Any] = list(params) + if limit > 0: + sql += " LIMIT ? OFFSET ?" + p += [limit, offset] + rows = conn.execute(sql, p).fetchall() return { "total": total, @@ -70,6 +88,19 @@ def list_rides( } +@router.get("/rides/bounds") +def ride_bounds() -> dict[str, Any]: + """Global min/max of distance and duration; the frontend uses them as slider domains.""" + with get_conn() as conn: + dmin, dmax, smin, smax = conn.execute( + "SELECT MIN(distance_m), MAX(distance_m), MIN(moving_s), MAX(moving_s) FROM activities" + ).fetchone() + return { + "distance_m": [dmin, dmax] if dmin is not None else None, + "moving_s": [smin, smax] if smin is not None else None, + } + + def _fetch_activity(activity_id: int): with get_conn() as conn: row = conn.execute( diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py index 8293dae..07f07a6 100644 --- a/backend/app/routers/stats.py +++ b/backend/app/routers/stats.py @@ -84,7 +84,11 @@ def delete_bike(bike_id: int) -> dict[str, Any]: # --- Aggregated stats -------------------------------------------------------- @router.get("/stats") -def overall_stats() -> dict[str, Any]: +def overall_stats(year: int = 0) -> dict[str, Any]: + """Global stats. With ?year=N the per-bike / unassigned tables are + restricted to that year (totals, longest and per-year stay all-time).""" + if year and not (1900 <= year <= 2100): + raise HTTPException(400, "year must be between 1900 and 2100") with get_conn() as conn: totals = dict(conn.execute( """ @@ -136,19 +140,24 @@ def overall_stats() -> dict[str, Any]: MAX(a.start_time) AS last_ride FROM bikes b LEFT JOIN activities a ON a.bike_id = b.id + AND (? = 0 OR substr(a.start_time, 1, 4) = CAST(? AS TEXT)) GROUP BY b.id, b.name ORDER BY distance_m DESC - """ + """, + (year, str(year)) ).fetchall()] - # Activities without an assigned bike. + # Activities without an assigned bike (same year filter). unassigned = dict(conn.execute( """ SELECT COUNT(*) AS rides, COALESCE(SUM(distance_m), 0) AS distance_m, COALESCE(SUM(moving_s), 0) AS moving_s - FROM activities WHERE bike_id IS NULL - """ + FROM activities + WHERE bike_id IS NULL + AND (? = 0 OR substr(start_time, 1, 4) = CAST(? AS TEXT)) + """, + (year, str(year)) ).fetchone()) return { diff --git a/do b/do index 3859e76..946227d 100755 --- a/do +++ b/do @@ -29,8 +29,8 @@ start_one() { # $1 = backend|frontend — the port is the source of truth echo "frontend: missing node_modules — run: npm install --prefix frontend" >&2 return 1 } - # one pid (exec), and vite roots at cwd, so cd first - nohup bash -c 'cd frontend && exec node node_modules/.bin/vite' >>"$RUNTIME_DIR/frontend.log" 2>&1 & + # one pid (exec), and vite roots at cwd, so cd first, expose dev server on all interfaces, and redirect logs to a file + nohup bash -c 'cd frontend && exec node node_modules/.bin/vite -- --host 0.0.0.0' >>"$RUNTIME_DIR/frontend.log" 2>&1 & fi pid=$! echo "$pid" > "$RUNTIME_DIR/$1.pid" diff --git a/docs/plan-mobile-pwa.md b/docs/plan-mobile-pwa.md new file mode 100644 index 0000000..de2cea9 --- /dev/null +++ b/docs/plan-mobile-pwa.md @@ -0,0 +1,136 @@ +# Plan: Mobile PWA + +**Goal:** make Cycling View comfortable on a phone in the saddle / at the desk, and +installable as a PWA. Desktop keeps the current layout untouched. + +**Design system:** repo has no DESIGN.md, but `app.css` already defines a working +system (dark `#0f1115`/`#181c23`, Strava orange accent, field colours). We **reuse it** +(YAGNI applies to design systems) and extend it with mobile-specific tokens +(touch targets, pressed states, safe areas). No new colour, accent, or font. +Mobile rules follow the anti-slop mobile baseline: ≥44px targets, `:active` instead +of hover-only feedback, 16px body min (iOS zoom), safe-area insets. + +--- + +## Phase 1 — PWA foundation (no UI changes) + +1. **`frontend/public/manifest.webmanifest`** — name, `display: standalone`, + `theme_color: #0f1115`, `background_color: #0f1115`, 192 + 512 icons + (one generated SVG-rasterised icon is enough; bike glyph on dark bg, orange accent). +2. **Hand-rolled service worker (`public/sw.js`, ~40 lines)** — no new dependency: + - App shell (JS/CSS/index.html): cache-first, versioned cache name, clean old versions on activate. + - API (`/api/*`): network-only, never cached (data must stay fresh). + - This gives: install prompt, app icon, launch screen, and **offline = last-loaded + screens still work, API errors show as the existing `.error` div**. That is the + honest scope; offline-first data sync is out of scope (would need backend changes). +3. **`index.html`** — link manifest, ``, + `viewport` + `viewport-fit=cover`, apple-mobile-web-app-capable + status bar style. +4. **Register SW in `main.ts`** (`if ('serviceWorker' in navigator)`, prod only). +5. Install: rely on the browser-native install prompt (Chrome/Android + iOS "Add to + Home Screen"). No custom `beforeinstallprompt` UI — YAGNI until we have one. + +Backend: **nothing to change**. nginx serves `sw.js`/manifest from the SPA static dir. + +## Phase 2 — Mobile layouts (breakpoint `max-width: 768px`, mobile styles only) + +### 2a. RidesList — table → card list + +The 10-column sortable table (min-width 800px + h-scroll + hidden columns) is the +least usable screen on a phone. Replace with, **below 768px only**: + +- **Ride cards, one per row**: `name` (primary), `date · km · duration` (muted line), + bike name if set. Tap anywhere → detail (whole card ≥44px, ``). + `estimated_power` `*` stays next to nothing here (moves to detail). +- **Sort**: single compact ` dispatch("change")} + /> + dispatch("change")} + /> + +
+ {fmt(lo)} {unit} + {fmt(hi)} {unit} +
+ + + \ No newline at end of file diff --git a/frontend/src/lib/SettingsAnalysis.svelte b/frontend/src/lib/SettingsAnalysis.svelte new file mode 100644 index 0000000..b4bf74f --- /dev/null +++ b/frontend/src/lib/SettingsAnalysis.svelte @@ -0,0 +1,80 @@ + + +
+

{$t("settings.analysis")}

+ + + + + + + + {#if $settings.hidePauses} +
+ + +
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/lib/SettingsBikes.svelte b/frontend/src/lib/SettingsBikes.svelte new file mode 100644 index 0000000..5549347 --- /dev/null +++ b/frontend/src/lib/SettingsBikes.svelte @@ -0,0 +1,123 @@ + + +
+

{$t("settings.bikes")}

+ {#if bikesError}{bikesError}{/if} + {#if bikes.length} +
    + {#each bikes as b} +
  • + {b.name} + {#if b.brand || b.model} + {[b.brand, b.model].filter(Boolean).join(" ")} + {/if} + +
  • + {/each} +
+ {/if} +
+ + + + +
+
+ + \ No newline at end of file diff --git a/frontend/src/lib/SettingsData.svelte b/frontend/src/lib/SettingsData.svelte new file mode 100644 index 0000000..b48eb57 --- /dev/null +++ b/frontend/src/lib/SettingsData.svelte @@ -0,0 +1,145 @@ + + +
+

{$t("settings.data")}

+ +
+ + {#if ingestMsg}{ingestMsg}{/if} +
+ +
+ + {#if recomputeMsg}{recomputeMsg}{/if} +
+ +
+ +
+ +
+ + + {#if importMsg}{importMsg}{/if} +
+
+ + \ No newline at end of file diff --git a/frontend/src/lib/SettingsDialog.svelte b/frontend/src/lib/SettingsDialog.svelte index 2c5280f..afb86ce 100644 --- a/frontend/src/lib/SettingsDialog.svelte +++ b/frontend/src/lib/SettingsDialog.svelte @@ -1,133 +1,16 @@ {#if open} @@ -144,169 +27,12 @@

{$t("settings.title")}

- -
-

{$t("settings.profile")}

-
- -
- - -
-
-
- -
- - kg -
-
-
- -
- - {$t("settings.years")} -
-
-
- -
- - -
-
-
- -
- - {$t("settings.bpm")} -
-
-
- -
- - -
-
-
- -
-

{$t("settings.analysis")}

- - - - - - - - {#if $settings.hidePauses} -
- - -
- {/if} -
- -
-

{$t("settings.bikes")}

- {#if bikesError}{bikesError}{/if} - {#if bikes.length} -
    - {#each bikes as b} -
  • - {b.name} - {#if b.brand || b.model} - {[b.brand, b.model].filter(Boolean).join(" ")} - {/if} - -
  • - {/each} -
- {/if} -
- - - - -
-
- -
-

{$t("settings.data")}

- -
- - {#if ingestMsg}{ingestMsg}{/if} -
- -
- - {#if recomputeMsg}{recomputeMsg}{/if} -
- -
- -
- -
- - - {#if importMsg}{importMsg}{/if} -
-
- +
+ + + + +
@@ -334,7 +60,11 @@ overflow: auto; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); } - .head { display: flex; align-items: center; justify-content: space-between; } + .head { + display: flex; + align-items: center; + justify-content: space-between; + } .head h3 { margin: 0; } .x { background: transparent; @@ -344,126 +74,23 @@ cursor: pointer; padding: 4px 8px; } - section { margin-top: 18px; } - section h4 { - margin: 0 0 8px; - font-size: 12px; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.06em; - } - .field { + /* Sections are self-styled components; the flex gap spaces them. */ + .body { + margin-top: 4px; display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 6px 0; - border-bottom: 1px solid var(--border); + flex-direction: column; + gap: 18px; } - .field:last-child { border-bottom: none; } - .field label { - font-size: 14px; - flex-shrink: 0; - } - .field-input { + .actions { display: flex; - align-items: center; - gap: 8px; - width: 190px; justify-content: flex-end; + margin-top: 18px; } - .field-input input, .field-input select { - flex: 1; - min-width: 0; - text-align: right; - box-sizing: border-box; - } - .unit { - width: 45px; - flex-shrink: 0; - color: var(--muted); - font-size: 13px; - text-align: left; - white-space: nowrap; - } - .row { - display: flex; - align-items: flex-start; - gap: 10px; - margin-bottom: 10px; - flex-wrap: wrap; - } - .row > span { display: flex; flex-direction: column; } - .row > span strong { font-weight: 500; } - .row > span small { color: var(--muted); font-size: 12px; } - .sub { - margin: -4px 0 12px 26px; - display: flex; - gap: 16px; - flex-wrap: wrap; - } - .inline { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 13px; - } - .inline.tight { margin-left: 4px; } - .inline input[type="number"] { width: 60px; } - .wide { width: 100%; text-align: left; display: inline-flex; align-items: center; gap: 8px; } - .file-import { - display: inline-flex; - align-items: center; - width: 100%; - padding: 6px 10px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--bg, transparent); - cursor: pointer; - font-size: 13px; - box-sizing: border-box; - } - .file-import span { display: inline-flex; align-items: center; gap: 8px; } - .file-import input { display: none; } - .file-import:hover { background: rgba(255, 255, 255, 0.04); } - .status { display: block; color: var(--muted); margin-top: 4px; width: 100%; } - .error-text { color: #ef4444; } - .bike-list { - list-style: none; - padding: 0; - margin: 0 0 8px; - } - .bike-list li { - display: flex; - align-items: center; - gap: 8px; - padding: 4px 0; - border-bottom: 1px solid var(--border); - font-size: 13px; - } - .bike-list .bname { flex: 1; } - .bike-list .dim { color: var(--muted); } - .bike-list .del { - background: transparent; - border: none; - color: var(--muted); - cursor: pointer; - padding: 2px 6px; - } - .bike-list .del:hover { color: #ef4444; } - .add-bike { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 6px; - margin-top: 4px; - } - .add-bike input { width: 100%; box-sizing: border-box; } - .add-bike button { grid-column: 1 / -1; } - .actions { display: flex; justify-content: flex-end; margin-top: 18px; } .primary { background: var(--accent); - color: white; + color: var(--bg); + font-weight: 600; border-color: var(--accent); } .primary:hover { filter: brightness(1.1); } - + \ No newline at end of file diff --git a/frontend/src/lib/SettingsProfile.svelte b/frontend/src/lib/SettingsProfile.svelte new file mode 100644 index 0000000..65224f7 --- /dev/null +++ b/frontend/src/lib/SettingsProfile.svelte @@ -0,0 +1,112 @@ + + +
+

{$t("settings.profile")}

+ + + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/frontend/src/lib/SettingsSheet.svelte b/frontend/src/lib/SettingsSheet.svelte new file mode 100644 index 0000000..dbedc70 --- /dev/null +++ b/frontend/src/lib/SettingsSheet.svelte @@ -0,0 +1,153 @@ + + + +
+ (page = e.detail)} ariaLabel={$t("settings.title")} /> +
+ + +
+
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/frontend/src/lib/StatsPanel.svelte b/frontend/src/lib/StatsPanel.svelte index 90a1671..c2425f4 100644 --- a/frontend/src/lib/StatsPanel.svelte +++ b/frontend/src/lib/StatsPanel.svelte @@ -7,14 +7,31 @@ let stats: OverallStats | null = null; let error = ""; + /** Bump to force a re-fetch (e.g. after a ride upload). 0 = initial mount only. */ + export let reloadKey = 0; - onMount(async () => { + /** Year filter for the per-bike table. 0 = all-time (default). */ + let bikeYear = 0; + let bikeYearLoaded = 0; + let bikeOpen = true; // expanded per default + let bikeLoading = false; + + async function load() { + if (bikeYear !== 0) bikeLoading = true; try { - stats = await api.stats(); + stats = await api.stats(bikeYear || undefined); + bikeYearLoaded = bikeYear; } catch (e: any) { error = e?.message ?? String(e); + } finally { + bikeLoading = false; } - }); + } + + onMount(load); + $: if (reloadKey > 0) load(); + $: if (bikeYear !== bikeYearLoaded) load(); + $: if (bikeYear !== 0) bikeOpen = true; {#if error} @@ -71,9 +88,19 @@ {/if} {#if stats.per_bike.length} -
- {$t("stats.per_bike")} - +
+ + {$t("stats.per_bike")} + {#if stats.per_year.length} + + {/if} + +
{#each stats.per_bike as b} @@ -119,7 +146,35 @@ text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 6px; + /* flex + custom ::before arrow: the native ::marker jumps to its own + line as soon as the summary holds block-level children. */ + display: flex; + align-items: center; + gap: 8px; + list-style: none; + } + details summary::-webkit-details-marker { display: none; } + details summary::before { + content: "▸"; + flex: none; + font-size: 11px; + transition: transform 0.12s; + } + details[open] > summary::before { transform: rotate(90deg); } + .year-select { + margin-left: auto; /* push to the right edge of the summary */ + font-size: 11px; + font-family: inherit; + color: var(--text); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 6px; + min-height: 24px; + text-transform: none; + letter-spacing: normal; } + table.ystats.loading th, table.ystats.loading td { opacity: 0.4; } table.ystats { width: 100%; font-size: 12px; diff --git a/frontend/src/lib/StatsSheet.svelte b/frontend/src/lib/StatsSheet.svelte new file mode 100644 index 0000000..0639b78 --- /dev/null +++ b/frontend/src/lib/StatsSheet.svelte @@ -0,0 +1,140 @@ + + + +
+ (page = e.detail)} ariaLabel={$t("stats.andBests")} /> +
+ +
+ +
+
+ +
+
+ +
+
+
+ + \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2d040da..7798d71 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -145,6 +145,10 @@ export const api = { bike_id?: number; date_from?: string; date_to?: string; + min_km?: number; + max_km?: number; + min_hours?: number; + max_hours?: number; limit?: number; offset?: number; } = {}) => { @@ -156,6 +160,8 @@ export const api = { return get(`/rides${s ? `?${s}` : ""}`); }, ride: (id: number | string) => get(`/rides/${id}`), + rideBounds: () => + get<{ distance_m: [number, number] | null; moving_s: [number, number] | null }>("/rides/bounds"), updateRide: async ( id: number | string, @@ -217,7 +223,7 @@ export const api = { return res.json(); }, - stats: () => get("/stats"), + stats: (year?: number) => get(year ? `/stats?year=${year}` : "/stats"), powerBestsForRide: (id: number | string) => get<{ activity_id: number; windows_s: number[]; bests: PowerBest[] }>(`/power-bests/${id}`), diff --git a/frontend/src/lib/charts/StreamChart.svelte b/frontend/src/lib/charts/StreamChart.svelte index 9defab3..9409227 100644 --- a/frontend/src/lib/charts/StreamChart.svelte +++ b/frontend/src/lib/charts/StreamChart.svelte @@ -16,6 +16,9 @@ export let zones: { from: number; to: number; color: string }[] | null = null; export let lowQ = 0.005; export let highQ = 0.995; + /** Bump to clear any drag-to-select zoom (zero-size setSelect fires the setSelect + * hook, which nulls selectionRange). uPlot 1.7+ would offer clearSelection(). */ + export let resetSignal = 0; let container: HTMLDivElement; let plot: uPlot | null = null; @@ -85,7 +88,9 @@ stroke: AXIS_TEXT, grid: { stroke: GRID, width: 1 }, ticks: { stroke: TICK, width: 1 }, - size: 55, + // No explicit size: uPlot auto-sizes the axis from the label text, + // so the plot area stretches toward the card edge instead of + // always reserving 55px. }, ], series: [ @@ -110,7 +115,6 @@ const idx = u.cursor.idx; if (idx == null) { hoverTime.set(null); - selectionRange.set(null); } else { const t = u.data[0][idx]; hoverTime.set(typeof t === "number" ? t : null); @@ -120,8 +124,10 @@ setSelect: [ (u) => { const { left, width, show } = u.select; + // Zero-width is uPlot hiding its transient box (click, mouse-leave, + // dblclick) — NOT a zoom reset. selectionRange is our app-level zoom + // state: set here on drag, cleared only by the reset pill or dblclick. if (width <= 0 || show === false) { - selectionRange.set(null); return; } const t0 = u.posToVal(left, "x"); @@ -187,6 +193,16 @@ if (w > 0) plot.setSize({ width: w, height }); } + let _rs = 0; + $: if (resetSignal !== _rs) { + _rs = resetSignal; + plot?.setSelect({ left: 0, top: 0, width: 0, height: 0 }); + // Drag-select also rescaled the x axis (uPlot built-in zoom) — setData with + // resetScales=true restores the auto scale from the full data. + plot?.setData(toData(), true); + selectionRange.set(null); + } + let _lastZones: typeof zones = null; $: if (xs && ys && container) { @@ -210,13 +226,20 @@ ro = new ResizeObserver(() => resize()); ro.observe(container); window.addEventListener("resize", resize); - container.addEventListener("mouseup", () => selectionRange.set(null)); + // uPlot's native dblclick already runs autoScaleX() (zoom-out); mirror it + // for our zoom state so the reset pill + map zoom go away with it. (Also + // fires on touch double-tap.) + container.addEventListener("dblclick", onDblClick); }); + function onDblClick() { + selectionRange.set(null); + } + onDestroy(() => { window.removeEventListener("resize", resize); ro?.disconnect(); - container?.removeEventListener("mouseup", () => selectionRange.set(null)); + container?.removeEventListener("dblclick", onDblClick); plot?.destroy(); }); @@ -241,6 +264,10 @@ border-radius: 8px; padding: 10px 14px 14px; } + @media (max-width: 768px) { + .chart-card { padding: 8px 6px 10px; } + .title { padding-bottom: 4px; } + } .title { font-size: 13px; font-weight: 600; diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 1666c8f..6e584f5 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -20,7 +20,20 @@ export const dictionaries = { "rides.filter.all": "Alle", "rides.filter.from": "Von", "rides.filter.to": "Bis", + "rides.filter.dist": "Distanz (km)", + "rides.filter.dur": "Dauer (h)", "rides.filter.count": "{count} Fahrten", + "rides.title": "Fahrten", + "rides.filter.apply": "Anwenden", + "rides.sort": "Sortierung", + "rides.sort.newest": "Neueste", + "rides.sort.oldest": "Älteste", + "rides.sort.distance": "Distanz", + "rides.sort.duration": "Dauer", + "rides.sort.speed": "Speed", + "rides.sort.power": "Power", + "rides.sort.hr": "HR", + "rides.sort.elevation": "Höhe", "rides.upload": "Fahrt hochladen", "rides.col.date": "Datum", "rides.col.name": "Name", @@ -32,8 +45,13 @@ export const dictionaries = { "rides.col.np": "NP", "rides.col.hr": "⌀ HR", "rides.col.elevation": "↑ Höhe", + "rides.perPage": "Pro Seite", + "rides.page": "Seite {x} / {y}", + "rides.prev": "Zurück", + "rides.next": "Weiter", "stats.loading": "Lade Stats…", "stats.alltime": "Allzeit", + "stats.andBests": "Stats & Bestwerte", "stats.rides": "Fahrten", "stats.distance": "Distanz", "stats.duration": "Fahrzeit", @@ -46,6 +64,7 @@ export const dictionaries = { "stats.bike": "Rad", "stats.unassigned": "– ohne –", "ride.back": "← Zurück", + "ride.reset_zoom": "Zoom zurücksetzen", "ride.edit": "Bearbeiten", "ride.delete": "Löschen", "ride.save": "Speichern", @@ -96,6 +115,7 @@ export const dictionaries = { "power.bests.col.ride": "Fahrt", "power.bests.no_data": "Keine Power-Daten.", "power.bests.unit_title": "Aktuell: {unit} (Gewicht {weight} kg)", + "power.bests.short": "Power-Bestwerte", "settings.analysis": "Auswertung", "settings.exclude_zero_power": "Power: 0 W ausblenden", "settings.exclude_zero_power_hint": "Rollphasen aus Charts und Mittelwerten entfernen.", @@ -150,7 +170,20 @@ export const dictionaries = { "rides.filter.all": "All", "rides.filter.from": "From", "rides.filter.to": "To", + "rides.filter.dist": "Distance (km)", + "rides.filter.dur": "Duration (h)", "rides.filter.count": "{count} rides", + "rides.title": "Rides", + "rides.filter.apply": "Apply", + "rides.sort": "Sort", + "rides.sort.newest": "Newest", + "rides.sort.oldest": "Oldest", + "rides.sort.distance": "Distance", + "rides.sort.duration": "Duration", + "rides.sort.speed": "Speed", + "rides.sort.power": "Power", + "rides.sort.hr": "HR", + "rides.sort.elevation": "Elevation", "rides.upload": "Upload ride", "rides.col.date": "Date", "rides.col.name": "Name", @@ -162,8 +195,13 @@ export const dictionaries = { "rides.col.np": "NP", "rides.col.hr": "Avg HR", "rides.col.elevation": "Elevation", + "rides.perPage": "Per page", + "rides.page": "Page {x} / {y}", + "rides.prev": "Previous", + "rides.next": "Next", "stats.loading": "Loading stats…", "stats.alltime": "All-time", + "stats.andBests": "Stats & Bests", "stats.rides": "Rides", "stats.distance": "Distance", "stats.duration": "Moving Time", @@ -176,6 +214,7 @@ export const dictionaries = { "stats.bike": "Bike", "stats.unassigned": "– none –", "ride.back": "← Back", + "ride.reset_zoom": "Reset zoom", "ride.edit": "Edit", "ride.delete": "Delete", "ride.save": "Save", @@ -226,6 +265,7 @@ export const dictionaries = { "power.bests.col.ride": "Ride", "power.bests.no_data": "No power data.", "power.bests.unit_title": "Current: {unit} (Weight {weight} kg)", + "power.bests.short": "Power Bests", "settings.analysis": "Analysis", "settings.exclude_zero_power": "Hide 0 W power", "settings.exclude_zero_power_hint": "Remove coasting phases from charts and averages.", diff --git a/frontend/src/lib/upload.ts b/frontend/src/lib/upload.ts new file mode 100644 index 0000000..6d9899b --- /dev/null +++ b/frontend/src/lib/upload.ts @@ -0,0 +1,8 @@ +import { writable } from "svelte/store"; + +/** + * Monotonic counter bumped whenever something asks for the upload dialog + * (currently the mobile tab bar "+"). RidesList owns the dialog and opens + * it when this changes, so the request works from any screen. + */ +export const uploadRequest = writable(0); \ No newline at end of file diff --git a/frontend/src/routes/RideDetail.svelte b/frontend/src/routes/RideDetail.svelte index 49e6c98..c12ea5c 100644 --- a/frontend/src/routes/RideDetail.svelte +++ b/frontend/src/routes/RideDetail.svelte @@ -8,7 +8,8 @@ import StreamChart from "../lib/charts/StreamChart.svelte"; import RouteMap from "../lib/RouteMap.svelte"; import PowerBestsTable from "../lib/PowerBestsTable.svelte"; - import { hoverTime } from "../lib/hover"; + import BottomSheet from "../lib/BottomSheet.svelte"; + import { hoverTime, selectionRange } from "../lib/hover"; import { settings, applyStreamFilter, @@ -65,9 +66,15 @@ } } - async function deleteRide() { + // Delete confirm via bottom sheet (native confirm() renders as a broken + // blocking alert on iOS Safari). + let confirmOpen = false; + function deleteRide() { + if (ride) confirmOpen = true; + } + async function doDelete() { + confirmOpen = false; if (!ride) return; - if (!confirm($t("ride.delete_confirm").replace("{name}", String(ride.name ?? ride.id)))) return; try { await api.deleteRide(ride.id); navigate("/", { replace: true }); @@ -206,6 +213,29 @@ return arr.some((v) => v != null); } + // Charts: pill switcher raises the selected chart; primary is bigger. + type FieldKey = "elevation" | "speed" | "power" | "hr" | "cadence" | "temperature"; + let activeField: FieldKey = "power"; + let resetSignal = 0; + $: chartSpecs = [ + { f: "elevation" as FieldKey, label: $t("ride.chart.elevation"), unit: "m", xs: ts, ys: altitude, valueDigits: 0, highQ: 0.995, zones: null }, + { f: "speed" as FieldKey, label: $t("ride.chart.speed"), unit: "km/h", xs: ts, ys: speedKmh, valueDigits: 1, highQ: 0.999, zones: null }, + { f: "power" as FieldKey, label: $t("ride.card.power"), unit: "W", xs: ts, ys: power, valueDigits: 0, highQ: 0.995, zones: null }, + { f: "hr" as FieldKey, label: $t("ride.card.hr"), unit: "bpm", xs: ts, ys: hr, valueDigits: 0, highQ: 0.995, zones: hrZoneBands }, + { f: "cadence" as FieldKey, label: $t("ride.card.cadence"), unit: "rpm", xs: ts, ys: cadence, valueDigits: 0, highQ: 0.995, zones: null }, + { f: "temperature" as FieldKey, label: $t("ride.card.temperature"), unit: "°C", xs: ts, ys: temperature, valueDigits: 1, highQ: 0.995, zones: null }, + ].filter((s) => hasAny(s.ys)); + $: if (chartSpecs.length && !chartSpecs.some((s) => s.f === activeField)) { + // Guaranteed to find one: chartSpecs is non-empty and covers every FieldKey. + activeField = + (["power", "hr", "speed", "elevation", "cadence", "temperature"] as FieldKey[]) + .find((f) => chartSpecs.some((s) => s.f === f))!; + } + // Primary first (stable for the rest); pills stay in natural order. + $: renderSpecs = [...chartSpecs].sort( + (a, b) => Number(b.f === activeField) - Number(a.f === activeField) + ); + /** Find the index in `ts` whose value is closest to `t`. */ function nearestIndex(tsArr: number[], targetT: number): number { if (tsArr.length === 0) return -1; @@ -236,7 +266,7 @@ $: hoverZoneIdx = getZoneIdx(hoverHR); - history.back()}>{$t("ride.back")} + history.back()}>{$t("ride.back")} {#if error}
{error}
{/if} @@ -367,42 +397,60 @@ {#if streams} + {#if chartSpecs.length} +
+
+ {#each chartSpecs as s} + + {/each} +
+ {#if $selectionRange != null} + + {/if} +
+ {/if}
- {#if hasAny(altitude)} - - {/if} - {#if hasAny(speedKmh)} - - {/if} - {#if hasAny(power)} - - {/if} - {#if hasAny(hr)} - -
- {#if hrZones} - {#each hrZones as z, i} -
- {#if hoverZoneIdx === i} - {z.label} - {/if} -
- {/each} - {/if} -
-
- {/if} - {#if hasAny(cadence)} - - {/if} - {#if hasAny(temperature)} - - {/if} + {#each renderSpecs as s} + {@const primary = s.f === activeField} + {#if s.f === "hr"} + +
+ {#if hrZones} + {#each hrZones as z, i} +
+ {#if hoverZoneIdx === i} + {z.label} + {/if} +
+ {/each} + {/if} +
+
+ {:else} + + {/if} + {/each}
{/if} @@ -421,6 +469,18 @@

{$t("common.loading")}

{/if} + +
+

+ {$t("ride.delete_confirm").replace("{name}", String(ride?.name ?? ride?.id))} +

+
+ + +
+
+
+ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 491f843..1792b85 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,6 +4,7 @@ import { svelte, vitePreprocess } from "@sveltejs/vite-plugin-svelte"; export default defineConfig({ plugins: [svelte({ preprocess: vitePreprocess() })], server: { + host: true, // bind 0.0.0.0 so phones on the LAN can reach the dev server port: 5173, proxy: { "/api": "http://localhost:8080", diff --git a/tests/backend/routers/test_rides.py b/tests/backend/routers/test_rides.py index 49e9678..43fe45d 100644 --- a/tests/backend/routers/test_rides.py +++ b/tests/backend/routers/test_rides.py @@ -12,6 +12,48 @@ def test_rides_list_contains_seeded_ride(seeded) -> None: assert "Morning Ride" in res.text +def test_rides_filter_by_distance_and_duration(seeded) -> None: + # Seeded ride: 42 km, 5200 s (~1.44 h) moving. + res = seeded.get("/api/rides?min_km=40&max_km=50&min_hours=1&max_hours=2") + assert res.status_code == 200 + assert res.json()["total"] == 1 + # Out of range on either dimension excludes it. + assert seeded.get("/api/rides?min_km=50").json()["total"] == 0 + assert seeded.get("/api/rides?max_km=40").json()["total"] == 0 + assert seeded.get("/api/rides?min_hours=2").json()["total"] == 0 + assert seeded.get("/api/rides?max_hours=1").json()["total"] == 0 + + +def test_rides_bounds(seeded) -> None: + res = seeded.get("/api/rides/bounds") + assert res.status_code == 200 + body = res.json() + assert body["distance_m"] == pytest.approx([42_000.0, 42_000.0]) + assert body["moving_s"] == pytest.approx([5_200.0, 5_200.0]) + + +def test_rides_pagination(seeded) -> None: + from app.db import connect + + conn = connect() + conn.execute( + "INSERT INTO activities (id, start_time, name, distance_m, moving_s) " + "VALUES (2, '2026-05-02T09:00:00Z', 'Second', 10000.0, 3600.0)" + ) + conn.commit() + conn.close() + + body = seeded.get("/api/rides", params={"limit": 1, "offset": 0}).json() + assert body["total"] == 2 + assert [r["id"] for r in body["items"]] == [2] # start_time DESC + body = seeded.get("/api/rides", params={"limit": 1, "offset": 1}).json() + assert [r["id"] for r in body["items"]] == [1] + # limit=0 means no limit. + assert len(seeded.get("/api/rides", params={"limit": 0}).json()["items"]) == 2 + # Offset past the end returns an empty page. + assert seeded.get("/api/rides", params={"limit": 1, "offset": 2}).json()["items"] == [] + + def test_ride_detail(seeded) -> None: res = seeded.get(f"/api/rides/{ACTIVITY_ID}") assert res.status_code == 200 diff --git a/tests/backend/routers/test_stats.py b/tests/backend/routers/test_stats.py index 02c00af..abd2849 100644 --- a/tests/backend/routers/test_stats.py +++ b/tests/backend/routers/test_stats.py @@ -42,5 +42,52 @@ def test_power_bests_windows_list(seeded) -> None: pytest.fail(f"no endpoint returned the supported power windows: {candidates}") +@pytest.fixture() +def seeded_bikes(api_client, data_dir): + """One bike with a ride in 2024 and 2026, plus an unassigned 2026 ride.""" + from app.db import connect, init_db + + init_db() + conn = connect() + conn.execute("INSERT INTO bikes (id, name) VALUES (1, 'Test Bike')") + for rid, start, dist in ((11, "2024-06-01T09:00:00Z", 10_000.0), (12, "2026-06-01T09:00:00Z", 20_000.0)): + conn.execute( + "INSERT INTO activities (id, start_time, name, type, bike_id, distance_m, moving_s) " + "VALUES (?, ?, ?, 'Ride', 1, ?, 3600.0)", + (rid, start, f"Bike ride {start[:4]}", dist), + ) + conn.execute( + "INSERT INTO activities (id, start_time, name, type, distance_m, moving_s) " + "VALUES (13, '2026-07-01T09:00:00Z', 'No bike ride', 'Ride', 5000.0, 3600.0)" + ) + conn.commit() + conn.close() + return api_client + + +def test_per_bike_all_time_and_year(seeded_bikes) -> None: + all_time = seeded_bikes.get("/api/stats").json() + bike = next(b for b in all_time["per_bike"] if b["bike_name"] == "Test Bike") + assert bike["rides"] == 2 + assert bike["distance_m"] == 30_000.0 + + for year, (rides, dist) in {2024: (1, 10_000.0), 2026: (1, 20_000.0)}.items(): + res = seeded_bikes.get(f"/api/stats?year={year}") + assert res.status_code == 200 + bike = next(b for b in res.json()["per_bike"] if b["bike_name"] == "Test Bike") + assert bike["rides"] == rides + assert bike["distance_m"] == dist + + # Unassigned respects the year filter too; totals stay all-time. + u24 = seeded_bikes.get("/api/stats?year=2024").json()["unassigned"] + u26 = seeded_bikes.get("/api/stats?year=2026").json()["unassigned"] + assert u24["rides"] == 0 + assert u26["rides"] == 1 # the unassigned 2026 ride + assert seeded_bikes.get("/api/stats?year=2024").json()["totals"]["rides"] == 3 + + res = seeded_bikes.get("/api/stats?year=1899") + assert res.status_code == 400 + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-v"])) \ No newline at end of file
{$t("stats.bike")}{$t("stats.rides")}km