From 1aaab070d28db1a020d77335a626b8dbb6a2fa4e Mon Sep 17 00:00:00 2001 From: Addi90 Date: Mon, 31 Aug 2026 20:17:47 +0200 Subject: [PATCH 01/24] feat(frontend): mobile layout (Phase 2 of mobile PWA plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ≤768px: ride table → tap cards (toolbar: sort select, filter, upload); StatsPanel + PowerBests in collapsible details sections - RideDetail: chart pill switcher (primary chart raised, 240px), drag-select zoom reset pill, map 480→280px, stat cards 2-col, zone cards → horizontal scroll-snap row - BottomSheet component (filters + ride delete confirm; replaces native confirm() which renders broken on iOS) - StreamChart: resetSignal prop (zero setSelect + setData reset) - app.css: 44px touch targets, 16px mobile input font, focus-visible / disabled / active state contract, safe-area padding - Contrast fix: dark text on accent buttons (white was 3.3:1, fails WCAG AA) - i18n: 14 new keys (de + en); docs/plan-mobile-pwa.md --- docs/plan-mobile-pwa.md | 136 ++++++++++++ frontend/src/app.css | 22 ++ frontend/src/lib/BottomSheet.svelte | 67 ++++++ frontend/src/lib/PowerBestsTable.svelte | 3 + frontend/src/lib/SettingsDialog.svelte | 4 +- frontend/src/lib/StatsPanel.svelte | 9 +- frontend/src/lib/charts/StreamChart.svelte | 12 + frontend/src/lib/i18n.ts | 24 ++ frontend/src/routes/RideDetail.svelte | 183 +++++++++++---- frontend/src/routes/RidesList.svelte | 247 +++++++++++++++++---- 10 files changed, 624 insertions(+), 83 deletions(-) create mode 100644 docs/plan-mobile-pwa.md create mode 100644 frontend/src/lib/BottomSheet.svelte 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 ` + + + + + + + + + + + + + + {#if error}
{error}
{/if} + {#if loading}

{$t("common.loading")}

{/if} + + + +
+ {$t("stats.alltime")} + +
+
+ {$t("power.bests.short")} + +
+ + + +
+ + + + +
+
+ From 1e2385c13a2182a9fc674eff9a99e94593310bbb Mon Sep 17 00:00:00 2001 From: Addi90 Date: Mon, 31 Aug 2026 21:33:39 +0200 Subject: [PATCH 02/24] fix(charts): keep drag-select (reset pill + map zoom) on mouse-leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setCursor fires with idx=null both when the pointer leaves the plot AND when uPlot clears the selection by click — the hook cleared selectionRange in both cases, so the reset pill disappeared as soon as the mouse left the chart. Now only clears when uPlot itself has no visible selection; click-to-clear and double-click reset unchanged. --- frontend/src/lib/charts/StreamChart.svelte | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/charts/StreamChart.svelte b/frontend/src/lib/charts/StreamChart.svelte index f2ebd3d..d507e15 100644 --- a/frontend/src/lib/charts/StreamChart.svelte +++ b/frontend/src/lib/charts/StreamChart.svelte @@ -113,7 +113,13 @@ const idx = u.cursor.idx; if (idx == null) { hoverTime.set(null); - selectionRange.set(null); + // Mouse-leave also fires setCursor(idx=null) while the drag-select + // box is still shown — only clear the selection when uPlot itself + // has none (e.g. cleared by a click), or the reset pill + map zoom + // would vanish every time the pointer leaves the chart. + if (!u.select || u.select.width <= 0 || u.select.show === false) { + selectionRange.set(null); + } } else { const t = u.data[0][idx]; hoverTime.set(typeof t === "number" ? t : null); From 033221f410ccfebe688f323a76001f306c29ea87 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Mon, 31 Aug 2026 21:50:36 +0200 Subject: [PATCH 03/24] fix(charts): make selectionRange an independent zoom state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uPlot's selection box is transient — it is hidden on click, mouse-leave AND dblclick, while the axis zoom only resets on dblclick (native autoScaleX). Syncing selectionRange with the box made the reset pill vanish on any click or pointer leave. Now: - setSelect hook only SETS selectionRange (real drag), never clears it - dblclick listener clears it (uPlot already autoScaleX()'d natively) - reset pill clears it explicitly (zero-width setSelect no longer does) - dropped the mouseup-clear and setCursor-clear listeners --- frontend/src/lib/charts/StreamChart.svelte | 23 ++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/charts/StreamChart.svelte b/frontend/src/lib/charts/StreamChart.svelte index d507e15..07d186c 100644 --- a/frontend/src/lib/charts/StreamChart.svelte +++ b/frontend/src/lib/charts/StreamChart.svelte @@ -113,13 +113,6 @@ const idx = u.cursor.idx; if (idx == null) { hoverTime.set(null); - // Mouse-leave also fires setCursor(idx=null) while the drag-select - // box is still shown — only clear the selection when uPlot itself - // has none (e.g. cleared by a click), or the reset pill + map zoom - // would vanish every time the pointer leaves the chart. - if (!u.select || u.select.width <= 0 || u.select.show === false) { - selectionRange.set(null); - } } else { const t = u.data[0][idx]; hoverTime.set(typeof t === "number" ? t : null); @@ -129,8 +122,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"); @@ -203,6 +198,7 @@ // 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; @@ -228,13 +224,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(); }); From cd0d279d71ed4923c1448f9021216afb874fed20 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Mon, 31 Aug 2026 22:04:11 +0200 Subject: [PATCH 04/24] fix(charts): restore highQ percentiles clobbered during pill refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highQ: 0.005 (== lowQ) made robustRange return [lo, lo+1] — a 1-unit y-window at the 0.5th percentile — so elevation/power/cadence/HR/temperature data all clipped above the plot area. Speed kept its 0.999 and was fine. Back to 0.995 (the pre-refactor default). --- frontend/src/routes/RideDetail.svelte | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/RideDetail.svelte b/frontend/src/routes/RideDetail.svelte index b362dc1..2822628 100644 --- a/frontend/src/routes/RideDetail.svelte +++ b/frontend/src/routes/RideDetail.svelte @@ -218,12 +218,12 @@ 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.005, zones: null }, + { 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.005, zones: null }, - { f: "hr" as FieldKey, label: $t("ride.card.hr"), unit: "bpm", xs: ts, ys: hr, valueDigits: 0, highQ: 0.005, zones: hrZoneBands }, - { f: "cadence" as FieldKey, label: $t("ride.card.cadence"), unit: "rpm", xs: ts, ys: cadence, valueDigits: 0, highQ: 0.005, zones: null }, - { f: "temperature" as FieldKey, label: $t("ride.card.temperature"), unit: "°C", xs: ts, ys: temperature, valueDigits: 1, highQ: 0.005, 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. From d7187e331c5bb28548a869bab3275c00ee3077d2 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Mon, 31 Aug 2026 22:19:09 +0200 Subject: [PATCH 05/24] feat(bests): unit toggle as segmented pill switch Single 'W/kg'/'W' button replaced by a two-segment pill (both options visible, active filled accent) matching the chart pill visual language. --- frontend/src/lib/PowerBestsTable.svelte | 34 +++++++++++++++++-------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/frontend/src/lib/PowerBestsTable.svelte b/frontend/src/lib/PowerBestsTable.svelte index c3b6a19..a3eb24f 100644 --- a/frontend/src/lib/PowerBestsTable.svelte +++ b/frontend/src/lib/PowerBestsTable.svelte @@ -48,18 +48,20 @@ $: if (activityId !== undefined) load(); $: if (reloadKey > 0) load(); - function togglePowerUnit() { - settings.update((s) => ({ ...s, powerUnit: s.powerUnit === "W" ? "W/kg" : "W" })); + function setPowerUnit(unit: "W" | "W/kg") { + if ($settings.powerUnit === unit) return; + settings.update((s) => ({ ...s, powerUnit: unit })); }

{title}

- + + +
{#if error} @@ -140,16 +142,26 @@ margin-bottom: 6px; } h3 { margin: 0; font-size: 14px; } - .unit { + .unit-toggle { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 999px; + overflow: hidden; + } + .unit-toggle button { background: transparent; - border: 1px solid var(--accent); - color: var(--accent); - border-radius: 6px; - padding: 2px 10px; + border: 0; + color: var(--muted); + padding: 3px 12px; font-size: 12px; cursor: pointer; + min-height: 26px; + } + .unit-toggle button.active { + background: var(--accent); + color: var(--bg); /* dark on orange: white fails WCAG AA */ + font-weight: 600; } - .unit:hover { background: rgba(252, 82, 0, 0.1); } table { width: 100%; border-collapse: collapse; From e2746cbe3297eee3ed60d62af82b09abc9678d9a Mon Sep 17 00:00:00 2001 From: Addi90 Date: Mon, 31 Aug 2026 22:52:27 +0200 Subject: [PATCH 06/24] perf(mobile): stretch uPlot into the card edges - drop the hardcoded 55px y-axis size; uPlot auto-sizes from label text - mobile: card padding 14px -> 6px horizontal, tighter title gap --- frontend/src/lib/charts/StreamChart.svelte | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/charts/StreamChart.svelte b/frontend/src/lib/charts/StreamChart.svelte index 07d186c..9409227 100644 --- a/frontend/src/lib/charts/StreamChart.svelte +++ b/frontend/src/lib/charts/StreamChart.svelte @@ -88,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: [ @@ -262,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; From 1f71241a59d5a132e260efdb6ce84bdb58bbd283 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Mon, 31 Aug 2026 23:05:58 +0200 Subject: [PATCH 07/24] feat(mobile): bottom tab bar (Rides + Settings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tabs only — the app has exactly two screens plus the settings dialog. Rides tab = back escape hatch from ride detail (no scroll-to-top); Settings tab = the header cog, which is equally unreachable at the bottom of a long ride page. Fixed bar with safe-area inset, 56px targets, hidden >=769px; main content padded clear of it on mobile. --- frontend/src/App.svelte | 54 +++++++++++++++++++++++++++++++++++++--- frontend/src/lib/i18n.ts | 2 ++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 4e948b0..b52f2b3 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,6 +1,6 @@ - +

Cycling View

@@ -34,6 +34,22 @@ + + @@ -72,4 +88,36 @@ } .cog:hover { background: rgba(255, 255, 255, 0.06); border-color: var(--accent); color: var(--accent); } main { padding: 16px 24px 48px; } + + .tabbar { display: none; } + @media (max-width: 768px) { + .tabbar { + display: grid; + grid-template-columns: 1fr 1fr; + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 30; + background: var(--panel); + border-top: 1px solid var(--border); + padding-bottom: env(safe-area-inset-bottom); /* iPhone home indicator */ + } + .tabbar button { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-height: 56px; + background: transparent; + border: 0; + color: var(--muted); + font-size: 11px; + cursor: pointer; + } + .tabbar button:active { opacity: 0.7; } + .tabbar button.active { color: var(--accent); } + /* Keep content clear of the fixed bar */ + main { padding: 16px 16px calc(76px + env(safe-area-inset-bottom)); } + } \ No newline at end of file diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 63b6536..83c0af6 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -21,6 +21,7 @@ export const dictionaries = { "rides.filter.from": "Von", "rides.filter.to": "Bis", "rides.filter.count": "{count} Fahrten", + "rides.title": "Fahrten", "rides.filter.apply": "Anwenden", "rides.sort": "Sortierung", "rides.sort.newest": "Neueste", @@ -163,6 +164,7 @@ export const dictionaries = { "rides.filter.from": "From", "rides.filter.to": "To", "rides.filter.count": "{count} rides", + "rides.title": "Rides", "rides.filter.apply": "Apply", "rides.sort": "Sort", "rides.sort.newest": "Newest", From 9611a34b5044f085e0b9256b9c2926090f8417d0 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Mon, 31 Aug 2026 23:56:31 +0200 Subject: [PATCH 08/24] feat(mobile): center + tab in footer opens the upload dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rides | + | Settings. The + (accent circle, big Plus) bumps a monotonic uploadRequest store; RidesList owns the dialog and opens it on change — from RideDetail the + also navigates to the list first. Header cog is hidden on mobile since settings now lives in the footer. --- frontend/src/App.svelte | 37 +++++++++++++++++++++++----- frontend/src/lib/upload.ts | 8 ++++++ frontend/src/routes/RidesList.svelte | 6 +++++ 3 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/upload.ts diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index b52f2b3..bf700b1 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,10 +1,11 @@ + +
+
+
+
+
+ dispatch("change")} + /> + dispatch("change")} + /> +
+
+ {fmt(lo)} {unit} + {fmt(hi)} {unit} +
+
+ + \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2d040da..0a5b6ef 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, diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 83c0af6..05d7a0f 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -20,6 +20,8 @@ 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", @@ -163,6 +165,8 @@ 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", diff --git a/frontend/src/routes/RidesList.svelte b/frontend/src/routes/RidesList.svelte index ef03281..7029781 100644 --- a/frontend/src/routes/RidesList.svelte +++ b/frontend/src/routes/RidesList.svelte @@ -9,6 +9,7 @@ import StatsPanel from "../lib/StatsPanel.svelte"; import PowerBestsTable from "../lib/PowerBestsTable.svelte"; import BottomSheet from "../lib/BottomSheet.svelte"; + import RangeSlider from "../lib/RangeSlider.svelte"; import { uploadRequest } from "../lib/upload"; let bikes: Bike[] = []; @@ -17,6 +18,25 @@ let bikeFilter: number | "" = ""; let dateFrom = ""; let dateTo = ""; + // Slider domains are the DB min/max (km / h); lo/hi are the knob positions. + let distDomain: [number, number] | null = null; + let durDomain: [number, number] | null = null; + let distLo = 0; + let distHi = 0; + let durLo = 0; + let durHi = 0; + let distInit = false; + let durInit = false; + $: if (distDomain && !distInit) { + distLo = distDomain[0]; + distHi = distDomain[1]; + distInit = true; + } + $: if (durDomain && !durInit) { + durLo = durDomain[0]; + durHi = durDomain[1]; + durInit = true; + } let loading = false; let error = ""; let uploadOpen = false; @@ -92,6 +112,11 @@ bike_id: bikeFilter === "" ? undefined : Number(bikeFilter), date_from: dateFrom || undefined, date_to: dateTo || undefined, + // Knob at the domain edge = unbounded. + min_km: distDomain && distLo > distDomain[0] ? distLo : undefined, + max_km: distDomain && distHi < distDomain[1] ? distHi : undefined, + min_hours: durDomain && durLo > durDomain[0] ? durLo : undefined, + max_hours: durDomain && durHi < durDomain[1] ? durHi : undefined, limit: 500, }); rides = res.items; @@ -106,6 +131,9 @@ onMount(async () => { bikes = await api.bikes(); await load(); + const b = await api.rideBounds(); + if (b.distance_m) distDomain = [b.distance_m[0] / 1000, b.distance_m[1] / 1000]; + if (b.moving_s) durDomain = [b.moving_s[0] / 3600, b.moving_s[1] / 3600]; }); async function onUploaded() { @@ -134,6 +162,38 @@ {$t("rides.filter.to")} + {#if distDomain} +
+ {$t("rides.filter.dist")} + +
+ {/if} + {#if durDomain} +
+ {$t("rides.filter.dur")} + +
+ {/if}

{$t("rides.filter.count").replace("{count}", String(total))}

+ {$t("rides.page").replace("{x}", String(page)).replace("{y}", String(totalPages))} + + + {/if} +
@@ -288,6 +313,12 @@
+ - {#if totalPages > 1} -
- - {$t("rides.page").replace("{x}", String(page)).replace("{y}", String(totalPages))} - -
- {/if}
@@ -313,12 +312,6 @@
- + + + + +
{#if error}
{error}
{/if} - {#if loading}

{$t("common.loading")}

{/if} {#if totalPages > 1} -
+
{$t("rides.page").replace("{x}", String(page)).replace("{y}", String(totalPages))} @@ -377,6 +375,14 @@ {/each} + {#if totalPages > 1} +
+ + {$t("rides.page").replace("{x}", String(page)).replace("{y}", String(totalPages))} + +
+ {/if} +
{$t("stats.alltime")} @@ -387,6 +393,12 @@
+{#if loading} +
+ +
+{/if} +
{/if} -
- {$t("stats.alltime")} - -
-
- {$t("power.bests.short")} - -
{#if loading} @@ -570,24 +562,6 @@ margin-top: 4px; } - .msection { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - margin-top: 12px; - padding: 0 14px 12px; - } - .msection summary { - cursor: pointer; - font-size: 14px; - font-weight: 600; - color: var(--muted); - min-height: var(--touch); - display: flex; - align-items: center; - user-select: none; - } - .sheet-fields { display: grid; gap: 12px; } .sheet-fields label, .sheet-fields .filter-label { display: block; font-size: 13px; color: var(--muted); } .sheet-fields select, .sheet-fields input { width: 100%; margin-top: 4px; } From 09926d408eb6ec3d6704d1bb1109dd51312b1890 Mon Sep 17 00:00:00 2001 From: Addi90 Date: Wed, 2 Sep 2026 09:48:57 +0200 Subject: [PATCH 17/24] fix(mobile): render stats sheet inside Router The stats & bests BottomSheet was mounted after ; its StatsPanel/PowerBestsTable render , which dereferences the svelte-routing context and throws outside a Router. The crash left the stats stuck on 'loading', the bests section collapsed, and the backdrop click dead. Moving the sheet inside the Router fixes all three. --- frontend/src/App.svelte | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 340d017..3490a41 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -75,17 +75,18 @@ > + + +
+ + +
+
- -
- - -
-
- \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0a5b6ef..7798d71 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -223,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/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 From 259e13115c8e3cfacaabeedbc48af25457a9efbd Mon Sep 17 00:00:00 2001 From: Addi90 Date: Thu, 3 Sep 2026 22:54:01 +0200 Subject: [PATCH 23/24] feat(mobile): settings as bottom sheet + tab button, upload as FAB Settings open as a swipeable bottom sheet on mobile (centered dialog on desktop) and move into the bottom tab bar; upload becomes the floating button. Add viewport-fit=cover so safe-area insets resolve on iOS. --- frontend/index.html | 6 +- frontend/src/App.svelte | 125 +++---- frontend/src/app.css | 4 +- frontend/src/lib/PillBar.svelte | 66 ++++ frontend/src/lib/PowerBestsTable.svelte | 38 +- frontend/src/lib/SettingsAnalysis.svelte | 80 +++++ frontend/src/lib/SettingsBikes.svelte | 123 +++++++ frontend/src/lib/SettingsData.svelte | 145 ++++++++ frontend/src/lib/SettingsDialog.svelte | 423 ++--------------------- frontend/src/lib/SettingsProfile.svelte | 112 ++++++ frontend/src/lib/SettingsSheet.svelte | 153 ++++++++ frontend/src/lib/StatsSheet.svelte | 43 +-- 12 files changed, 796 insertions(+), 522 deletions(-) create mode 100644 frontend/src/lib/PillBar.svelte create mode 100644 frontend/src/lib/SettingsAnalysis.svelte create mode 100644 frontend/src/lib/SettingsBikes.svelte create mode 100644 frontend/src/lib/SettingsData.svelte create mode 100644 frontend/src/lib/SettingsProfile.svelte create mode 100644 frontend/src/lib/SettingsSheet.svelte diff --git a/frontend/index.html b/frontend/index.html index c826278..6de3818 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,11 @@ - + + Cycling View diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index e4a572d..077663e 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -5,16 +5,33 @@ import RidesList from "./routes/RidesList.svelte"; import RideDetail from "./routes/RideDetail.svelte"; import SettingsDialog from "./lib/SettingsDialog.svelte"; + import SettingsSheet from "./lib/SettingsSheet.svelte"; import BottomSheet from "./lib/BottomSheet.svelte"; import StatsSheet from "./lib/StatsSheet.svelte"; import { uploadRequest } from "./lib/upload"; import { t } from "./lib/i18n"; + import { onMount, onDestroy } from "svelte"; export let url = ""; let settingsOpen = false; let statsOpen = false; + // ≤768px: settings open as a swipeable bottom sheet (like stats); ≥769px: + // centered modal dialog. The settings tab (mobile) and the gear (desktop) + // both toggle `settingsOpen`; only the one matching the viewport renders. + let isMobile = false; + let mql: MediaQueryList | null = null; + function onMql(e: MediaQueryListEvent) { + isMobile = e.matches; + } + onMount(() => { + mql = window.matchMedia("(max-width: 768px)"); + isMobile = mql.matches; + mql.addEventListener("change", onMql); + }); + onDestroy(() => mql?.removeEventListener("change", onMql)); + // Clicking a inside the sheet (ride bests, stats) navigates to the // ride; close the sheet so it doesn't sit over the loaded detail page. function onSheetStackClick(e: MouseEvent) { @@ -44,9 +61,10 @@ - + + {#if isMobile} + +
+ +
+
+ {/if} - +{#if !isMobile} + +{/if} \ No newline at end of file diff --git a/frontend/src/lib/PowerBestsTable.svelte b/frontend/src/lib/PowerBestsTable.svelte index fdce35a..4cb8042 100644 --- a/frontend/src/lib/PowerBestsTable.svelte +++ b/frontend/src/lib/PowerBestsTable.svelte @@ -6,6 +6,7 @@ import { fmtWindow, fmtPower } from "./power-format"; import { fmtDateShort } from "./format"; import { t } from "./i18n"; + import PillBar from "./PillBar.svelte"; /** When set, show this ride's bests; otherwise the all-time leaderboard. */ export let activityId: number | string | null = null; @@ -52,15 +53,24 @@ if ($settings.powerUnit === unit) return; settings.update((s) => ({ ...s, powerUnit: unit })); } + + // W / W-kg unit toggle options (values match settings.powerUnit). + const unitOptions = [ + { label: "W/kg", value: "W/kg" }, + { label: "W", value: "W" }, + ];

{title}

-
- - +
+ setPowerUnit(e.detail)} + title={$t("power.bests.unit_title").replace("{unit}", $settings.powerUnit).replace("{weight}", String($settings.weightKg))} + />
@@ -144,26 +154,6 @@ h3 { margin: 0; font-size: 14px; } .unit-toggle { flex: none; /* don't shrink next to the h3 in narrow panels */ - display: inline-flex; - border: 1px solid var(--border); - border-radius: 999px; - overflow: hidden; - } - .unit-toggle button { - flex: none; /* never shrink below content: "W/kg" used to clip */ - white-space: nowrap; - background: transparent; - border: 0; - color: var(--muted); - padding: 3px 12px; - font-size: 12px; - cursor: pointer; - min-height: 26px; - } - .unit-toggle button.active { - background: var(--accent); - color: var(--bg); /* dark on orange: white fails WCAG AA */ - font-weight: 600; } table { width: 100%; 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 9fae090..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,128 +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); - /* Dark text on the orange: white fails WCAG AA (3.3:1). */ 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/StatsSheet.svelte b/frontend/src/lib/StatsSheet.svelte index 2698142..0639b78 100644 --- a/frontend/src/lib/StatsSheet.svelte +++ b/frontend/src/lib/StatsSheet.svelte @@ -1,6 +1,7 @@ -
- - +
+ (page = e.detail)} ariaLabel={$t("stats.andBests")} />
@@ -135,27 +126,7 @@