diff --git a/client/src/hooks/useListsSubTab.test.ts b/client/src/hooks/useListsSubTab.test.ts new file mode 100644 index 000000000..b46917549 --- /dev/null +++ b/client/src/hooks/useListsSubTab.test.ts @@ -0,0 +1,49 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useListsSubTab } from './useListsSubTab'; + +// FE-HOOK-LISTSTAB-001 to FE-HOOK-LISTSTAB-006 + +describe('useListsSubTab', () => { + it('FE-HOOK-LISTSTAB-001: opens on packing when nothing is stored for the trip', () => { + const { result } = renderHook(() => useListsSubTab(7)); + expect(result.current[0]).toBe('packing'); + }); + + it('FE-HOOK-LISTSTAB-002: reopens the sub-tab stored for that trip, not another one', () => { + sessionStorage.setItem('trip-lists-subtab-7', 'todo'); + sessionStorage.setItem('trip-lists-subtab-8', 'packing'); + expect(renderHook(() => useListsSubTab(7)).result.current[0]).toBe('todo'); + expect(renderHook(() => useListsSubTab(8)).result.current[0]).toBe('packing'); + expect(renderHook(() => useListsSubTab(7, { onlyKnownTabs: true })).result.current[0]).toBe('todo'); + }); + + it('FE-HOOK-LISTSTAB-003: the desktop takes whatever is stored, the phone only a tab it knows', () => { + sessionStorage.setItem('trip-lists-subtab-7', 'files'); + expect(renderHook(() => useListsSubTab(7)).result.current[0]).toBe('files'); + expect(renderHook(() => useListsSubTab(7, { onlyKnownTabs: true })).result.current[0]).toBe('packing'); + }); + + it('FE-HOOK-LISTSTAB-004: an empty stored value falls back to packing on both', () => { + sessionStorage.setItem('trip-lists-subtab-7', ''); + expect(renderHook(() => useListsSubTab(7)).result.current[0]).toBe('packing'); + expect(renderHook(() => useListsSubTab(7, { onlyKnownTabs: true })).result.current[0]).toBe('packing'); + }); + + it('FE-HOOK-LISTSTAB-005: switching shows the new tab and stores it under the trip key', () => { + const { result } = renderHook(() => useListsSubTab(7, { onlyKnownTabs: true })); + act(() => result.current[1]('todo')); + expect(result.current[0]).toBe('todo'); + expect(sessionStorage.getItem('trip-lists-subtab-7')).toBe('todo'); + act(() => result.current[1]('packing')); + expect(result.current[0]).toBe('packing'); + expect(sessionStorage.getItem('trip-lists-subtab-7')).toBe('packing'); + }); + + it('FE-HOOK-LISTSTAB-006: a choice made on one surface is what the other opens with', () => { + const desktop = renderHook(() => useListsSubTab(3)); + act(() => desktop.result.current[1]('todo')); + expect(renderHook(() => useListsSubTab(3, { onlyKnownTabs: true })).result.current[0]).toBe('todo'); + }); +}); diff --git a/client/src/hooks/useListsSubTab.ts b/client/src/hooks/useListsSubTab.ts new file mode 100644 index 000000000..f75e7efa4 --- /dev/null +++ b/client/src/hooks/useListsSubTab.ts @@ -0,0 +1,25 @@ +import { useState } from 'react'; + +/** The two lists the Lists tab switches between. */ +export type ListsSubTab = 'packing' | 'todo'; + +const storageKey = (tripId: number) => `trip-lists-subtab-${tripId}`; + +/** + * The Lists tab's sub-tab, remembered per trip for the browser session. Both the + * desktop container and the phone shell read and write the same key. The phone + * shell only takes a stored value it knows (`onlyKnownTabs`); the desktop one + * takes whatever is stored, as it always has. + */ +export function useListsSubTab(tripId: number, { onlyKnownTabs = false }: { onlyKnownTabs?: boolean } = {}) { + const [subTab, setSubTabState] = useState(() => { + const saved = sessionStorage.getItem(storageKey(tripId)); + if (onlyKnownTabs) return saved === 'todo' ? 'todo' : 'packing'; + return (saved as ListsSubTab) || 'packing'; + }); + const setSubTab = (tab: ListsSubTab) => { + setSubTabState(tab); + sessionStorage.setItem(storageKey(tripId), tab); + }; + return [subTab, setSubTab] as const; +} diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 82a0da8a7..6f67bc27f 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -21,6 +21,7 @@ import { badgeLabel, distanceBadge } from './roadtrip/stageBadges' import type { CorridorReach } from '../../../components/Roadtrip/corridorSearchModel' import { useSettingsStore } from '../../../store/settingsStore' import { useAuthStore } from '../../../store/authStore' +import { useListsSubTab, type ListsSubTab } from '../../../hooks/useListsSubTab' import { canManageDocSync } from '../../../components/Files/docsync/useDocSync' import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered' import type { Day, Trip } from '../../../types' @@ -46,7 +47,7 @@ export type MTripView = 'plan' | 'map' export type MTripMode = 'go' | 'edit' | 'browse' /** The road trip tab's own two halves: the chain, or the same map showing the stage. */ export type MRtView = 'list' | 'map' -export type MTripListsTab = 'packing' | 'todo' +export type MTripListsTab = ListsSubTab export type MTripCollabTab = 'chat' | 'notes' | 'links' | 'polls' /** @@ -221,10 +222,7 @@ export default function MTripShell({ const [mode, setMode] = useState('go') const [browseFromEdit, setBrowseFromEdit] = useState(false) const [sheet, setSheet] = useState(null) - const [listsTab, setListsTabState] = useState(() => { - const saved = sessionStorage.getItem(`trip-lists-subtab-${tripId}`) - return saved === 'todo' ? 'todo' : 'packing' - }) + const [listsTab, setListsTab] = useListsSubTab(tripId, { onlyKnownTabs: true }) const [collabTab, setCollabTab] = useState('chat') const [transportsCompact, setTransportsCompact] = useState(false) const [bookingsCompact, setBookingsCompact] = useState(false) @@ -386,11 +384,6 @@ export default function MTripShell({ const mapFront = (trTab === 'plan' && view === 'map') || (trTab === 'roadtrip' && rtView === 'map') - const setListsTab = (tab: MTripListsTab) => { - setListsTabState(tab) - sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) - } - const openSheet = (id: string, payload?: unknown) => setSheet({ id, payload }) const closeSheet = () => setSheet(null) diff --git a/client/src/pages/tripPlanner/ListsContainer.tsx b/client/src/pages/tripPlanner/ListsContainer.tsx index 3ca7ceed8..80cba31eb 100644 --- a/client/src/pages/tripPlanner/ListsContainer.tsx +++ b/client/src/pages/tripPlanner/ListsContainer.tsx @@ -6,15 +6,13 @@ import { useTranslation } from '../../i18n' import { useAuthStore } from '../../store/authStore' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' +import { useListsSubTab } from '../../hooks/useListsSubTab' import type { PackingItem, TodoItem } from '../../types' import { PackingListPanel, TodoListPanel } from './plannerLazy' import { LazyPanel } from './LazyPanel' export function ListsContainer({ tripId, packingItems, todoItems }: Readonly<{ tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }>) { - const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => { - return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing' - }) - const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) } + const [subTab, setSubTabPersist] = useListsSubTab(tripId) const [importPackingSignal, setImportPackingSignal] = useState(0) const [addCategorySignal, setAddCategorySignal] = useState(0) const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)