Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1aaab07
feat(frontend): mobile layout (Phase 2 of mobile PWA plan)
Addi90 Aug 31, 2026
1e2385c
fix(charts): keep drag-select (reset pill + map zoom) on mouse-leave
Addi90 Aug 31, 2026
033221f
fix(charts): make selectionRange an independent zoom state
Addi90 Aug 31, 2026
cd0d279
fix(charts): restore highQ percentiles clobbered during pill refactor
Addi90 Aug 31, 2026
d7187e3
feat(bests): unit toggle as segmented pill switch
Addi90 Aug 31, 2026
e2746cb
perf(mobile): stretch uPlot into the card edges
Addi90 Aug 31, 2026
1f71241
feat(mobile): bottom tab bar (Rides + Settings)
Addi90 Aug 31, 2026
9611a34
feat(mobile): center + tab in footer opens the upload dialog
Addi90 Aug 31, 2026
3f1d005
fix(bests): stop unit pill switch clipping in narrow panels
Addi90 Aug 31, 2026
08f9616
chore(dev): expose vite dev server on the LAN
Addi90 Aug 31, 2026
9ca8d98
refactor(mobile): drop duplicated upload button and top back link
Addi90 Aug 31, 2026
1af6a62
feat(rides): distance/duration filters as dual-knob sliders
Addi90 Sep 1, 2026
ffdb25a
fix(rides): align slider track ends with knobs, snap bounds to step grid
Addi90 Sep 1, 2026
318ba89
feat(rides): paginate rides list with per-page selector
Addi90 Sep 1, 2026
346ede1
feat(rides): right-align per-page select, full-width mobile pager, bl…
Addi90 Sep 1, 2026
a7110b5
feat(mobile): stats & bests as tab button, settings as floating gear
Addi90 Sep 2, 2026
09926d4
fix(mobile): render stats sheet inside Router
Addi90 Sep 2, 2026
d7f436d
docs: refresh AGENTS.md for mobile layout, filters, pagination
Addi90 Sep 2, 2026
2bdc7b7
fix(mobile): swipe-down-to-close for BottomSheet
Addi90 Sep 2, 2026
4c9b0bb
fix(mobile): close stats sheet when a ride link is clicked
Addi90 Sep 2, 2026
20ce114
fix: expose vite on all interfaces, lift settings FAB above tab bar
Addi90 Sep 2, 2026
6ba0ae1
feat(stats): per-bike stats with year filter (all-time default)
Addi90 Sep 2, 2026
259e131
feat(mobile): settings as bottom sheet + tab button, upload as FAB
Addi90 Sep 3, 2026
b23cc97
feat: swipe to change pages on mobile ride list
Addi90 Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PROJECT KNOWLEDGE BASE

**Generated:** 2026-08-30
**Generated:** 2026-09-02

## OVERVIEW
Project: **Cycling View**
Expand All @@ -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 <Router>)
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
Expand Down Expand Up @@ -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)
Expand All @@ -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 `<Link>` must stay inside `<Router>`**: 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 = <repo>/data`, `repo_root = <repo>/`; override with `CV_DATA_DIR` / `CV_REPO_ROOT` env vars (Docker does this). CORS origins are hardcoded to localhost:5173/8080.
Expand Down
45 changes: 38 additions & 7 deletions backend/app/routers/rides.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand All @@ -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,
Expand All @@ -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(
Expand Down
19 changes: 14 additions & 5 deletions backend/app/routers/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"""
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions do
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
136 changes: 136 additions & 0 deletions docs/plan-mobile-pwa.md
Original file line number Diff line number Diff line change
@@ -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, `<meta name="theme-color">`,
`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, `<Link>`).
`estimated_power` `*` stays next to nothing here (moves to detail).
- **Sort**: single compact `<select>` (Newest / Longest / Fastest / Power / HR / Elevation)
above the list, reusing the existing `setSort` keys. Drop per-column sort on mobile —
a select is thumb-friendly and covers the 6 sorts people actually want.
- **Filters**: bike + date range collapse into a "Filter" button that opens a
**bottom sheet** (native-dialog styled: bottom-anchored panel, safe-area padding,
3 fields + Apply). No sheet library — one ~60-line component with
`transform: translateY(100%)` → `0`, used for filters *and* the delete confirm (see 2c).
- **StatsPanel + PowerBests**: below the list, in a `<details>`-style collapsible
("Stats" / "Power bests") so the ride list is the first thing on screen.

### 2b. RideDetail — stack, not grid

