From 337e3f45dcf092adc35b10c1b8a875e856371cd7 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:17:57 +0200 Subject: [PATCH 1/2] Confirm joining trips in a dialog instead of a second tap On phones the armed "Tap again to join" label pushed the trip's buttons onto a row of their own. The join button now only asks, and the trip log page confirms in a dialog that says the join cannot be undone. Co-Authored-By: Claude Opus 5.5 --- frontend/src/assets/tripLog.css | 2 +- frontend/src/components/TripLogItem.vue | 29 ++++------------ .../components/__tests__/TripLogItem.spec.ts | 14 +++----- frontend/src/locales/en.json | 2 +- frontend/src/locales/nl.json | 2 +- frontend/src/views/TripLogView.vue | 33 +++++++++++++++++-- .../src/views/__tests__/TripLogView.spec.ts | 22 ++++++++++--- 7 files changed, 62 insertions(+), 42 deletions(-) diff --git a/frontend/src/assets/tripLog.css b/frontend/src/assets/tripLog.css index bd22158e..1634f6dd 100644 --- a/frontend/src/assets/tripLog.css +++ b/frontend/src/assets/tripLog.css @@ -315,7 +315,7 @@ padding-inline: 0.55rem; } - .trip-log-item__label:not(.trip-log-item__label--kept) { + .trip-log-item__label { display: none; } } diff --git a/frontend/src/components/TripLogItem.vue b/frontend/src/components/TripLogItem.vue index eb176f55..eeb9fac0 100644 --- a/frontend/src/components/TripLogItem.vue +++ b/frontend/src/components/TripLogItem.vue @@ -13,7 +13,6 @@ import { endLabel, loggedDistanceKm, odometerDistanceKm, PURPOSE_ICONS } from '@ import { COMMUTE_PLACE_ICONS, commutePlaceAt } from '@/utils/commutePlaces' import type { CommutePlace } from '@/services/commutePlacesApi' import { useUnits } from '@/composables/useUnits' -import { useTwoTapConfirm } from '@/composables/useTwoTapConfirm' import ChipGroup from './ChipGroup.vue' import type { ChipOption } from '@/utils/chips' @@ -35,7 +34,7 @@ const emit = defineEmits<{ /** One of the trip's ends, to set as home or work. */ (e: 'pickPlace', latitude: number, longitude: number): void (e: 'setHidden', hidden: boolean): void - /** Join with the trip after it; confirmed with a second tap, since it cannot be undone. */ + /** Asks to join with the trip after it; the page confirms, since it cannot be undone. */ (e: 'joinNext'): void }>() @@ -107,12 +106,6 @@ function choosePurpose(purpose: TripPurpose | null) { emit('save', purpose, props.entry.notes) } -const joinConfirm = useTwoTapConfirm() - -function joinNext() { - if (joinConfirm.tap(true)) emit('joinNext') -} - function saveNotes() { const notes = normalizedNotes() if (notes === props.entry.notes) return @@ -242,23 +235,15 @@ function saveNotes() { diff --git a/frontend/src/components/__tests__/TripLogItem.spec.ts b/frontend/src/components/__tests__/TripLogItem.spec.ts index 85d84124..908fd866 100644 --- a/frontend/src/components/__tests__/TripLogItem.spec.ts +++ b/frontend/src/components/__tests__/TripLogItem.spec.ts @@ -210,18 +210,14 @@ describe('TripLogItem', () => { ).toBe(false) }) - it('joins with the next trip only on a second tap', async () => { + it('asks to join with the next trip', async () => { const wrapper = mountItem(logEntry()) - const join = () => - wrapper - .findAll('button') - .find((b) => b.text().startsWith('Join') || b.text().startsWith('Tap'))! - await join().trigger('click') - expect(wrapper.emitted('joinNext')).toBeUndefined() - expect(join().text()).toBe('Tap again to join') + await wrapper + .findAll('button') + .find((b) => b.text() === 'Join with next trip')! + .trigger('click') - await join().trigger('click') expect(wrapper.emitted('joinNext')).toHaveLength(1) }) diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 92ce1f99..6b2687dd 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -781,7 +781,7 @@ "show": "Show again", "hideHint": "Leave this trip out of the map, the statistics, the dashboard and these totals, for a positioning glitch. It stays listed under the hidden trips.", "joinNext": "Join with next trip", - "joinNextConfirm": "Tap again to join", + "joinNextConfirm": "Join trips", "joinNextHint": "Joins this trip with the one after it, for a drive a short stop such as a fuel station split in two. Cannot be undone.", "showHidden": "Show hidden trip | Show {n} hidden trips" }, diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 46d9891e..e202fb14 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -781,7 +781,7 @@ "show": "Weer tonen", "hideHint": "Laat deze rit weg uit de kaart, de statistieken, het dashboard en deze totalen, bij een fout in de positie. Hij blijft staan bij de verborgen ritten.", "joinNext": "Samenvoegen met volgende rit", - "joinNextConfirm": "Tik nogmaals om samen te voegen", + "joinNextConfirm": "Ritten samenvoegen", "joinNextHint": "Voegt deze rit samen met de rit erna, voor een rit die door een korte stop, zoals bij een tankstation, in tweeën is gesplitst. Kan niet ongedaan worden gemaakt.", "showHidden": "Verborgen rit tonen | {n} verborgen ritten tonen" }, diff --git a/frontend/src/views/TripLogView.vue b/frontend/src/views/TripLogView.vue index 970d1ebf..6745e08a 100644 --- a/frontend/src/views/TripLogView.vue +++ b/frontend/src/views/TripLogView.vue @@ -8,6 +8,7 @@ import { useUiSettingsStore } from '@/stores/settingsUi' import StatusCard from '@/components/StatusCard.vue' import TripLogItem from '@/components/TripLogItem.vue' import CommutePlacesModal from '@/components/CommutePlacesModal.vue' +import DetailModal from '@/components/DetailModal.vue' import { useCommutePlacesStore } from '@/stores/commutePlaces' import { TRIP_PURPOSES, type TripPurpose } from '@/services/tripLogApi' import { downloadCsv } from '@/utils/download' @@ -121,8 +122,17 @@ function setHidden(id: number, hidden: boolean) { if (vin.value) void store.setHidden(vin.value, id, hidden) } -function joinNext(id: number) { - if (vin.value) void store.mergeWithNext(vin.value, id) +// Joining cannot be undone, so it is asked for in a dialog rather than on the trip's own row. +const joiningId = ref(null) +const joinBusy = computed(() => joiningId.value !== null && store.saving.has(joiningId.value)) + +function cancelJoin() { + if (!joinBusy.value) joiningId.value = null +} + +async function confirmJoin() { + if (vin.value && joiningId.value !== null) await store.mergeWithNext(vin.value, joiningId.value) + joiningId.value = null } function classifyAll(purpose: TripPurpose) { @@ -234,10 +244,27 @@ function exportCsv() { @save="(purpose, notes) => save(entry.id, purpose, notes)" @pick-place="openPlaces" @set-hidden="(hidden) => setHidden(entry.id, hidden)" - @join-next="joinNext(entry.id)" + @join-next="joiningId = entry.id" /> + +

{{ t('tripLog.joinNextHint') }}

+ +
+ { expect(wrapper.find('.trip-log-totals').text()).toContain('Unclassified (0)') }) - it('joins a trip with the next one on a second tap, and offers none for the newest', async () => { + it('joins a trip with the next one once confirmed, and offers none for the newest', async () => { vi.mocked(tripLogApi.mergeWithNext).mockResolvedValue(logEntry({ id: 1, distanceKm: 80 })) const wrapper = await mountView() const items = wrapper.findAll('.trip-log-item') expect(items[0]!.text()).not.toContain('Join with next trip') - const join = () => items[1]!.findAll('button').find((b) => /join/i.test(b.text()))! - await join().trigger('click') - await join().trigger('click') + await button(wrapper, 'Join with next trip')[0]!.trigger('click') + expect(tripLogApi.mergeWithNext).not.toHaveBeenCalled() + + await button(wrapper, 'Join trips')[0]!.trigger('click') await flushPromises() expect(tripLogApi.mergeWithNext).toHaveBeenCalledWith(car.vin, 1) + expect(button(wrapper, 'Join trips')).toHaveLength(0) + }) + + it('leaves the trips as they are when the join is cancelled', async () => { + const wrapper = await mountView() + + await button(wrapper, 'Join with next trip')[0]!.trigger('click') + await button(wrapper, 'Cancel')[0]!.trigger('click') + + expect(tripLogApi.mergeWithNext).not.toHaveBeenCalled() + expect(button(wrapper, 'Join trips')).toHaveLength(0) }) it('hides a trip, which then leaves the list', async () => { From 63f3cea212196073042de19f871d9a4c02796515 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:22:20 +0200 Subject: [PATCH 2/2] Unmount trip log pages between tests The address lookup retries in rounds while the mocked API returns no addresses. Pages left mounted kept asking in later tests, which made "asks for no addresses while place names are switched off" flaky in CI. Unmounting stops the lookup, as it does when the page closes. Co-Authored-By: Claude Opus 5.5 --- frontend/src/views/__tests__/TripLogView.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/__tests__/TripLogView.spec.ts b/frontend/src/views/__tests__/TripLogView.spec.ts index 6bc161d6..791d23f4 100644 --- a/frontend/src/views/__tests__/TripLogView.spec.ts +++ b/frontend/src/views/__tests__/TripLogView.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { flushPromises, mount } from '@vue/test-utils' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' import { createPinia, setActivePinia, type Pinia } from 'pinia' import { createI18n } from 'vue-i18n' import en from '@/locales/en.json' @@ -43,6 +43,10 @@ vi.mock('@/services/vehicleApi', () => ({ vi.mock('@/utils/download', () => ({ downloadCsv: vi.fn() })) +// The page looks up addresses in rounds with pauses between them, and stops when it closes; left +// open, a test's page would carry on asking in the tests after it. +enableAutoUnmount(afterEach) + const i18n = createI18n({ legacy: false, locale: 'en', messages: { en } }) const car = { id: 1, vin: 'FAKEVN00000000001', vehicleType: 'hev' } as Vehicle