Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion frontend/src/assets/tripLog.css
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@
padding-inline: 0.55rem;
}

.trip-log-item__label:not(.trip-log-item__label--kept) {
.trip-log-item__label {
display: none;
}
}
29 changes: 7 additions & 22 deletions frontend/src/components/TripLogItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
}>()

Expand Down Expand Up @@ -107,12 +106,6 @@ function choosePurpose(purpose: TripPurpose | null) {
emit('save', purpose, props.entry.notes)
}

const joinConfirm = useTwoTapConfirm<true>()

function joinNext() {
if (joinConfirm.tap(true)) emit('joinNext')
}

function saveNotes() {
const notes = normalizedNotes()
if (notes === props.entry.notes) return
Expand Down Expand Up @@ -242,23 +235,15 @@ function saveNotes() {
<button
v-if="canJoinNext"
type="button"
class="btn btn-sm"
:class="joinConfirm.armed.value ? 'btn-warning' : 'btn-outline-secondary'"
class="btn btn-outline-secondary btn-sm"
:disabled="saving"
:title="t('tripLog.joinNextHint')"
:aria-label="
joinConfirm.armed.value ? t('tripLog.joinNextConfirm') : t('tripLog.joinNext')
"
@click="joinNext"
:aria-label="t('tripLog.joinNext')"
@click="emit('joinNext')"
>
<!-- Armed, the label stays even where the others go, so the second tap is asked for. -->
<font-awesome-icon icon="link" /><span
class="trip-log-item__label"
:class="{ 'trip-log-item__label--kept': joinConfirm.armed.value }"
>{{
joinConfirm.armed.value ? t('tripLog.joinNextConfirm') : t('tripLog.joinNext')
}}</span
>
<font-awesome-icon icon="link" /><span class="trip-log-item__label">{{
t('tripLog.joinNext')
}}</span>
</button>
</div>
</div>
Expand Down
14 changes: 5 additions & 9 deletions frontend/src/components/__tests__/TripLogItem.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
33 changes: 30 additions & 3 deletions frontend/src/views/TripLogView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<number | null>(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) {
Expand Down Expand Up @@ -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"
/>
</ul>

<DetailModal :open="joiningId !== null" :title="t('tripLog.joinNext')" @close="cancelJoin">
<p class="text-sm">{{ t('tripLog.joinNextHint') }}</p>
<template #footer>
<button
type="button"
class="btn btn-outline-secondary"
:disabled="joinBusy"
@click="cancelJoin"
>
{{ t('common.cancel') }}
</button>
<button type="button" class="btn btn-warning" :disabled="joinBusy" @click="confirmJoin">
<font-awesome-icon icon="link" />{{ t('tripLog.joinNextConfirm') }}
</button>
</template>
</DetailModal>

<CommutePlacesModal
:open="placesOpen"
:vin="vin"
Expand Down
28 changes: 22 additions & 6 deletions frontend/src/views/__tests__/TripLogView.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -43,6 +43,10 @@ vi.mock('@/services/vehicleApi', () => ({

vi.mock('@/utils/download', () => ({ downloadCsv: vi.fn<typeof downloadCsv>() }))

// 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

Expand All @@ -57,7 +61,7 @@ async function mountView() {
const wrapper = mount(TripLogView, {
global: {
plugins: [i18n, pinia],
stubs: { FontAwesomeIcon: true, CommutePlacesModal: true },
stubs: { FontAwesomeIcon: true, CommutePlacesModal: true, teleport: true },
},
})
await flushPromises()
Expand Down Expand Up @@ -129,18 +133,30 @@ describe('TripLogView', () => {
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 () => {
Expand Down
Loading