- **Stat cards**: `auto-fill minmax(180px,1fr)` → `minmax(150px, 1fr)` (2-up on phone),
or horizontal scroll-snap row if 2-up feels cramped — decide by eye with real data.
Keep the coloured left border per card (existing, works well, not a tell: it's a
semantic field marker, not a gray border).
- **Charts**: this is the big one. Six stacked 180px charts with mouse-cursor scrub
is unusable on touch.
- **One primary chart** (Power if present, else HR, else Speed) rendered full-width.
- **Field pill switcher** above it (Elev / Speed / Power / HR / Cad / Temp) —
tapping swaps the chart's data. Reuses `StreamChart` as-is, just conditional render.
- **Touch on the chart**: uPlot already handles touch scrub (`setCursor` fires on
drag) → the synced hover time (map dot, HR zone legend) works with one finger.
Add: drag-to-select stays (zoom), and a **"Reset zoom" pill** appears once a
selection exists (sets `selectionRange` null + clears uPlot select). Double-tap
= also clears, if cheap in uPlot; otherwise skip.
- Below the primary chart: remaining charts stay stacked (they're there to be found,
not to be touched constantly).
- **Map**: `min-height: 480px` → `280px` on mobile. MapLibre's native pinch/pan works;
nothing else to change.
- **Zone cards**: `auto-fill` → horizontal scroll-snap row (they're glance data).
- **Edit form**: full-width, 16px inputs (it's already max-width 480px — fine as-is).
- **Back link**: fine as-is; make it ≥44px tall.

### 2c. Native `confirm()` → bottom sheet

`deleteRide()` uses browser `confirm()` — on iOS Safari this is a raw alert that
blocks the page and looks broken. Route through the Phase-2a bottom-sheet component:
"Delete {name}? [Cancel] [Delete]" (danger accent, ≥44px buttons).

## Phase 3 — State & token extensions

Add to `app.css` (all new, none off-system):

```css
:root {
/* Mobile */
--touch: 44px; /* min tap-target height */
--safe-top: env(safe-area-inset-top);
--safe-bottom: env(safe-area-inset-bottom);
}
button:active, a:active { opacity: 0.85; } /* pressed feedback, replaces hover-only */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
:disabled { opacity: 0.5; cursor: not-allowed; }
@media (min-width: 769px) { /* keep desktop hover as-is; don't regress it */ }
```

- Every button/input ≥44px tall below 768px (padding, not height where it'd break
desktop). The round settings cog gets a 44px hit area (transparent padding).
- `tr:hover td` row highlight already degrades fine (no touch = no hover, no harm);
keep it.
- **Audit before handoff**: `ux_audit` on the final CSS + pairs
(`#e6e8ec` on `#181c23` body, `#98a2b3` on `#0f1115` muted, `#fc5200` on `#181c23`
accent — orange on dark is the one pair to check carefully for APCA).

## Inventory (components + states we ship)

| Component | States |
|---|---|
| Ride card (new) | default / active / focus-visible |
| Sort select (new, mobile) | default / active / focus-visible / disabled |
| Bottom sheet (new: filter + confirm) | default / open / focus-trap-ish / active |
| Field pill switcher (new) | default / active / focus-visible |
| Reset-zoom pill (new) | default / active |
| Button (existing) | + `:active`, `:focus-visible`, `:disabled` |
| Cog / back link | 44px hit area |
| Everything else | unchanged |

## Explicit non-changes

- **No DESIGN.md rewrite**, no new accent/font/scale — existing tokens carry.
- **No bottom tab bar** — two screens + one dialog doesn't need it.
- **No light mode** — dark is the established look; adding a theme is a separate task.
- **No offline data layer** — network-only API, shell-offline. Backend untouched.
- **No new dependencies** — no `vite-plugin-pwa`, no sheet lib, no icon gen tool;
hand-rolled manifest + SW, one rasterised icon.
- Desktop layout is bit-identical below the 768px breakpoint.

## Suggested order

1. Phase 1 (PWA plumbing) — independent, ships value alone (installable).
2. Phase 3 tokens + states — 1 file, `ux_audit` gate.
3. Phase 2a (list) → 2b (detail) → 2c (sheet reuse) — the actual comfort win.
6 changes: 5 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover: extend the viewport into the notched regions so
env(safe-area-inset-*) is non-zero on iOS (without it every safe-area
inset below resolves to 0 and the sheet/tab bar tuck under the home
indicator). Ignored by non-iOS browsers; inset is 0 on non-notch devices. -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Cycling View</title>
</head>
<body>
Expand Down
Loading
Loading