From 93fa78a9f9c18d82fa91c23c195b0a3d117c1934 Mon Sep 17 00:00:00 2001 From: rodrigohpalmeirim Date: Tue, 18 Aug 2026 17:19:36 +0100 Subject: [PATCH] dedupe error toasts and anchor them above the station menu Repeated error messages (e.g. from retried requests) now refresh the existing toast and show a xN counter instead of stacking duplicates. Toasts move from the bottom of the screen, where they covered the station menu's bike list, to just above the sheet's top edge. The anchor tracks the sheet's predicted height as soon as a station is tapped (the skeleton list is sized from the station's bike count), so toasts don't wait for bike info to load, and a short transition glides them to the settled position. During trips, and while no sheet is open, they fall back to the bottom, which is free then. Also adds giraDebug console hooks (dev only) to fake a login, inject stations, and spawn toasts without a real account. --- src/lib/components/ErrorMessage.svelte | 26 +++++++++++++++++++++++--- src/lib/components/StationMenu.svelte | 21 +++++++++++++++++++-- src/lib/debug.ts | 22 +++++++++++++++++++++- src/lib/ui.svelte.ts | 23 +++++++++++++++++++---- src/routes/+page.svelte | 16 ++++++++++++++-- 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/src/lib/components/ErrorMessage.svelte b/src/lib/components/ErrorMessage.svelte index ec173ce..34b883e 100644 --- a/src/lib/components/ErrorMessage.svelte +++ b/src/lib/components/ErrorMessage.svelte @@ -2,12 +2,32 @@ import { errorMessages, keyboard } from '$lib/ui.svelte'; import { flip } from 'svelte/animate'; import { fly } from 'svelte/transition'; + import IconAlertTriangle from '@tabler/icons-svelte/icons/alert-triangle'; + + interface Props { + /** Window-top offset of the station menu sheet, so toasts sit right above it instead of covering the bike list. */ + menuPos?: number; + } + let { menuPos = undefined }: Props = $props(); + + let innerHeight = $state(0); + const bottom = $derived(menuPos !== undefined && menuPos < innerHeight ? innerHeight - menuPos + 12 : 40); -
+ + +
{#each $errorMessages as error (error.id)} -
- {error.msg} +
+ + {error.msg} + {#if error.count > 1} + ×{error.count} + {/if}
{/each}
\ No newline at end of file diff --git a/src/lib/components/StationMenu.svelte b/src/lib/components/StationMenu.svelte index 91f504a..beedb76 100644 --- a/src/lib/components/StationMenu.svelte +++ b/src/lib/components/StationMenu.svelte @@ -20,9 +20,10 @@ interface Props { bikeListHeight?: number; posTop?: number|undefined; + anchorTop?: number|undefined; } - let { bikeListHeight = $bindable(0), posTop = $bindable(0) }: Props = $props(); + let { bikeListHeight = $bindable(0), posTop = $bindable(0), anchorTop = $bindable(undefined) }: Props = $props(); let initPos = 0; let pos = new Tween($selectedStation != null ? 0 : 9999, { @@ -73,6 +74,7 @@ let dragging = $state(false); let timeout:ReturnType; let bikeList:HTMLDivElement; + let listWrapper:HTMLDivElement; let menu:HTMLDivElement; let updating = $state(false); let windowHeight:number|undefined = $state(); @@ -86,6 +88,20 @@ } }); + // Where the sheet's top edge is headed, available while station info is + // still loading: the list is already skeleton-sized from the station's bike + // count, so the target height is known before the fetch returns. Computed + // from the list's height cap rather than a rect so the in-flight resize + // animation doesn't leak intermediate positions + $effect(() => { + if (pos.current !== null && !dragging && windowHeight !== undefined && dragged && listWrapper) { + const chrome = dragged.clientHeight - listWrapper.clientHeight; + anchorTop = Math.min(windowHeight - chrome - Math.min(windowHeight / 2, bikeListHeight) + pos.current, windowHeight); + } else { + anchorTop = undefined; + } + }); + function onTouchStart(event: TouchEvent) { initPos = event.touches[0].clientY - pos.current; } @@ -170,6 +186,7 @@ tick: (_:number) => { dismiss(); posTop = windowHeight; + anchorTop = windowHeight; }, }; } @@ -290,7 +307,7 @@ {$t('free_docks_label')}
-
isScrolling = true} ontouchend={() => isScrolling = false}> +
isScrolling = true} ontouchend={() => isScrolling = false}>
{#if bikeInfo.length == 0} {#each new Array(bikes) as _} diff --git a/src/lib/debug.ts b/src/lib/debug.ts index 7d8f752..47ecec8 100644 --- a/src/lib/debug.ts +++ b/src/lib/debug.ts @@ -1,10 +1,12 @@ import type { Position } from '@capacitor/geolocation'; import { get } from 'svelte/store'; import { currentPos, setDebugPosition } from '$lib/location'; -import { following } from '$lib/map.svelte'; +import { following, selectedStation, stations } from '$lib/map.svelte'; import { appSettings } from '$lib/settings'; import { shortestAngleDelta } from '$lib/marker-animation'; import { currentTrip, DEBUG_START_POSITION, toggleDebugTrip } from '$lib/trip'; +import { errorMessages } from '$lib/ui.svelte'; +import { token } from '$lib/account'; /** Riding a Gira, ~18 km/h. */ export const DEBUG_BIKE_SPEED_MPS = 5; @@ -81,6 +83,24 @@ function debugPosition(lat: number, lng: number, heading: number|null, speed: nu export function startDebugControls() { if (!import.meta.env.DEV || typeof window === 'undefined') return () => undefined; + + // Console hooks for exercising UI states that normally need a real account + // or a live failure (e.g. error toasts): giraDebug.fakeLogin() renders the + // main UI without credentials, giraDebug.addError() spawns a toast + const fakeJwtPayload = window.btoa(JSON.stringify({ exp: 4102444800, iat: 0, nbf: 0, jti: 'debug', sub: 'debug', loginProvider: 'debug', services: [], iss: 'debug', aud: 'debug' })); + (window as unknown as { giraDebug: unknown }).giraDebug = { + addError: (msg: string, delay?: number) => errorMessages.add(msg, delay), + fakeLogin: () => token.set({ accessToken: `debug.${fakeJwtPayload}.debug`, refreshToken: 'debug', expiration: 4102444800 }), + fakeStations: (select = true) => { + stations.value = [ + { code: '101', name: '101 - Cais do Sodré', description: null, latitude: 38.7064, longitude: -9.1449, bikes: 12, docks: 20, serialNumber: '101', assetStatus: 'active' }, + { code: '202', name: '202 - Marquês de Pombal', description: null, latitude: 38.7255, longitude: -9.1503, bikes: 5, docks: 15, serialNumber: '202', assetStatus: 'active' }, + { code: '303', name: '303 - Saldanha', description: null, latitude: 38.7336, longitude: -9.1450, bikes: 0, docks: 18, serialNumber: '303', assetStatus: 'active' }, + ]; + if (select) selectedStation.set('101'); + }, + selectStation: (serial: string) => selectedStation.set(serial), + }; const held = new Set; let fastTravel = false; let frame: number | null = null; diff --git a/src/lib/ui.svelte.ts b/src/lib/ui.svelte.ts index cabc1e7..2bfcfb9 100644 --- a/src/lib/ui.svelte.ts +++ b/src/lib/ui.svelte.ts @@ -13,12 +13,27 @@ export type Insets = { export const safeInsets = writable({ top: 0, bottom: 0, left: 0, right: 0 }); export const errorMessages = (() => { - const { subscribe, update } = writable<{ msg: string, id: number }[]>([]); + const { subscribe, update } = writable<{ msg: string, id: number, count: number }[]>([]); + const timeouts = new Map>; + const remove = (id: number) => { + timeouts.delete(id); + update(messages => messages.filter(m => m.id !== id)); + }; const add = async (msg: string, delay = 3000) => { if (!(await App.getState()).isActive) return; - const id = Math.random(); - update(messages => [...messages, { msg, id }].slice(-3)); - setTimeout(() => update(messages => messages.filter(m => m.id !== id)), delay); + update(messages => { + // Re-adding a visible message (e.g. each attempt of a retried request) + // refreshes it and bumps its counter instead of stacking a duplicate + const existing = messages.find(m => m.msg === msg); + if (existing) { + clearTimeout(timeouts.get(existing.id)); + timeouts.set(existing.id, setTimeout(() => remove(existing.id), delay)); + return messages.map(m => m === existing ? { ...m, count: m.count + 1 } : m); + } + const id = Math.random(); + timeouts.set(id, setTimeout(() => remove(id), delay)); + return [...messages, { msg, id, count: 1 }].slice(-3); + }); }; return { subscribe, add }; })(); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e44a075..638706d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -39,6 +39,18 @@ let profileOpen = $state(false); let locationPermission = $state(false); + // Toasts anchor to the sheet's predicted top edge (anchorTop), which tracks + // station switches immediately instead of waiting for bike info to load. + // It blanks during drags — hold the last value so toasts don't fall to the + // bottom and back — and only truly clear it during trips, when the + // unmounted menu's position is a stale snapshot + let stationMenuAnchor: number|undefined = $state(); + let toastMenuPos: number|undefined = $state(); + $effect(() => { + if ($currentTrip !== null) toastMenuPos = undefined; + else if (stationMenuAnchor !== undefined) toastMenuPos = stationMenuAnchor; + }); + onMount(() => { Geolocation.checkPermissions().then(({ location }) => { locationPermission = location == 'granted'; @@ -84,7 +96,7 @@ {#if $currentTrip !== null} {:else} - + {#if $tripRating.currentRating != null && $networkStatus } {/if} @@ -128,4 +140,4 @@
- \ No newline at end of file + \ No newline at end of file