diff --git a/client/src/utils/connectionsVisibility.test.ts b/client/src/utils/connectionsVisibility.test.ts new file mode 100644 index 000000000..086e25b45 --- /dev/null +++ b/client/src/utils/connectionsVisibility.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest' +import { + parseStoredConnections, resolveEffectiveConnections, resolveVisibleConnectionIds, + toggleConnectionId, toggleAllConnections, +} from './connectionsVisibility' + +describe('parseStoredConnections', () => { + it('returns null when nothing was ever stored', () => { + expect(parseStoredConnections(null)).toBeNull() + }) + + it('reads a legacy bare-array value as "only" mode', () => { + expect(parseStoredConnections('[1,2,3]')).toEqual({ mode: 'only', ids: [1, 2, 3] }) + }) + + it('reads an empty legacy array as "only" mode with no ids', () => { + expect(parseStoredConnections('[]')).toEqual({ mode: 'only', ids: [] }) + }) + + it('reads the tagged "only" object format', () => { + expect(parseStoredConnections('{"mode":"only","ids":[5]}')).toEqual({ mode: 'only', ids: [5] }) + }) + + it('reads the tagged "all-except" object format', () => { + expect(parseStoredConnections('{"mode":"all-except","ids":[7,8]}')).toEqual({ mode: 'all-except', ids: [7, 8] }) + }) + + it('returns null for invalid JSON', () => { + expect(parseStoredConnections('not json')).toBeNull() + }) + + it('returns null for a well-formed but unrecognized shape', () => { + expect(parseStoredConnections('{"foo":"bar"}')).toBeNull() + expect(parseStoredConnections('{"mode":"bogus","ids":[1]}')).toBeNull() + expect(parseStoredConnections('null')).toBeNull() + expect(parseStoredConnections('42')).toBeNull() + }) +}) + +describe('resolveEffectiveConnections', () => { + it('returns the stored preference unchanged when present, regardless of the account default', () => { + const stored = { mode: 'only' as const, ids: [1] } + expect(resolveEffectiveConnections(stored, true)).toEqual(stored) + expect(resolveEffectiveConnections(stored, false)).toEqual(stored) + }) + + it('falls back to "all-except []" when nothing is stored and the account default is on', () => { + expect(resolveEffectiveConnections(null, true)).toEqual({ mode: 'all-except', ids: [] }) + }) + + it('falls back to "only []" when nothing is stored and the account default is off', () => { + expect(resolveEffectiveConnections(null, false)).toEqual({ mode: 'only', ids: [] }) + }) +}) + +describe('resolveVisibleConnectionIds', () => { + it('"only" mode shows exactly the stored ids, independent of what is routable', () => { + expect(resolveVisibleConnectionIds({ mode: 'only', ids: [2, 4] }, [1, 2, 3, 4, 5])).toEqual([2, 4]) + }) + + it('"all-except" mode shows every routable id except the stored ones', () => { + expect(resolveVisibleConnectionIds({ mode: 'all-except', ids: [3] }, [1, 2, 3, 4])).toEqual([1, 2, 4]) + }) + + it('"all-except []" shows every routable id', () => { + expect(resolveVisibleConnectionIds({ mode: 'all-except', ids: [] }, [1, 2, 3])).toEqual([1, 2, 3]) + }) +}) + +describe('toggleConnectionId', () => { + it('materializes the account default before toggling when nothing is stored yet (default on)', () => { + // Default on -> effective mode is 'all-except []'; turning id 5 off adds it to the exceptions. + expect(toggleConnectionId(null, true, 5)).toEqual({ mode: 'all-except', ids: [5] }) + }) + + it('materializes the account default before toggling when nothing is stored yet (default off)', () => { + // Default off -> effective mode is 'only []'; turning id 5 on adds it to the inclusion list. + expect(toggleConnectionId(null, false, 5)).toEqual({ mode: 'only', ids: [5] }) + }) + + it('adds an id in "only" mode', () => { + expect(toggleConnectionId({ mode: 'only', ids: [1] }, false, 2)).toEqual({ mode: 'only', ids: [1, 2] }) + }) + + it('removes an id already present in "only" mode', () => { + expect(toggleConnectionId({ mode: 'only', ids: [1, 2] }, false, 2)).toEqual({ mode: 'only', ids: [1] }) + }) + + it('adds an id to the exception list in "all-except" mode (hides it)', () => { + expect(toggleConnectionId({ mode: 'all-except', ids: [] }, true, 9)).toEqual({ mode: 'all-except', ids: [9] }) + }) + + it('removes an id from the exception list in "all-except" mode (re-shows it)', () => { + expect(toggleConnectionId({ mode: 'all-except', ids: [9] }, true, 9)).toEqual({ mode: 'all-except', ids: [] }) + }) +}) + +describe('toggleAllConnections', () => { + it('flips "only" to "all-except []"', () => { + expect(toggleAllConnections({ mode: 'only', ids: [1, 2] }, false)).toEqual({ mode: 'all-except', ids: [] }) + }) + + it('flips "all-except" to "only []"', () => { + expect(toggleAllConnections({ mode: 'all-except', ids: [3] }, false)).toEqual({ mode: 'only', ids: [] }) + }) + + it('materializes the account default first when nothing is stored yet, then flips it', () => { + // Default on -> effective is 'all-except []' -> flips to 'only []'. + expect(toggleAllConnections(null, true)).toEqual({ mode: 'only', ids: [] }) + // Default off -> effective is 'only []' -> flips to 'all-except []'. + expect(toggleAllConnections(null, false)).toEqual({ mode: 'all-except', ids: [] }) + }) +}) diff --git a/client/src/utils/connectionsVisibility.ts b/client/src/utils/connectionsVisibility.ts new file mode 100644 index 000000000..edacd326d --- /dev/null +++ b/client/src/utils/connectionsVisibility.ts @@ -0,0 +1,89 @@ +// Per-trip booking-route visibility, persisted in localStorage under +// `trek:visible-connections:` (see useTripPlanner.ts). Two modes: +// 'only' — nothing shown except `ids` +// 'all-except' — everything routable shown except `ids` +// The account-wide "always show booking routes" setting only decides which +// mode a trip starts in the first time it's ever touched (see +// resolveEffectiveConnections) — once a trip has a stored preference, that +// preference always wins, regardless of the account setting's current value. + +export type ConnectionsMode = 'only' | 'all-except' + +export interface StoredConnections { + mode: ConnectionsMode + ids: number[] +} + +function isNumberArray(v: unknown): v is number[] { + return Array.isArray(v) && v.every(n => typeof n === 'number') +} + +/** + * Parses the raw localStorage value for a trip's route-visibility + * preference. Returns null when nothing has ever been stored (the caller + * should fall back to the account-wide default in that case — see + * resolveEffectiveConnections). Accepts the legacy bare-array format (from + * before the 'all-except' mode existed) as an 'only' mode for backward + * compatibility with values already sitting in users' browsers. + */ +export function parseStoredConnections(raw: string | null): StoredConnections | null { + if (raw === null) return null + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (isNumberArray(parsed)) return { mode: 'only', ids: parsed } + if ( + parsed && typeof parsed === 'object' && !Array.isArray(parsed) && + ((parsed as { mode?: unknown }).mode === 'only' || (parsed as { mode?: unknown }).mode === 'all-except') && + isNumberArray((parsed as { ids?: unknown }).ids) + ) { + const obj = parsed as { mode: ConnectionsMode; ids: number[] } + return { mode: obj.mode, ids: obj.ids } + } + return null +} + +/** + * The mode/ids actually in effect for a trip: the stored preference if the + * trip has one, otherwise the account-wide default (all routes shown, or + * none) as an ephemeral, unwritten fallback. + */ +export function resolveEffectiveConnections(stored: StoredConnections | null, alwaysShowRoutesDefault: boolean): StoredConnections { + if (stored) return stored + return alwaysShowRoutesDefault ? { mode: 'all-except', ids: [] } : { mode: 'only', ids: [] } +} + +/** The concrete set of reservation ids visible right now. */ +export function resolveVisibleConnectionIds(effective: StoredConnections, routableIds: number[]): number[] { + if (effective.mode === 'only') return effective.ids + const excluded = new Set(effective.ids) + return routableIds.filter(id => !excluded.has(id)) +} + +/** + * Applies one reservation's toggle to the current stored preference, + * materializing the account-default fallback first if the trip has no + * stored preference yet. Toggling just flips the id's membership in `ids` — + * under 'only' that turns it on/off directly; under 'all-except' it + * removes/re-adds it from the exception list. + */ +export function toggleConnectionId(stored: StoredConnections | null, alwaysShowRoutesDefault: boolean, id: number): StoredConnections { + const base = resolveEffectiveConnections(stored, alwaysShowRoutesDefault) + const ids = base.ids.includes(id) ? base.ids.filter(x => x !== id) : [...base.ids, id] + return { mode: base.mode, ids } +} + +/** + * Flips the whole trip between "show everything" and "show nothing" (the + * toolbar's bulk show-all/hide-all button), materializing the account + * default first if needed. Always starts the new mode with an empty + * ids list — an explicit bulk action discards whatever per-leg overrides + * applied to the mode it's leaving. + */ +export function toggleAllConnections(stored: StoredConnections | null, alwaysShowRoutesDefault: boolean): StoredConnections { + const base = resolveEffectiveConnections(stored, alwaysShowRoutesDefault) + return base.mode === 'all-except' ? { mode: 'only', ids: [] } : { mode: 'all-except', ids: [] } +}