Skip to content

Mobile layout, bottom sheets, swipe pagination - #5

Merged
Addi90 merged 24 commits into
masterfrom
feat/mobile-layout
Sep 4, 2026
Merged

Addi90 merged 24 commits into
masterfrom
feat/mobile-layout

Conversation

@Addi90

@Addi90 Addi90 commented Sep 4, 2026

Copy link
Copy Markdown
Owner
  • Mobile layout (≤768px): bottom tab bar, ride cards, pagination, floating upload button
  • Filters in a bottom sheet with dual-knob RangeSlider for distance/duration
  • BottomSheet: swipe-down-to-close, fixed height (no layout reflow)
  • Settings split into tabbed sheets (Bikes/Profile/Data/Analysis); stats gain per-bike breakdown + year filter
  • Swipe to flip pages in the ride list (touch & mouse, parallax + chevron arrow)
  • Backend: ride filters + bounds endpoints, per-bike stats (with tests)

Addi90 added 24 commits August 31, 2026 21:22
- ≤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
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.
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
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).
Single 'W/kg'/'W' button replaced by a two-segment pill (both options
visible, active filled accent) matching the chart pill visual language.
- drop the hardcoded 55px y-axis size; uPlot auto-sizes from label text
- mobile: card padding 14px -> 6px horizontal, tighter title gap
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.
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.
Flex items shrink below their content by default, and the pill's
overflow:hidden then cut 'W/kg' off. flex:none on the pill and its
buttons; the h3 absorbs the shrink instead.
host: true binds 0.0.0.0 instead of localhost so phones on the same
network can load the app (vite prints the Network URL on startup).
Upload is the footer + on mobile, so the toolbar copy is redundant
(desktop toolbar keeps its button). The top back link is hidden on
mobile ride detail — the Rides footer tab is the back path.
Server-side min/max filters on distance (km) and duration (h) replace
text inputs with two-thumb range sliders whose bounds are the DB's
shortest/longest ride (GET /api/rides/bounds). In both the desktop
aside and the mobile filter sheet.
Track div is inset by half the thumb width (browsers clamp thumbs inside
the input box, so the orange bar stuck out past the end knobs). Filter
bounds are snapped to the step grid (floor/ceil) so the duration slider
moves in even 0.25h steps from a clean start instead of an offset one.
Per-page dropdown (25 / 50 / 100 / all) at the top left, page
prev/next + 'page x / y' indicator; filter and page-size changes reset
to page 1. 'All' uses limit=0, which the API now treats as no limit
(same 0=all convention as the streams endpoint).
…ur loading overlay

Per-page dropdown moves to the right (desktop list bar and mobile
toolbar). Mobile pager stretches full width with the arrows at each
edge and is repeated above and below the card list. Loading now shows
a fixed spinner overlay that dims and blurs the page instead of the
old inline text.
The all-time bests and statistics cards under the mobile ride list are
removed; their slot in the bottom tab bar (previously Settings) now
opens them in a bottom sheet. Settings becomes a floating round gear
button above the tab bar (mobile only; desktop keeps the header cog).
The stats & bests BottomSheet was mounted after </Router>; its
StatsPanel/PowerBestsTable render <Link>, 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.
The sheet had no swipe gesture at all (despite AGENTS.md describing one):
a hard swipe-down at the top of the sheet content overscroll-chained to
the page behind, and gentle ones did nothing.

Standard bottom-sheet gesture (iOS sheets / Material 3):
- claim only when content scrollTop is 0 (checked live, so a drag that
  reaches the top mid-gesture can still claim); 8px slop; ignore
  horizontal drags and drags starting on input/textarea/select
- preventDefault on a non-passive touchmove stops the overscroll chain
  to the page behind; overscroll-behavior: contain as a native guard
- sheet follows the finger (1:1, then rubber-band), backdrop dims
- dismiss on release velocity >= 0.3 px/ms or displacement >= max(100px,
  30% of sheet height); else spring back; then animate off and close
- slide transition animates height, so the off-screen settle composes
  without a visible teleport

Benefits all three sheets (stats, filter, delete-confirm).
Tapping a best or stat inside the sheet navigated to the ride but the
sheet stayed open over the loaded detail page. Anchor clicks bubble to
the sheet-stack div, so close the sheet on any link tap inside it.
- do: start frontend dev server with --host 0.0.0.0 so it is reachable
  over the network
- app.css: add --elev-1 / --elev-1-pressed elevation tokens
- App.svelte: raise the mobile settings FAB so it clears the bottom tab bar
- GET /api/stats?year=N restricts the per-bike and unassigned tables to
  that year; totals, longest rides and per-year stay all-time. Year is
  bound as text (SQLite never equates TEXT with INTEGER), 400 on invalid.
- StatsPanel: compact year select in the per-bike <details> summary
  (All-time + each year), aligned right, section expanded by default,
  table dims while refetching. <summary> keeps its default display so
  the details marker arrow shows like the per-year section.
- Test: per-bike all-time vs 2024/2026, unassigned year filter,
  totals stay all-time, 400 on out-of-range year.
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.
Horizontal drag on the card list (touch or mouse) parallaxes at 0.5x;
releasing past 30px slides the list out, loads the neighbouring page,
and snaps back. A fading chevron shows pager semantics (next = right
chevron, prev = left), biased to the side being swiped away from, and
only on swipes that would actually change page. Vertical drags stay
native scroll (touch-action: pan-y + direction detection).

AGENTS.md: note the iOS Safari date-input width quirk in the filter
sheet (blind CSS fixes reverted; needs a real-device screenshot),
record the swipe feature, update svelte-check baseline to 12 warnings.
@Addi90
Addi90 merged commit ba9af12 into master Sep 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant